From 4b97f4a6c9c92ccc18e2d84726e54761132ed6f2 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Mon, 20 Apr 2026 00:19:22 -0500 Subject: [PATCH] Feat: Reworked ENTIRE Project for Readability --- Todo | 1 + src/App.svelte | 523 +-- src/{lib => api}/client.ts | 42 +- src/api/index.ts | 11 + src/api/mutations/chapters.ts | 48 + src/api/mutations/downloads.ts | 83 + src/api/mutations/extensions.ts | 89 + src/api/mutations/index.ts | 5 + src/api/mutations/manga.ts | 91 + src/api/mutations/mutations.md | 450 +++ src/api/mutations/tracking.ts | 80 + src/api/queries/chapters.ts | 10 + src/api/queries/downloads.ts | 14 + src/api/queries/extensions.ts | 35 + src/api/queries/index.ts | 5 + src/api/queries/manga.ts | 96 + src/api/queries/queries.md | 171 + src/api/queries/tracking.ts | 69 + src/components/pages/Downloads.svelte | 182 - src/components/pages/Extensions.svelte | 393 -- src/components/pages/Home.svelte | 669 ---- src/components/pages/Library.svelte | 1283 ------- src/components/pages/Search.svelte | 1384 ------- src/components/pages/Tracking.svelte | 894 ----- src/components/reader/Reader.svelte | 1469 -------- src/components/series/SeriesDetail.svelte | 1342 ------- src/components/settings/Settings.svelte | 3188 ----------------- src/components/settings/ThemeEditor.svelte | 575 --- src/components/shared/MangaPreview.svelte | 618 ---- src/core/algorithms/filter.ts | 50 + src/core/algorithms/index.ts | 5 + src/core/algorithms/paginate.ts | 29 + src/core/algorithms/queue.ts | 29 + src/core/algorithms/search.ts | 33 + src/core/algorithms/sort.ts | 32 + src/core/async/batchRequests.ts | 61 + src/core/async/createPaginatedQuery.ts | 25 + src/core/async/fetchWithRetry.ts | 31 + src/core/async/index.ts | 3 + src/{lib => core}/auth.ts | 44 +- src/{lib => core/cache}/imageCache.ts | 40 +- src/core/cache/index.ts | 3 + src/core/cache/memoryCache.ts | 0 src/core/cache/queryCache.ts | 161 + .../keybinds/defaultBinds.ts} | 27 - src/core/keybinds/index.ts | 3 + src/core/keybinds/keybindEngine.ts | 25 + src/core/theme.ts | 36 + src/core/ui/idle.ts | 23 + src/core/ui/index.ts | 2 + src/core/ui/zoom.ts | 40 + src/core/updater.ts | 40 + src/{lib => core}/util.ts | 182 +- src/{styles => design/base}/animations.css | 27 +- src/design/base/index.css | 4 + src/design/base/reset.css | 41 + src/design/base/scrollbars.css | 9 + src/design/base/typography.css | 9 + src/design/themes/high-contrast.css | 25 + src/design/themes/index.css | 5 + src/design/themes/light-contrast.css | 29 + src/design/themes/light.css | 32 + src/design/themes/midnight.css | 25 + src/design/themes/warm.css | 25 + src/design/tokens/colors.css | 35 + src/design/tokens/index.css | 8 + src/design/tokens/motion.css | 5 + src/design/tokens/radius.css | 8 + src/design/tokens/shadows.css | 2 + src/design/tokens/spacing.css | 12 + src/design/tokens/typography.css | 28 + src/design/tokens/zindex.css | 5 + src/design/utilities/layout.css | 0 src/design/utilities/text.css | 0 src/design/utilities/visibility.css | 0 .../components}/GenreDrillPage.svelte | 152 +- .../discover/components/KeywordTab.svelte | 330 ++ .../discover/components/Search.svelte | 298 ++ .../discover/components/SourceTab.svelte | 285 ++ .../discover/components/TagTab.svelte | 474 +++ src/features/discover/index.ts | 2 + src/features/discover/lib/searchFilter.ts | 138 + .../downloads/components/DownloadItem.svelte | 145 + .../downloads/components/DownloadQueue.svelte | 51 + .../downloads/components/Downloads.svelte | 142 + src/features/downloads/index.ts | 2 + src/features/downloads/lib/downloadPoller.ts | 61 + src/features/downloads/lib/downloadQueue.ts | 25 + .../downloads/store/downloadState.svelte.ts | 69 + .../components/ExtensionCard.svelte | 107 + .../components/ExtensionFilters.svelte | 87 + .../extensions/components/Extensions.svelte | 272 ++ src/features/extensions/index.ts | 2 + .../extensions/lib/extensionHelpers.ts | 55 + .../home/components/ActivityFeed.svelte | 194 + .../home/components/HeroSlotPicker.svelte | 194 + src/features/home/components/HeroStage.svelte | 581 +++ src/features/home/components/Home.svelte | 312 ++ src/features/home/components/StatsGrid.svelte | 132 + .../home/components/UpdatesRow.svelte | 187 + src/features/home/index.ts | 0 src/features/home/lib/homeHelpers.ts | 35 + .../library/components/Library.svelte | 628 ++++ .../library/components/LibraryFilters.svelte | 113 + .../library/components/LibraryGrid.svelte | 220 ++ .../library/components/LibraryToolbar.svelte | 241 ++ src/features/library/index.ts | 3 + src/features/library/lib/librarySort.ts | 52 + .../library/store/libraryState.svelte.ts | 45 + .../reader/components/PageView.svelte | 215 ++ src/features/reader/components/Reader.svelte | 513 +++ .../reader/components/ReaderControls.svelte | 373 ++ .../reader/components/ReaderOverlay.svelte | 84 + .../components/ReaderProgressBar.svelte | 112 + src/features/reader/index.ts | 0 src/features/reader/lib/chapterActions.ts | 76 + src/features/reader/lib/chapterLoader.ts | 48 + src/features/reader/lib/index.ts | 14 + src/features/reader/lib/navigation.ts | 84 + src/features/reader/lib/pageLoader.ts | 103 + src/features/reader/lib/readerKeybinds.ts | 59 + src/features/reader/lib/scrollHandler.ts | 110 + src/features/reader/lib/zoomHelpers.ts | 38 + .../reader/store/readerState.svelte.ts | 107 + .../series/components/ChapterList.svelte | 173 + .../series/components/SeriesActions.svelte | 642 ++++ .../series/components/SeriesDetail.svelte | 778 ++++ .../series/components/SeriesHeader.svelte | 313 ++ src/features/series/index.ts | 10 + src/features/series/lib/chapterList.ts | 79 + src/features/series/lib/mangaPrefs.ts | 17 + .../series/panels}/AutomationPanel.svelte | 95 +- .../series/panels}/MarkersPanel.svelte | 12 +- .../series/panels}/MigrateModal.svelte | 160 +- .../series/panels}/TrackingPanel.svelte | 175 +- src/features/settings/components/Settings.css | 1305 +++++++ .../settings/components/Settings.svelte | 173 + .../settings/components/ThemeEditor.svelte | 501 +++ src/features/settings/index.ts | 0 .../settings/sections/AboutSettings.svelte | 216 ++ .../sections/AppearanceSettings.svelte | 93 + .../settings/sections/ContentSettings.svelte | 170 + .../settings/sections/DevtoolsSettings.svelte | 131 + .../settings/sections/FoldersSettings.svelte | 158 + .../settings/sections/GeneralSettings.svelte | 110 + .../settings/sections/KeybindsSettings.svelte | 53 + .../settings/sections/LibrarySettings.svelte | 65 + .../sections/PerformanceSettings.svelte | 152 + .../settings/sections/ReaderSettings.svelte | 141 + .../settings/sections/SecuritySettings.svelte | 361 ++ .../settings/sections/StorageSettings.svelte | 629 ++++ .../settings/sections/TrackingSettings.svelte | 151 + .../tracking/components/Tracking.svelte | 667 ++++ src/features/tracking/index.ts | 2 + src/features/tracking/lib/trackingSync.ts | 111 + src/lib/cache.ts | 275 -- src/lib/chapterList.ts | 34 - src/lib/discord.ts | 79 - src/lib/queries.ts | 1001 ------ src/lib/types.ts | 148 - src/main.ts | 5 +- src/routes.ts | 15 - src/shared/chrome/AuthGate.svelte | 85 + .../chrome/Layout.svelte | 28 +- .../chrome/RecentActivity.svelte | 85 +- .../chrome/Sidebar.svelte | 8 +- .../chrome/SplashScreen.svelte | 67 +- .../chrome/TitleBar.svelte | 80 +- .../chrome/Toaster.svelte | 123 +- src/shared/manga/MangaPreview.svelte | 967 +++++ .../manga}/SourceBrowse.svelte | 23 +- .../shared => shared/manga}/ThreeDCard.svelte | 54 +- .../shared => shared/manga}/Thumbnail.svelte | 24 +- .../shared => shared/ui}/ContextMenu.svelte | 26 +- .../shared => shared/ui}/SourceList.svelte | 99 +- src/store/app.svelte.ts | 22 + src/store/boot.svelte.ts | 107 + src/store/discord.ts | 69 + src/store/index.ts | 4 + src/store/notifications.svelte.ts | 36 + src/store/state.svelte.ts | 903 ++--- src/styles/global.css | 54 - src/styles/tokens.css | 262 -- src/types/api.ts | 20 + src/types/chapter.ts | 15 + src/types/extension.ts | 20 + src/types/index.ts | 5 + src/types/manga.ts | 34 + src/types/settings.ts | 0 src/types/tracking.ts | 45 + vite.config.ts | 12 +- 191 files changed, 19210 insertions(+), 15915 deletions(-) rename src/{lib => api}/client.ts (72%) create mode 100644 src/api/index.ts create mode 100644 src/api/mutations/chapters.ts create mode 100644 src/api/mutations/downloads.ts create mode 100644 src/api/mutations/extensions.ts create mode 100644 src/api/mutations/index.ts create mode 100644 src/api/mutations/manga.ts create mode 100644 src/api/mutations/mutations.md create mode 100644 src/api/mutations/tracking.ts create mode 100644 src/api/queries/chapters.ts create mode 100644 src/api/queries/downloads.ts create mode 100644 src/api/queries/extensions.ts create mode 100644 src/api/queries/index.ts create mode 100644 src/api/queries/manga.ts create mode 100644 src/api/queries/queries.md create mode 100644 src/api/queries/tracking.ts delete mode 100644 src/components/pages/Downloads.svelte delete mode 100644 src/components/pages/Extensions.svelte delete mode 100644 src/components/pages/Home.svelte delete mode 100644 src/components/pages/Library.svelte delete mode 100644 src/components/pages/Search.svelte delete mode 100644 src/components/pages/Tracking.svelte delete mode 100644 src/components/reader/Reader.svelte delete mode 100644 src/components/series/SeriesDetail.svelte delete mode 100644 src/components/settings/Settings.svelte delete mode 100644 src/components/settings/ThemeEditor.svelte delete mode 100644 src/components/shared/MangaPreview.svelte create mode 100644 src/core/algorithms/filter.ts create mode 100644 src/core/algorithms/index.ts create mode 100644 src/core/algorithms/paginate.ts create mode 100644 src/core/algorithms/queue.ts create mode 100644 src/core/algorithms/search.ts create mode 100644 src/core/algorithms/sort.ts create mode 100644 src/core/async/batchRequests.ts create mode 100644 src/core/async/createPaginatedQuery.ts create mode 100644 src/core/async/fetchWithRetry.ts create mode 100644 src/core/async/index.ts rename src/{lib => core}/auth.ts (74%) rename src/{lib => core/cache}/imageCache.ts (78%) create mode 100644 src/core/cache/index.ts create mode 100644 src/core/cache/memoryCache.ts create mode 100644 src/core/cache/queryCache.ts rename src/{lib/keybinds.ts => core/keybinds/defaultBinds.ts} (67%) create mode 100644 src/core/keybinds/index.ts create mode 100644 src/core/keybinds/keybindEngine.ts create mode 100644 src/core/theme.ts create mode 100644 src/core/ui/idle.ts create mode 100644 src/core/ui/index.ts create mode 100644 src/core/ui/zoom.ts create mode 100644 src/core/updater.ts rename src/{lib => core}/util.ts (51%) rename src/{styles => design/base}/animations.css (51%) create mode 100644 src/design/base/index.css create mode 100644 src/design/base/reset.css create mode 100644 src/design/base/scrollbars.css create mode 100644 src/design/base/typography.css create mode 100644 src/design/themes/high-contrast.css create mode 100644 src/design/themes/index.css create mode 100644 src/design/themes/light-contrast.css create mode 100644 src/design/themes/light.css create mode 100644 src/design/themes/midnight.css create mode 100644 src/design/themes/warm.css create mode 100644 src/design/tokens/colors.css create mode 100644 src/design/tokens/index.css create mode 100644 src/design/tokens/motion.css create mode 100644 src/design/tokens/radius.css create mode 100644 src/design/tokens/shadows.css create mode 100644 src/design/tokens/spacing.css create mode 100644 src/design/tokens/typography.css create mode 100644 src/design/tokens/zindex.css create mode 100644 src/design/utilities/layout.css create mode 100644 src/design/utilities/text.css create mode 100644 src/design/utilities/visibility.css rename src/{components/pages => features/discover/components}/GenreDrillPage.svelte (71%) create mode 100644 src/features/discover/components/KeywordTab.svelte create mode 100644 src/features/discover/components/Search.svelte create mode 100644 src/features/discover/components/SourceTab.svelte create mode 100644 src/features/discover/components/TagTab.svelte create mode 100644 src/features/discover/index.ts create mode 100644 src/features/discover/lib/searchFilter.ts create mode 100644 src/features/downloads/components/DownloadItem.svelte create mode 100644 src/features/downloads/components/DownloadQueue.svelte create mode 100644 src/features/downloads/components/Downloads.svelte create mode 100644 src/features/downloads/index.ts create mode 100644 src/features/downloads/lib/downloadPoller.ts create mode 100644 src/features/downloads/lib/downloadQueue.ts create mode 100644 src/features/downloads/store/downloadState.svelte.ts create mode 100644 src/features/extensions/components/ExtensionCard.svelte create mode 100644 src/features/extensions/components/ExtensionFilters.svelte create mode 100644 src/features/extensions/components/Extensions.svelte create mode 100644 src/features/extensions/index.ts create mode 100644 src/features/extensions/lib/extensionHelpers.ts create mode 100644 src/features/home/components/ActivityFeed.svelte create mode 100644 src/features/home/components/HeroSlotPicker.svelte create mode 100644 src/features/home/components/HeroStage.svelte create mode 100644 src/features/home/components/Home.svelte create mode 100644 src/features/home/components/StatsGrid.svelte create mode 100644 src/features/home/components/UpdatesRow.svelte create mode 100644 src/features/home/index.ts create mode 100644 src/features/home/lib/homeHelpers.ts create mode 100644 src/features/library/components/Library.svelte create mode 100644 src/features/library/components/LibraryFilters.svelte create mode 100644 src/features/library/components/LibraryGrid.svelte create mode 100644 src/features/library/components/LibraryToolbar.svelte create mode 100644 src/features/library/index.ts create mode 100644 src/features/library/lib/librarySort.ts create mode 100644 src/features/library/store/libraryState.svelte.ts create mode 100644 src/features/reader/components/PageView.svelte create mode 100644 src/features/reader/components/Reader.svelte create mode 100644 src/features/reader/components/ReaderControls.svelte create mode 100644 src/features/reader/components/ReaderOverlay.svelte create mode 100644 src/features/reader/components/ReaderProgressBar.svelte create mode 100644 src/features/reader/index.ts create mode 100644 src/features/reader/lib/chapterActions.ts create mode 100644 src/features/reader/lib/chapterLoader.ts create mode 100644 src/features/reader/lib/index.ts create mode 100644 src/features/reader/lib/navigation.ts create mode 100644 src/features/reader/lib/pageLoader.ts create mode 100644 src/features/reader/lib/readerKeybinds.ts create mode 100644 src/features/reader/lib/scrollHandler.ts create mode 100644 src/features/reader/lib/zoomHelpers.ts create mode 100644 src/features/reader/store/readerState.svelte.ts create mode 100644 src/features/series/components/ChapterList.svelte create mode 100644 src/features/series/components/SeriesActions.svelte create mode 100644 src/features/series/components/SeriesDetail.svelte create mode 100644 src/features/series/components/SeriesHeader.svelte create mode 100644 src/features/series/index.ts create mode 100644 src/features/series/lib/chapterList.ts create mode 100644 src/features/series/lib/mangaPrefs.ts rename src/{components/series => features/series/panels}/AutomationPanel.svelte (76%) rename src/{components/series => features/series/panels}/MarkersPanel.svelte (97%) rename src/{components/series => features/series/panels}/MigrateModal.svelte (87%) rename src/{components/series => features/series/panels}/TrackingPanel.svelte (84%) create mode 100644 src/features/settings/components/Settings.css create mode 100644 src/features/settings/components/Settings.svelte create mode 100644 src/features/settings/components/ThemeEditor.svelte create mode 100644 src/features/settings/index.ts create mode 100644 src/features/settings/sections/AboutSettings.svelte create mode 100644 src/features/settings/sections/AppearanceSettings.svelte create mode 100644 src/features/settings/sections/ContentSettings.svelte create mode 100644 src/features/settings/sections/DevtoolsSettings.svelte create mode 100644 src/features/settings/sections/FoldersSettings.svelte create mode 100644 src/features/settings/sections/GeneralSettings.svelte create mode 100644 src/features/settings/sections/KeybindsSettings.svelte create mode 100644 src/features/settings/sections/LibrarySettings.svelte create mode 100644 src/features/settings/sections/PerformanceSettings.svelte create mode 100644 src/features/settings/sections/ReaderSettings.svelte create mode 100644 src/features/settings/sections/SecuritySettings.svelte create mode 100644 src/features/settings/sections/StorageSettings.svelte create mode 100644 src/features/settings/sections/TrackingSettings.svelte create mode 100644 src/features/tracking/components/Tracking.svelte create mode 100644 src/features/tracking/index.ts create mode 100644 src/features/tracking/lib/trackingSync.ts delete mode 100644 src/lib/cache.ts delete mode 100644 src/lib/chapterList.ts delete mode 100644 src/lib/discord.ts delete mode 100644 src/lib/queries.ts delete mode 100644 src/lib/types.ts delete mode 100644 src/routes.ts create mode 100644 src/shared/chrome/AuthGate.svelte rename src/{components => shared}/chrome/Layout.svelte (53%) rename src/{components => shared}/chrome/RecentActivity.svelte (77%) rename src/{components => shared}/chrome/Sidebar.svelte (88%) rename src/{components => shared}/chrome/SplashScreen.svelte (91%) rename src/{components => shared}/chrome/TitleBar.svelte (61%) rename src/{components => shared}/chrome/Toaster.svelte (50%) create mode 100644 src/shared/manga/MangaPreview.svelte rename src/{components/shared => shared/manga}/SourceBrowse.svelte (94%) rename src/{components/shared => shared/manga}/ThreeDCard.svelte (66%) rename src/{components/shared => shared/manga}/Thumbnail.svelte (58%) rename src/{components/shared => shared/ui}/ContextMenu.svelte (84%) rename src/{components/shared => shared/ui}/SourceList.svelte (67%) create mode 100644 src/store/app.svelte.ts create mode 100644 src/store/boot.svelte.ts create mode 100644 src/store/discord.ts create mode 100644 src/store/index.ts create mode 100644 src/store/notifications.svelte.ts delete mode 100644 src/styles/global.css delete mode 100644 src/styles/tokens.css create mode 100644 src/types/api.ts create mode 100644 src/types/chapter.ts create mode 100644 src/types/extension.ts create mode 100644 src/types/index.ts create mode 100644 src/types/manga.ts create mode 100644 src/types/settings.ts create mode 100644 src/types/tracking.ts diff --git a/Todo b/Todo index 5dcade0..e34e861 100644 --- a/Todo +++ b/Todo @@ -40,6 +40,7 @@ In-Progress: - Add Flathub Support (Pending Video) + - Currently Moku Migration is on Series. Finish Modularizing then fix rest of files. Testing: \ No newline at end of file diff --git a/src/App.svelte b/src/App.svelte index 8a1f22a..876a2d2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,60 +1,32 @@ {#if devSplash} setTimeout(() => devSplash = false, 340)} /> -{:else if !appReady && !loginRequired && !unsupportedMode} - { appReady = true; }} - onRetry={handleRetry} - onBypass={handleBypass} /> -{:else if unsupportedMode} + onRetry={retryBoot} + onBypass={() => bypassBoot(() => { appReady = true; })} /> + +{:else if boot.unsupportedMode || boot.loginRequired} -
-
- -

moku

- { - store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : - store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth" - } -

{store.settings.serverUrl || "localhost:4567"}

-

- { - store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : - store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode" - } is not supported. Switch your server to Basic Auth and update Settings → Security. -

- -
-
-{:else if loginRequired} - -
-
- -

moku

- Basic Auth -

{store.settings.serverUrl || "localhost:4567"}

- {#if loginError} -

{loginError}

- {/if} -
- e.key === "Enter" && handleLogin()} /> - e.key === "Enter" && handleLogin()} /> -
- - -
-
+ { appReady = true; }} /> + {:else} + {#if idle && !store.activeChapter} + { idle = false; }} /> + {/if} +
- {#if idle && !store.activeChapter} - { idle = false; resetIdle(); }} /> - {/if} {#if !store.activeChapter}{/if}
{#if store.activeChapter}{:else}{/if}
{#if store.settingsOpen}{/if} {#if themeEditorOpen} - + {/if} @@ -471,28 +158,6 @@ {/if} + \ No newline at end of file diff --git a/src/lib/client.ts b/src/api/client.ts similarity index 72% rename from src/lib/client.ts rename to src/api/client.ts index fd3f94a..2a935c0 100644 --- a/src/lib/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ -import { store } from "../store/state.svelte"; -import { fetchAuthenticated } from "./auth"; +import { store } from "@store/state.svelte"; +import { fetchAuthenticated } from "../core/auth"; const DEFAULT_URL = "http://127.0.0.1:4567"; @@ -8,20 +8,16 @@ function getServerUrl(): string { return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; } -function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } - export function plainThumbUrl(path: string): string { if (!path) return ""; if (path.startsWith("http")) return path; return `${getServerUrl()}${path}`; } -export function thumbUrl(path: string): string { - return plainThumbUrl(path); -} +export const thumbUrl = plainThumbUrl; interface GQLResponse { - data: T; + data: T; errors?: { message: string }[]; } @@ -37,14 +33,13 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise { } async function fetchWithRetry( - url: string, - init: RequestInit, + url: string, + init: RequestInit, signal?: AbortSignal, - retries = 3, - delayMs = 300, + retries = 3, + delayMs = 300, ): Promise { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - for (let i = 0; i < retries; i++) { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); try { @@ -53,8 +48,7 @@ async function fetchWithRetry( return res; } catch (e: any) { if (e?.authRequired) throw e; - const isAbort = e?.name === "AbortError" || signal?.aborted; - if (isAbort) throw new DOMException("Aborted", "AbortError"); + if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (i === retries - 1) throw e; await abortableSleep(delayMs * Math.pow(1.5, i), signal); } @@ -63,23 +57,19 @@ async function fetchWithRetry( } export async function gql( - query: string, + query: string, variables?: Record, - signal?: AbortSignal, + signal?: AbortSignal, ): Promise { - const res = await fetchWithRetry(gqlUrl(), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query, variables }), - }, signal); - + const res = await fetchWithRetry( + `${getServerUrl()}/api/graphql`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) }, + signal, + ); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); - const json: GQLResponse = await res.json(); - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (json.errors?.length) throw new Error(json.errors[0].message); - return json.data; } diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..2552de0 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,11 @@ +export * from "./client"; +export * from "./queries/manga"; +export * from "./queries/chapters"; +export * from "./queries/downloads"; +export * from "./queries/extensions"; +export * from "./queries/tracking"; +export * from "./mutations/manga"; +export * from "./mutations/chapters"; +export * from "./mutations/downloads"; +export * from "./mutations/extensions"; +export * from "./mutations/tracking"; diff --git a/src/api/mutations/chapters.ts b/src/api/mutations/chapters.ts new file mode 100644 index 0000000..b2ea201 --- /dev/null +++ b/src/api/mutations/chapters.ts @@ -0,0 +1,48 @@ +export const FETCH_CHAPTERS = ` + mutation FetchChapters($mangaId: Int!) { + fetchChapters(input: { mangaId: $mangaId }) { + chapters { + id name chapterNumber sourceOrder isRead isDownloaded isBookmarked + pageCount mangaId uploadDate realUrl lastPageRead scanlator + } + } + } +`; + +export const FETCH_CHAPTER_PAGES = ` + mutation FetchChapterPages($chapterId: Int!) { + fetchChapterPages(input: { chapterId: $chapterId }) { pages } + } +`; + +export const MARK_CHAPTER_READ = ` + mutation MarkChapterRead($id: Int!, $isRead: Boolean!) { + updateChapter(input: { id: $id, patch: { isRead: $isRead } }) { + chapter { id isRead } + } + } +`; + +export const MARK_CHAPTERS_READ = ` + mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) { + updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) { + chapters { id isRead } + } + } +`; + +export const UPDATE_CHAPTERS_PROGRESS = ` + mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) { + updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) { + chapters { id isRead isBookmarked lastPageRead } + } + } +`; + +export const DELETE_DOWNLOADED_CHAPTERS = ` + mutation DeleteDownloadedChapters($ids: [Int!]!) { + deleteDownloadedChapters(input: { ids: $ids }) { + chapters { id isDownloaded } + } + } +`; diff --git a/src/api/mutations/downloads.ts b/src/api/mutations/downloads.ts new file mode 100644 index 0000000..5dcff0f --- /dev/null +++ b/src/api/mutations/downloads.ts @@ -0,0 +1,83 @@ +const QUEUE_FRAGMENT = ` + state + queue { + progress state + chapter { + id name pageCount mangaId + manga { id title thumbnailUrl } + } + } +`; + +export const ENQUEUE_DOWNLOAD = ` + mutation EnqueueDownload($chapterId: Int!) { + enqueueChapterDownload(input: { id: $chapterId }) { + downloadStatus { ${QUEUE_FRAGMENT} } + } + } +`; + +export const ENQUEUE_CHAPTERS_DOWNLOAD = ` + mutation EnqueueChaptersDownload($chapterIds: [Int!]!) { + enqueueChapterDownloads(input: { ids: $chapterIds }) { + downloadStatus { state } + } + } +`; + +export const DEQUEUE_DOWNLOAD = ` + mutation DequeueDownload($chapterId: Int!) { + dequeueChapterDownload(input: { id: $chapterId }) { + downloadStatus { state } + } + } +`; + +export const START_DOWNLOADER = ` + mutation StartDownloader { + startDownloader(input: {}) { + downloadStatus { ${QUEUE_FRAGMENT} } + } + } +`; + +export const STOP_DOWNLOADER = ` + mutation StopDownloader { + stopDownloader(input: {}) { + downloadStatus { ${QUEUE_FRAGMENT} } + } + } +`; + +export const CLEAR_DOWNLOADER = ` + mutation ClearDownloader { + clearDownloader(input: {}) { + downloadStatus { ${QUEUE_FRAGMENT} } + } + } +`; + +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 }) { + mangas { id title thumbnailUrl inLibrary } + hasNextPage + } + } +`; + +export const SET_DOWNLOADS_PATH = ` + mutation SetDownloadsPath($path: String!) { + setSettings(input: { settings: { downloadsPath: $path } }) { + settings { downloadsPath } + } + } +`; + +export const SET_LOCAL_SOURCE_PATH = ` + mutation SetLocalSourcePath($path: String!) { + setSettings(input: { settings: { localSourcePath: $path } }) { + settings { localSourcePath } + } + } +`; diff --git a/src/api/mutations/extensions.ts b/src/api/mutations/extensions.ts new file mode 100644 index 0000000..26e1d5d --- /dev/null +++ b/src/api/mutations/extensions.ts @@ -0,0 +1,89 @@ +export const FETCH_EXTENSIONS = ` + mutation FetchExtensions { + fetchExtensions(input: {}) { + extensions { + apkName pkgName name lang versionName + isInstalled isObsolete hasUpdate iconUrl + } + } + } +`; + +export const UPDATE_EXTENSION = ` + mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) { + updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) { + extension { apkName pkgName name isInstalled hasUpdate } + } + } +`; + +export const INSTALL_EXTERNAL_EXTENSION = ` + mutation InstallExternalExtension($url: String!) { + installExternalExtension(input: { extensionUrl: $url }) { + extension { apkName pkgName name isInstalled } + } + } +`; + +export const SET_EXTENSION_REPOS = ` + mutation SetExtensionRepos($repos: [String!]!) { + setSettings(input: { settings: { extensionRepos: $repos } }) { + settings { extensionRepos } + } + } +`; + +export const SET_SERVER_AUTH = ` + mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) { + setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) { + settings { authMode authUsername } + } + } +`; + +export const SET_SOCKS_PROXY = ` + mutation SetSocksProxy( + $socksProxyEnabled: Boolean! + $socksProxyHost: String! + $socksProxyPort: String! + $socksProxyVersion: Int! + $socksProxyUsername: String! + $socksProxyPassword: String! + ) { + setSettings(input: { settings: { + socksProxyEnabled: $socksProxyEnabled + socksProxyHost: $socksProxyHost + socksProxyPort: $socksProxyPort + socksProxyVersion: $socksProxyVersion + socksProxyUsername: $socksProxyUsername + socksProxyPassword: $socksProxyPassword + }}) { + settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername } + } + } +`; + +export const SET_FLARESOLVERR = ` + mutation SetFlareSolverr( + $flareSolverrEnabled: Boolean! + $flareSolverrUrl: String! + $flareSolverrTimeout: Int! + $flareSolverrSessionName: String! + $flareSolverrSessionTtl: Int! + $flareSolverrAsResponseFallback: Boolean! + ) { + setSettings(input: { settings: { + flareSolverrEnabled: $flareSolverrEnabled + flareSolverrUrl: $flareSolverrUrl + flareSolverrTimeout: $flareSolverrTimeout + flareSolverrSessionName: $flareSolverrSessionName + flareSolverrSessionTtl: $flareSolverrSessionTtl + flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback + }}) { + settings { + flareSolverrEnabled flareSolverrUrl flareSolverrTimeout + flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback + } + } + } +`; diff --git a/src/api/mutations/index.ts b/src/api/mutations/index.ts new file mode 100644 index 0000000..35767c1 --- /dev/null +++ b/src/api/mutations/index.ts @@ -0,0 +1,5 @@ +export * from "./manga"; +export * from "./chapters"; +export * from "./downloads"; +export * from "./extensions"; +export * from "./tracking"; diff --git a/src/api/mutations/manga.ts b/src/api/mutations/manga.ts new file mode 100644 index 0000000..176eca4 --- /dev/null +++ b/src/api/mutations/manga.ts @@ -0,0 +1,91 @@ +export const FETCH_MANGA = ` + mutation FetchManga($id: Int!) { + fetchManga(input: { id: $id }) { + manga { + id title description thumbnailUrl status author artist genre inLibrary realUrl + source { id name displayName } + } + } + } +`; + +export const UPDATE_MANGA = ` + mutation UpdateManga($id: Int!, $inLibrary: Boolean) { + updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) { + manga { id inLibrary } + } + } +`; + +export const UPDATE_MANGAS = ` + mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) { + updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) { + mangas { id inLibrary } + } + } +`; + +export const UPDATE_MANGA_CATEGORIES = ` + mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) { + updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) { + manga { id } + } + } +`; + +export const CREATE_CATEGORY = ` + mutation CreateCategory($name: String!) { + createCategory(input: { name: $name }) { + category { id name order default includeInUpdate includeInDownload } + } + } +`; + +export const UPDATE_CATEGORY = ` + mutation UpdateCategory($id: Int!, $name: String) { + updateCategory(input: { id: $id, patch: { name: $name } }) { + category { id name order } + } + } +`; + +export const DELETE_CATEGORY = ` + mutation DeleteCategory($id: Int!) { + deleteCategory(input: { categoryId: $id }) { + category { id } + } + } +`; + +export const UPDATE_CATEGORY_ORDER = ` + mutation UpdateCategoryOrder($id: Int!, $position: Int!) { + updateCategoryOrder(input: { id: $id, position: $position }) { + categories { id name order default includeInUpdate includeInDownload } + } + } +`; + +export const UPDATE_LIBRARY = ` + mutation UpdateLibrary { + updateLibrary(input: {}) { + updateStatus { + jobsInfo { isRunning finishedJobs totalJobs } + } + } + } +`; + +export const CREATE_BACKUP = ` + mutation CreateBackup { + createBackup(input: {}) { url } + } +`; + +export const RESTORE_BACKUP = ` + mutation RestoreBackup($backup: Upload!) { + restoreBackup(input: { backup: $backup }) { + id + status { mangaProgress state totalManga } + } + } +`; diff --git a/src/api/mutations/mutations.md b/src/api/mutations/mutations.md new file mode 100644 index 0000000..8098ef9 --- /dev/null +++ b/src/api/mutations/mutations.md @@ -0,0 +1,450 @@ +# Mutations + +## Manga (`mutations/manga.ts`) + +### `FETCH_MANGA` +Fetches and refreshes manga metadata from its source. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `Int!` | Manga ID | + +--- + +### `UPDATE_MANGA` +Updates a single manga's library membership. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `Int!` | Manga ID | +| `inLibrary` | `Boolean` | Add/remove from library | + +--- + +### `UPDATE_MANGAS` +Bulk-updates library membership for multiple manga. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `ids` | `[Int!]!` | Manga IDs | +| `inLibrary` | `Boolean` | Add/remove from library | + +--- + +### `UPDATE_MANGA_CATEGORIES` +Adds or removes a manga from categories. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `mangaId` | `Int!` | Manga ID | +| `addTo` | `[Int!]!` | Category IDs to add to | +| `removeFrom` | `[Int!]!` | Category IDs to remove from | + +--- + +### `CREATE_CATEGORY` +Creates a new manga category. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `name` | `String!` | Category name | + +--- + +### `UPDATE_CATEGORY` +Updates a category's name. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `Int!` | Category ID | +| `name` | `String` | New name | + +--- + +### `DELETE_CATEGORY` +Deletes a category by ID. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `Int!` | Category ID | + +--- + +### `UPDATE_CATEGORY_ORDER` +Moves a category to a new position. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `Int!` | Category ID | +| `position` | `Int!` | New position index | + +--- + +### `UPDATE_LIBRARY` +Triggers a library-wide metadata refresh and returns job status. + +**Variables:** none + +--- + +### `CREATE_BACKUP` +Creates a backup and returns its download URL. + +**Variables:** none + +--- + +### `RESTORE_BACKUP` +Restores a backup from an uploaded file and returns restore job status. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `backup` | `Upload!` | Backup file | + +--- + +## Chapters (`mutations/chapters.ts`) + +### `FETCH_CHAPTERS` +Fetches/refreshes the chapter list for a manga from its source. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `mangaId` | `Int!` | Manga ID | + +--- + +### `FETCH_CHAPTER_PAGES` +Fetches the page URLs for a specific chapter. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `chapterId` | `Int!` | Chapter ID | + +--- + +### `MARK_CHAPTER_READ` +Marks a single chapter as read or unread. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `Int!` | Chapter ID | +| `isRead` | `Boolean!` | Read state | + +--- + +### `MARK_CHAPTERS_READ` +Bulk-marks multiple chapters as read or unread. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `ids` | `[Int!]!` | Chapter IDs | +| `isRead` | `Boolean!` | Read state | + +--- + +### `UPDATE_CHAPTERS_PROGRESS` +Bulk-updates read state, bookmark state, and last page read for multiple chapters. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `ids` | `[Int!]!` | Chapter IDs | +| `isRead` | `Boolean` | Read state | +| `isBookmarked` | `Boolean` | Bookmark state | +| `lastPageRead` | `Int` | Last page index read | + +--- + +### `DELETE_DOWNLOADED_CHAPTERS` +Deletes downloaded chapter files for the given chapter IDs. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `ids` | `[Int!]!` | Chapter IDs | + +--- + +## Downloads (`mutations/downloads.ts`) + +### `ENQUEUE_DOWNLOAD` +Adds a single chapter to the download queue. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `chapterId` | `Int!` | Chapter ID | + +--- + +### `ENQUEUE_CHAPTERS_DOWNLOAD` +Adds multiple chapters to the download queue. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `chapterIds` | `[Int!]!` | Chapter IDs | + +--- + +### `DEQUEUE_DOWNLOAD` +Removes a chapter from the download queue. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `chapterId` | `Int!` | Chapter ID | + +--- + +### `START_DOWNLOADER` +Starts the downloader and returns the current queue state. + +**Variables:** none + +--- + +### `STOP_DOWNLOADER` +Stops the downloader and returns the current queue state. + +**Variables:** none + +--- + +### `CLEAR_DOWNLOADER` +Clears all items from the download queue. + +**Variables:** none + +--- + +### `FETCH_SOURCE_MANGA` +Fetches manga from a source (browse/search), with pagination and optional filters. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `source` | `LongString!` | Source ID | +| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) | +| `page` | `Int!` | Page number | +| `query` | `String` | Search query | +| `filters` | `[FilterChangeInput!]` | Source-specific filters | + +--- + +### `SET_DOWNLOADS_PATH` +Sets the downloads directory path in settings. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `path` | `String!` | Filesystem path | + +--- + +### `SET_LOCAL_SOURCE_PATH` +Sets the local source directory path in settings. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `path` | `String!` | Filesystem path | + +--- + +## Extensions (`mutations/extensions.ts`) + +### `FETCH_EXTENSIONS` +Fetches the latest extension list from configured repos. + +**Variables:** none + +--- + +### `UPDATE_EXTENSION` +Installs, uninstalls, or updates an extension. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `String!` | Extension package name | +| `install` | `Boolean` | Install the extension | +| `uninstall` | `Boolean` | Uninstall the extension | +| `update` | `Boolean` | Update the extension | + +--- + +### `INSTALL_EXTERNAL_EXTENSION` +Installs an extension from an external APK URL. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `url` | `String!` | APK download URL | + +--- + +### `SET_EXTENSION_REPOS` +Sets the list of extension repository URLs. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `repos` | `[String!]!` | Repository URLs | + +--- + +### `SET_SERVER_AUTH` +Configures server authentication mode and credentials. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `authMode` | `AuthMode!` | Auth mode | +| `authUsername` | `String!` | Username | +| `authPassword` | `String!` | Password | + +--- + +### `SET_SOCKS_PROXY` +Configures SOCKS proxy settings. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy | +| `socksProxyHost` | `String!` | Proxy host | +| `socksProxyPort` | `String!` | Proxy port | +| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) | +| `socksProxyUsername` | `String!` | Proxy username | +| `socksProxyPassword` | `String!` | Proxy password | + +--- + +### `SET_FLARESOLVERR` +Configures FlareSolverr integration settings. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr | +| `flareSolverrUrl` | `String!` | FlareSolverr URL | +| `flareSolverrTimeout` | `Int!` | Request timeout (ms) | +| `flareSolverrSessionName` | `String!` | Session name | +| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) | +| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only | + +--- + +## Tracking (`mutations/tracking.ts`) + +### `BIND_TRACK` +Binds a manga to a remote tracker entry. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `mangaId` | `Int!` | Manga ID | +| `trackerId` | `Int!` | Tracker ID | +| `remoteId` | `LongString!` | Remote entry ID on the tracker | + +--- + +### `UPDATE_TRACK` +Updates tracking progress, status, score, and dates for a track record. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `recordId` | `Int!` | Track record ID | +| `status` | `Int` | Reading status | +| `lastChapterRead` | `Float` | Last chapter read | +| `scoreString` | `String` | Score in tracker's format | +| `startDate` | `LongString` | Start date | +| `finishDate` | `LongString` | Finish date | +| `private` | `Boolean` | Mark as private | + +--- + +### `UNBIND_TRACK` +Unbinds a manga from a tracker record. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `recordId` | `Int!` | Track record ID | + +--- + +### `FETCH_TRACK` +Refreshes a track record from the remote tracker. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `recordId` | `Int!` | Track record ID | + +--- + +### `LOGIN_TRACKER_OAUTH` +Initiates OAuth login for a tracker using a callback URL. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `trackerId` | `Int!` | Tracker ID | +| `callbackUrl` | `String!` | OAuth callback URL | + +--- + +### `LOGIN_TRACKER_CREDENTIALS` +Logs into a tracker using username and password. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `trackerId` | `Int!` | Tracker ID | +| `username` | `String!` | Username | +| `password` | `String!` | Password | + +--- + +### `LOGOUT_TRACKER` +Logs out of a tracker. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `trackerId` | `Int!` | Tracker ID | + +--- + +### `LOGIN_USER` +Authenticates a user and returns access and refresh tokens. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `username` | `String!` | Username | +| `password` | `String!` | Password | + +--- + +### `REFRESH_TOKEN` +Refreshes the current access token. + +**Variables:** none diff --git a/src/api/mutations/tracking.ts b/src/api/mutations/tracking.ts new file mode 100644 index 0000000..f9baf12 --- /dev/null +++ b/src/api/mutations/tracking.ts @@ -0,0 +1,80 @@ +const TRACK_RECORD_FRAGMENT = ` + id trackerId remoteId title status score displayScore + lastChapterRead totalChapters remoteUrl startDate finishDate private +`; + +export const BIND_TRACK = ` + mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) { + bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) { + trackRecord { ${TRACK_RECORD_FRAGMENT} } + } + } +`; + +export const UPDATE_TRACK = ` + mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) { + updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) { + trackRecord { + id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private + } + } + } +`; + +export const UNBIND_TRACK = ` + mutation UnbindTrack($recordId: Int!) { + unbindTrack(input: { recordId: $recordId }) { + trackRecord { id } + } + } +`; + +export const FETCH_TRACK = ` + mutation FetchTrack($recordId: Int!) { + fetchTrack(input: { recordId: $recordId }) { + trackRecord { + id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate + } + } + } +`; + +export const LOGIN_TRACKER_OAUTH = ` + mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) { + loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) { + isLoggedIn + tracker { id name isLoggedIn authUrl } + } + } +`; + +export const LOGIN_TRACKER_CREDENTIALS = ` + mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) { + loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) { + isLoggedIn + tracker { id name isLoggedIn authUrl } + } + } +`; + +export const LOGOUT_TRACKER = ` + mutation LogoutTracker($trackerId: Int!) { + logoutTracker(input: { trackerId: $trackerId }) { + tracker { id name isLoggedIn authUrl } + } + } +`; + +export const LOGIN_USER = ` + mutation Login($username: String!, $password: String!) { + login(input: { username: $username, password: $password }) { + accessToken refreshToken + } + } +`; + +export const REFRESH_TOKEN = ` + mutation RefreshToken { + refreshToken { accessToken } + } +`; diff --git a/src/api/queries/chapters.ts b/src/api/queries/chapters.ts new file mode 100644 index 0000000..aca9946 --- /dev/null +++ b/src/api/queries/chapters.ts @@ -0,0 +1,10 @@ +export const GET_CHAPTERS = ` + query GetChapters($mangaId: Int!) { + chapters(condition: { mangaId: $mangaId }) { + nodes { + id name chapterNumber sourceOrder isRead isDownloaded isBookmarked + pageCount mangaId uploadDate realUrl lastPageRead scanlator + } + } + } +`; diff --git a/src/api/queries/downloads.ts b/src/api/queries/downloads.ts new file mode 100644 index 0000000..416d000 --- /dev/null +++ b/src/api/queries/downloads.ts @@ -0,0 +1,14 @@ +export const GET_DOWNLOAD_STATUS = ` + query GetDownloadStatus { + downloadStatus { + state + queue { + progress state + chapter { + id name pageCount mangaId + manga { id title thumbnailUrl } + } + } + } + } +`; diff --git a/src/api/queries/extensions.ts b/src/api/queries/extensions.ts new file mode 100644 index 0000000..2d3820f --- /dev/null +++ b/src/api/queries/extensions.ts @@ -0,0 +1,35 @@ +export const GET_EXTENSIONS = ` + query GetExtensions { + extensions { + nodes { + apkName pkgName name lang versionName + isInstalled isObsolete hasUpdate iconUrl + } + } + } +`; + +export const GET_SOURCES = ` + query GetSources { + sources { + nodes { id name lang displayName iconUrl isNsfw } + } + } +`; + +export const GET_SETTINGS = ` + query GetSettings { + settings { extensionRepos } + } +`; + +export const GET_SERVER_SECURITY = ` + query GetServerSecurity { + settings { + authMode authUsername + socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername + flareSolverrEnabled flareSolverrUrl flareSolverrTimeout + flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback + } + } +`; diff --git a/src/api/queries/index.ts b/src/api/queries/index.ts new file mode 100644 index 0000000..35767c1 --- /dev/null +++ b/src/api/queries/index.ts @@ -0,0 +1,5 @@ +export * from "./manga"; +export * from "./chapters"; +export * from "./downloads"; +export * from "./extensions"; +export * from "./tracking"; diff --git a/src/api/queries/manga.ts b/src/api/queries/manga.ts new file mode 100644 index 0000000..9f32101 --- /dev/null +++ b/src/api/queries/manga.ts @@ -0,0 +1,96 @@ +export const GET_LIBRARY = ` + query GetLibrary { + mangas(condition: { inLibrary: true }) { + nodes { + id title thumbnailUrl inLibrary downloadCount unreadCount + description status author artist genre + source { id name displayName } + chapters { totalCount } + } + } + } +`; + +export const GET_ALL_MANGA = ` + query GetAllManga { + mangas { + nodes { id title thumbnailUrl inLibrary downloadCount } + } + } +`; + +export const GET_MANGA = ` + query GetManga($id: Int!) { + manga(id: $id) { + id title description thumbnailUrl status author artist genre inLibrary realUrl + source { id name displayName } + } + } +`; + +export const GET_CATEGORIES = ` + query GetCategories { + categories { + nodes { + id name order default includeInUpdate includeInDownload + mangas { + nodes { id title thumbnailUrl inLibrary downloadCount unreadCount } + } + } + } + } +`; + +export const GET_DOWNLOADED_CHAPTERS_PAGES = ` + query GetDownloadedChaptersPages { + chapters(condition: { isDownloaded: true }) { + nodes { pageCount } + } + } +`; + +export const GET_DOWNLOADS_PATH = ` + query GetDownloadsPath { + settings { downloadsPath localSourcePath } + } +`; + +export const LIBRARY_UPDATE_STATUS = ` + query LibraryUpdateStatus { + libraryUpdateStatus { + jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount } + mangaUpdates { + status + manga { id title thumbnailUrl unreadCount } + } + } + } +`; + +export const GET_RESTORE_STATUS = ` + query GetRestoreStatus($id: String!) { + restoreStatus(id: $id) { mangaProgress state totalManga } + } +`; + +export const VALIDATE_BACKUP = ` + query ValidateBackup($backup: Upload!) { + validateBackup(input: { backup: $backup }) { + missingSources { id name } + missingTrackers { name } + } + } +`; + +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) { + nodes { + id title thumbnailUrl inLibrary genre status + source { id displayName } + } + pageInfo { hasNextPage } + totalCount + } + } +`; diff --git a/src/api/queries/queries.md b/src/api/queries/queries.md new file mode 100644 index 0000000..25d9064 --- /dev/null +++ b/src/api/queries/queries.md @@ -0,0 +1,171 @@ +# Queries + +## Manga (`queries/manga.ts`) + +### `GET_LIBRARY` +Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count. + +**Variables:** none + +--- + +### `GET_ALL_MANGA` +Fetches all manga (library and non-library) with minimal fields. + +**Variables:** none + +--- + +### `GET_MANGA` +Fetches a single manga by ID with full metadata and source info. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `Int!` | Manga ID | + +--- + +### `GET_CATEGORIES` +Fetches all categories with their order, settings, and the manga assigned to each. + +**Variables:** none + +--- + +### `GET_DOWNLOADED_CHAPTERS_PAGES` +Fetches page counts for all downloaded chapters. + +**Variables:** none + +--- + +### `GET_DOWNLOADS_PATH` +Fetches the configured downloads path and local source path from settings. + +**Variables:** none + +--- + +### `LIBRARY_UPDATE_STATUS` +Fetches the current library update job status, including progress and any manga with new chapters. + +**Variables:** none + +--- + +### `GET_RESTORE_STATUS` +Fetches the status of a backup restore operation by its job ID. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `id` | `String!` | Restore job ID | + +--- + +### `VALIDATE_BACKUP` +Validates a backup file and returns any missing sources or trackers. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `backup` | `Upload!` | Backup file | + +--- + +## Chapters (`queries/chapters.ts`) + +### `GET_CHAPTERS` +Fetches all chapters for a given manga, including read/download/bookmark state and page info. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `mangaId` | `Int!` | Manga ID | + +--- + +## Downloads (`queries/downloads.ts`) + +### `GET_DOWNLOAD_STATUS` +Fetches the current downloader state and full queue with chapter and manga info. + +**Variables:** none + +--- + +## Extensions (`queries/extensions.ts`) + +### `GET_EXTENSIONS` +Fetches all extensions with install status, update availability, and metadata. + +**Variables:** none + +--- + +### `GET_SOURCES` +Fetches all available sources with language and NSFW flags. + +**Variables:** none + +--- + +### `GET_SETTINGS` +Fetches extension repository settings. + +**Variables:** none + +--- + +### `GET_SERVER_SECURITY` +Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config. + +**Variables:** none + +--- + +## Tracking (`queries/tracking.ts`) + +### `GET_TRACKERS` +Fetches all trackers with login status, supported scores, statuses, and auth info. + +**Variables:** none + +--- + +### `GET_MANGA_TRACK_RECORDS` +Fetches all tracking records for a specific manga across all trackers. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `mangaId` | `Int!` | Manga ID | + +--- + +### `SEARCH_TRACKER` +Searches a tracker for manga by query string. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `trackerId` | `Int!` | Tracker ID | +| `query` | `String!` | Search query | + +--- + +### `GET_ALL_TRACKER_RECORDS` +Fetches all trackers and their full track records, including associated manga info. + +**Variables:** none + +--- + +### `GET_TRACKER_RECORDS` +Fetches track records for a specific tracker. + +**Variables:** +| Name | Type | Description | +|------|------|-------------| +| `trackerId` | `Int!` | Tracker ID | diff --git a/src/api/queries/tracking.ts b/src/api/queries/tracking.ts new file mode 100644 index 0000000..3387359 --- /dev/null +++ b/src/api/queries/tracking.ts @@ -0,0 +1,69 @@ +export const GET_TRACKERS = ` + query GetTrackers { + trackers { + nodes { + id name icon isLoggedIn authUrl supportsPrivateTracking scores + statuses { value name } + } + } + } +`; + +export const GET_MANGA_TRACK_RECORDS = ` + query GetMangaTrackRecords($mangaId: Int!) { + manga(id: $mangaId) { + trackRecords { + nodes { + id trackerId remoteId title status score displayScore + lastChapterRead totalChapters remoteUrl startDate finishDate private + } + } + } + } +`; + +export const SEARCH_TRACKER = ` + query SearchTracker($trackerId: Int!, $query: String!) { + searchTracker(input: { trackerId: $trackerId, query: $query }) { + trackSearches { + id trackerId remoteId title coverUrl summary + publishingStatus publishingType startDate totalChapters trackingUrl + } + } + } +`; + +export const GET_ALL_TRACKER_RECORDS = ` + query GetAllTrackerRecords { + trackers { + nodes { + id name icon isLoggedIn scores + statuses { value name } + trackRecords { + nodes { + id trackerId title status displayScore lastChapterRead + totalChapters remoteUrl private + manga { id title thumbnailUrl inLibrary } + } + } + } + } + } +`; + +export const GET_TRACKER_RECORDS = ` + query GetTrackerRecords($trackerId: Int!) { + trackers(condition: { id: $trackerId }) { + nodes { + id name + statuses { value name } + trackRecords { + nodes { + id title status displayScore lastChapterRead totalChapters remoteUrl + manga { id title thumbnailUrl } + } + } + } + } + } +`; diff --git a/src/components/pages/Downloads.svelte b/src/components/pages/Downloads.svelte deleted file mode 100644 index 4e14b40..0000000 --- a/src/components/pages/Downloads.svelte +++ /dev/null @@ -1,182 +0,0 @@ - - -
-
-

Downloads

- -
- - -
-
- -
-
-
- - {togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"} - - {queue.length} queued -
- - {#if loading} -
- {:else if queue.length === 0} -
Queue is empty.
- {:else} -
- {#each queue as item, i (item.chapter.id)} - {@const isActive = i === 0 && isRunning} - {@const pages = item.chapter.pageCount ?? 0} - {@const done = Math.round(item.progress * pages)} - {@const manga = item.chapter.manga} - {@const isRemoving = dequeueing.has(item.chapter.id)} -
- {#if manga?.thumbnailUrl} -
- -
- {/if} -
- {#if manga?.title}{manga.title}{/if} - {item.chapter.name} - {#if pages > 0} - {isActive ? `${done} / ${pages} pages` : `${pages} pages`} - {/if} - {#if isActive} -
-
-
- {/if} -
-
- {item.state} - {#if !isActive} - - {/if} -
-
- {/each} -
- {/if} -
-
- - diff --git a/src/components/pages/Extensions.svelte b/src/components/pages/Extensions.svelte deleted file mode 100644 index d2dbbe1..0000000 --- a/src/components/pages/Extensions.svelte +++ /dev/null @@ -1,393 +0,0 @@ - - -
-
-

Extensions

-
- {#each FILTERS as f} - - {/each} -
-
-
- - -
- - - -
-
- - {#if availableLangs.length > 1} -
- - {#each availableLangs as lang} - - {/each} -
- {/if} - - {#if panel === "apk"} -
-
- Install from APK URL - -
-
- installError = null} - onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} use:focusOnMount /> - -
- {#if installError}
{installError}
{/if} -
- {/if} - - {#if panel === "repos"} -
-
- Extension Repositories - -
- {#if reposLoading} -
- {:else} - {#if repos.length === 0} -
No repos configured.
- {:else} -
- {#each repos as url} -
- {url} - -
- {/each} -
- {/if} -
- repoError = null} - onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} /> - -
- {#if repoError}
{repoError}
{/if} - {/if} -
- {/if} - - - - {#if loading} -
- {:else if groups.length === 0} -
No extensions found.
- {:else} -
- {#each groups as { base, primary, variants }} - {@const isExpanded = expanded.has(base)} - {@const hasVariants = variants.length > 0} -
-
- (e.target as HTMLImageElement).style.display = "none"} /> -
- {base} - {primary.lang.toUpperCase()} v{primary.versionName} -
- {#if working.has(primary.pkgName)} - - {:else if primary.hasUpdate} -
- - -
- {:else if primary.isInstalled} - - {:else} - - {/if} - {#if hasVariants} - - {/if} -
- {#if isExpanded && hasVariants} -
- {#each variants as v} -
- {v.lang.toUpperCase()} - {v.name} - v{v.versionName} - {#if v.hasUpdate}{/if} -
- {#if working.has(v.pkgName)} - - {:else if v.hasUpdate} - - {:else if v.isInstalled} - - {:else} - - {/if} -
-
- {/each} -
- {/if} -
- {/each} -
- {/if} -
- - - - - diff --git a/src/components/pages/Home.svelte b/src/components/pages/Home.svelte deleted file mode 100644 index 81c6919..0000000 --- a/src/components/pages/Home.svelte +++ /dev/null @@ -1,669 +0,0 @@ - - -
-
- -
-
- - {#if heroThumb} -
- {:else} -
- {/if} -
- - - -
- {#if activeSlot?.kind === "empty"} -

Nothing here yet

-

{activeSlot.slotIndex === 0 ? "Read a manga to see it here" : "Pin a manga or keep reading to fill this slot"}

- {#if activeSlot.slotIndex !== 0} - - {/if} - {:else} -
- {#if activeSlot?.kind === "continue"} - Reading - {:else} - Pinned - {/if} - {#each (heroManga?.genre ?? []).slice(0, 3) as g} - - {/each} -
- -

{heroTitle}

- {#if heroManga?.author}

{heroManga.author}

{/if} - - {#if heroEntry} -

- - {heroEntry.chapterName} - {#if heroEntry.pageNumber > 1} · p.{heroEntry.pageNumber}{/if} - {timeAgo(heroEntry.readAt)} -

- {/if} - - {#if heroManga?.description}

{heroManga.description}

{/if} - -
- {#if activeSlot?.kind === "continue"} - - {:else if heroManga} - - {/if} - {#if activeSlot?.slotIndex !== 0} - {#if activeSlot?.kind === "pinned"} - - {:else} - - {/if} - {/if} -
- {/if} - -
- -
- {#each resolvedSlots as slot, i} - - {/each} -
- - {activeIdx + 1}/{TOTAL_SLOTS} -
-
- -
-
Up Next
- - {#if activeSlot?.kind === "empty"} -

No chapters to show

- {:else if loadingHeroChapters} - {#each Array(4) as _} -
-
-
-
- {/each} - {:else if heroChapters.length === 0} -

No chapters available

- {:else} - {#each heroChapters as ch (ch.id)} - {@const isCurrent = heroEntry?.chapterId === ch.id} - - {/each} - {#if heroManga} - - {/if} - {/if} -
- -
-
- -
-
- Recent Activity - {#if recentHistory.length > 0} - - {/if} -
-
- {#if recentHistory.length > 0} - {#each recentHistory as entry (entry.chapterId)} - - {/each} - {:else} -
- {#each Array(5) as _, i} -
-
-
-
-
-
-
-
- {/each} -
- -
-
- {/if} -
-
- -
-
-
- Updates - {#if lastRefresh}{timeAgoRefresh(lastRefresh)}{/if} - - {#if libraryUpdates.length > 0} - - {/if} -
- {#if libraryUpdates.length > 0} -
{ e.preventDefault(); handleRowWheel(e); }}> - {#each libraryUpdates as u (u.mangaId)} - {@const m = libraryManga.find(x => x.id === u.mangaId)} - - {/each} -
- {:else} -

{lastRefresh ? "No new chapters found" : "Check for updates in the library"}

- {/if} -
- -
- -
-
- Your Stats -
-
-
{stats.currentStreakDays}Day streak
-
{stats.totalChaptersRead}Chapters read
-
{formatReadTime(stats.totalMinutesRead)}Read time
-
{stats.totalMangaRead}Series started
-
{libraryUpdates.length}New updates
-
{stats.longestStreakDays}dBest streak
-
-
-
- -
-
- -{#if pickerOpen} - -{/if} - - diff --git a/src/components/pages/Library.svelte b/src/components/pages/Library.svelte deleted file mode 100644 index fc79d30..0000000 --- a/src/components/pages/Library.svelte +++ /dev/null @@ -1,1283 +0,0 @@ - - - - -{#if ctx} - ctx = null} /> -{/if} -{#if emptyCtx} - emptyCtx = null} /> -{/if} - - \ No newline at end of file diff --git a/src/components/pages/Search.svelte b/src/components/pages/Search.svelte deleted file mode 100644 index 6b0a0fa..0000000 --- a/src/components/pages/Search.svelte +++ /dev/null @@ -1,1384 +0,0 @@ - - -
- -
-

Search

-
- - - -
-
- - {#if tab === "keyword"} -
- - - {#if hasMultipleLangs && kw_showAdvanced} -
-
- Languages -
- - -
-
-
- {#each availableLangs as lang (lang)} - - {/each} -
-
-
- Searching {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} -
-
- {/if} -
- - {#if !kw_query.trim()} - {#if srch_loading && srch_results.length === 0} -
- {#each Array(24) as _, i (i)} -
- {/each} -
- {:else if srch_results.length > 0} -
- Popular right now -
-
- {#each srch_results as m (m.id)} - - {/each} - {#if srch_loading} - {#each Array(12) as _, i (i)} -
- {/each} - {/if} -
- {:else} -
- -

Search across sources

-

- {#if hasMultipleLangs} - {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""} - {:else} - {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} - {/if} -

-
- {/if} - {:else} - {#if kw_flatResults.length > 0} -
- {kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} -
-
- {#each kw_flatResults as m (m.id)} - - {/each} - {#if kw_anyLoading} - {#each Array(6) as _, i (i)} -
- {/each} - {/if} -
- {:else if kw_anyLoading} -
- {#each Array(12) as _, i (i)} -
- {/each} -
- {:else if kw_allDone && !kw_hasResults} -
-

No results for "{kw_query.trim()}"

-

Try a different spelling or fewer words

-
- {/if} - {/if} - - {:else if tab === "tag"} -
-
-
- - - {#if tag_tagFilter} - - {/if} -
-
-
Status
- {#each MANGA_STATUSES as { value, label } (value)} - - {/each} -
Genre
- {#each tag_filteredGenres as tag (tag)} - - {/each} - {#if tag_filteredGenres.length === 0} -

No matching genres

- {/if} -
-
- -
- {#if !tag_hasActiveFilters} -
- -

Browse by tag

-

Select a status or genre to find matching manga.

-
- {:else} -
-
- {#each tag_activeStatuses as status (status)} - - {MANGA_STATUSES.find((s) => s.value === status)?.label ?? status} - - - {/each} - {#each tag_activeTags as tag (tag)} - - {tag} - - - {/each} -
-
- {#if tag_activeTags.length > 1} -
- - -
- {/if} - - -
-
- -
- - {#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0} - {tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")} - {:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0} - {tag_activeTags[0]} - {:else} - {[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)} - {/if} - {#if tag_searchSources} - + sources - {/if} - - {#if tag_loadingLocal} - - {:else} - - {tag_totalVisible}{tag_localHasNext ? "+" : ""} results - {#if tag_searchSources && sourceCacheReady} - · {sourceCache.size} cached - {/if} - - {/if} -
- - {#if tag_loadingLocal} -
- {#each Array(48) as _, i (i)} -
- {/each} -
- {:else if tag_mergedResults.length > 0} -
- {#each tag_mergedResults as m (m.id)} - - {/each} - - {#if tag_loadingMoreLocal} - {#each Array(12) as _, i (i)} -
- {/each} - {/if} -
- {:else} -
-

No results

-

- {#if tag_searchSources} - Try OR mode or broader tags. - {:else} - Try OR mode, enable Sources, or check your library. - {/if} -

-
- {/if} - {/if} -
-
- - {:else if tab === "source"} -
-
-
- Language - -
- {#if loadingSources} -
- -
- {:else} -
- {#each src_visibleSources as src (src.id)} - - {/each} - {#if src_visibleSources.length === 0} -

No sources for this language

- {/if} -
- {/if} -
- -
- {#if !src_activeSource} -
- -

Browse a source

-

Select a source to see its popular titles, or search within it.

-
- {:else} -
-
- { (e.target as HTMLImageElement).style.display = "none"; }} /> - {src_activeSource.displayName} - {#if src_loadingBrowse} - - {:else if src_browseResults.length > 0} - {src_browseResults.length} results - {/if} -
-
-
- - -
- - {#if src_loadingBrowse && src_browseResults.length === 0} -
- {#each Array(18) as _, i (i)} -
- {/each} -
- {:else if src_browseResults.length > 0} -
- {#each src_browseResults as m (m.id)} - - {/each} -
- {#if src_hasNextPage} -
- -
- {/if} - {:else if !src_loadingBrowse} -
-

{src_submitted ? `No results for "${src_submitted}"` : "No results"}

-
- {/if} - {/if} -
-
- {/if} -
- - - - \ No newline at end of file diff --git a/src/components/pages/Tracking.svelte b/src/components/pages/Tracking.svelte deleted file mode 100644 index 041bdf7..0000000 --- a/src/components/pages/Tracking.svelte +++ /dev/null @@ -1,894 +0,0 @@ - - -
- -
-
-

Tracking

-
- -
-
- - {#if !loading && loggedInTrackers.length > 0} -
- - {#each loggedInTrackers as t} - {@const count = t.trackRecords.nodes.length} - - {/each} -
- -
-
- - -
-
- - - -
-
- {/if} -
- -
- - {#if loading} -
- - Loading… -
- - {:else if error} -
-

{error}

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

No trackers connected.

-

Go to Settings → Tracking to connect AniList, MAL, or others.

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

{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}

- {#if searchQuery || statusFilter !== "all"} - - {/if} -
- - {:else} -
- {#each filtered as record (record.tracker.id + ":" + record.id)} - {@const tracker = record.tracker} - {@const isBusy = updatingId === record.id} - {@const isSyncing = syncingId === record.id} - {@const progress = record.totalChapters > 0 - ? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100) - : null} - {@const stars = scoreToStars(record.displayScore, tracker.scores)} - {@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"} - -
- -
-
openManga(record)} - onkeydown={(e) => e.key === "Enter" && openManga(record)} - title="Open in library" - > - {#if record.manga?.thumbnailUrl} - - {:else} -
- {/if} -
- -
- {#if record.private} - - {/if} - {#if isSyncing} - - - - {:else} - - {/if} - {#if record.remoteUrl} - - - - {/if} - -
- -
- -
-
- - -
- {/each} -
- {/if} - -
-
- -{#if confirmUnbindRecord} - {@const r = confirmUnbindRecord} - -{/if} - - - - diff --git a/src/components/reader/Reader.svelte b/src/components/reader/Reader.svelte deleted file mode 100644 index 8dda5db..0000000 --- a/src/components/reader/Reader.svelte +++ /dev/null @@ -1,1469 +0,0 @@ - - - - - diff --git a/src/components/series/SeriesDetail.svelte b/src/components/series/SeriesDetail.svelte deleted file mode 100644 index 637841e..0000000 --- a/src/components/series/SeriesDetail.svelte +++ /dev/null @@ -1,1342 +0,0 @@ - - -{#if store.activeManga} - - -{#if ctx} - ctx = null} /> -{/if} - -{#if migrateOpen && manga} - migrateOpen = false} - onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }} - /> -{/if} - -{#if trackingOpen && store.activeManga} - trackingOpen = false} /> -{/if} - -{#if autoOpen && store.activeManga} - autoOpen = false} /> -{/if} - -{#if markersOpen && store.activeManga} - -{/if} - -{#if linkPickerOpen} - -{/if} -{/if} - - - diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte deleted file mode 100644 index a7c7ccd..0000000 --- a/src/components/settings/Settings.svelte +++ /dev/null @@ -1,3188 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/components/settings/ThemeEditor.svelte b/src/components/settings/ThemeEditor.svelte deleted file mode 100644 index c2193ba..0000000 --- a/src/components/settings/ThemeEditor.svelte +++ /dev/null @@ -1,575 +0,0 @@ - - - - - - - diff --git a/src/components/shared/MangaPreview.svelte b/src/components/shared/MangaPreview.svelte deleted file mode 100644 index 332d946..0000000 --- a/src/components/shared/MangaPreview.svelte +++ /dev/null @@ -1,618 +0,0 @@ - - -{#if store.previewManga} - - -{#if linkPickerOpen} - -{/if} - -{/if} - - - - \ No newline at end of file diff --git a/src/core/algorithms/filter.ts b/src/core/algorithms/filter.ts new file mode 100644 index 0000000..67fee53 --- /dev/null +++ b/src/core/algorithms/filter.ts @@ -0,0 +1,50 @@ +import type { Manga, Source } from "@types"; +import type { Settings } from "@types"; +import { shouldHideSource } from "@core/util"; + +// ── Source deduplication ────────────────────────────────────────────────────── + +/** + * Deduplicates sources by name, preferring `preferredLang` when multiple + * sources share a name. The local source (id "0") is always excluded. + * + * When `applyHide` is true, sources that fail the NSFW/block check are + * also removed — used in fan-out and cache-build paths where only + * user-visible sources should be queried. + */ +export function dedupeSourcesByLang( + sources: Source[], + preferredLang: string, + settings: Pick, + applyHide = false, +): Source[] { + const map = new Map(); + for (const s of sources) { + if (s.id === "0") continue; + if (applyHide && shouldHideSource(s, settings)) continue; + const existing = map.get(s.name); + if (!existing) { map.set(s.name, s); continue; } + const existingPref = existing.lang === preferredLang; + const newPref = s.lang === preferredLang; + if (newPref && !existingPref) map.set(s.name, s); + else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s); + } + return Array.from(map.values()); +} + +// ── Manga predicate filters ─────────────────────────────────────────────────── + +/** + * Generic predicate pipeline — composes multiple boolean predicates into one. + * All predicates must return true for an item to pass. + * + * Usage: + * const keep = buildFilter( + * m => !shouldHideNsfw(m, settings), + * m => m.inLibrary, + * ); + * const filtered = items.filter(keep); + */ +export function buildFilter(...predicates: ((item: T) => boolean)[]): (item: T) => boolean { + return (item) => predicates.every((p) => p(item)); +} diff --git a/src/core/algorithms/index.ts b/src/core/algorithms/index.ts new file mode 100644 index 0000000..95f4d46 --- /dev/null +++ b/src/core/algorithms/index.ts @@ -0,0 +1,5 @@ +export * from './sort'; +export * from './filter'; +export * from './paginate'; +export * from './search'; +export * from './queue'; diff --git a/src/core/algorithms/paginate.ts b/src/core/algorithms/paginate.ts new file mode 100644 index 0000000..979dab8 --- /dev/null +++ b/src/core/algorithms/paginate.ts @@ -0,0 +1,29 @@ +export interface PaginationState { + visible: number; +} + +export interface PaginationResult { + items: T[]; + hasMore: boolean; + remaining: number; +} + +export function createPaginator(pageSize: number) { + return { + slice(all: T[], visible: number): PaginationResult { + return { + items: all.slice(0, visible), + hasMore: all.length > visible, + remaining: Math.max(0, all.length - visible), + }; + }, + + nextVisible(current: number): number { + return current + pageSize; + }, + + reset(): number { + return pageSize; + }, + }; +} diff --git a/src/core/algorithms/queue.ts b/src/core/algorithms/queue.ts new file mode 100644 index 0000000..81a6a8c --- /dev/null +++ b/src/core/algorithms/queue.ts @@ -0,0 +1,29 @@ +export interface AsyncQueue { + enqueue(item: T): void; + drain(): void; + clear(): void; + size(): number; +} + +export function createAsyncQueue( + worker: (item: T) => Promise, + concurrency = 1, +): AsyncQueue { + const queue: T[] = []; + let active = 0; + + function next() { + while (active < concurrency && queue.length > 0) { + const item = queue.shift()!; + active++; + worker(item).finally(() => { active--; next(); }); + } + } + + return { + enqueue(item) { queue.push(item); next(); }, + drain() { next(); }, + clear() { queue.length = 0; }, + size() { return queue.length; }, + }; +} diff --git a/src/core/algorithms/search.ts b/src/core/algorithms/search.ts new file mode 100644 index 0000000..0c92805 --- /dev/null +++ b/src/core/algorithms/search.ts @@ -0,0 +1,33 @@ +export interface SearchResult { + item: T; + score: number; +} + +export function searchItems( + items: T[], + query: string, + getField: (item: T) => string, +): T[] { + const q = query.trim().toLowerCase(); + if (!q) return items; + return items.filter(item => getField(item).toLowerCase().includes(q)); +} + +export function searchWithScore( + items: T[], + query: string, + getField: (item: T) => string, +): SearchResult[] { + const q = query.trim().toLowerCase(); + if (!q) return items.map(item => ({ item, score: 0 })); + + return items + .map(item => { + const field = getField(item).toLowerCase(); + if (!field.includes(q)) return null; + const score = field === q ? 2 : field.startsWith(q) ? 1 : 0; + return { item, score }; + }) + .filter((r): r is SearchResult => r !== null) + .sort((a, b) => b.score - a.score); +} diff --git a/src/core/algorithms/sort.ts b/src/core/algorithms/sort.ts new file mode 100644 index 0000000..d148045 --- /dev/null +++ b/src/core/algorithms/sort.ts @@ -0,0 +1,32 @@ +export type SortDir = "asc" | "desc"; + +export interface SortField { + key: string; + comparator: (a: T, b: T, context?: Record) => number; +} + +export interface SortConfig { + fields: SortField[]; + defaultField: string; + defaultDir: SortDir; +} + +export interface Sorter { + sort(items: T[], field: string, dir: SortDir, context?: Record): T[]; +} + +export function createSorter(config: SortConfig): Sorter { + const fieldMap = new Map(config.fields.map(f => [f.key, f])); + + return { + sort(items, field, dir, context) { + const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField); + if (!f) return [...items]; + const d = dir ?? config.defaultDir; + return [...items].sort((a, b) => { + const cmp = f.comparator(a, b, context); + return d === "asc" ? cmp : -cmp; + }); + }, + }; +} diff --git a/src/core/async/batchRequests.ts b/src/core/async/batchRequests.ts new file mode 100644 index 0000000..ff49dbc --- /dev/null +++ b/src/core/async/batchRequests.ts @@ -0,0 +1,61 @@ +/** + * Runs an async task over every item in `items`, with at most `concurrency` + * tasks in-flight at once. Respects the provided AbortSignal — each worker + * exits early if the signal fires. Errors thrown by individual tasks are + * swallowed so one failure does not cancel the whole batch. + */ +export async function runConcurrent( + items: T[], + fn: (item: T) => Promise, + signal: AbortSignal, + concurrency = 6, +): Promise { + let i = 0; + async function worker() { + while (i < items.length) { + if (signal.aborted) return; + const item = items[i++]; + await fn(item).catch(() => {}); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, worker), + ); +} + +/** + * Deduplicates in-flight async calls by key. + * + * Two call signatures are supported: + * + * 1. Direct call — supply a key and a zero-arg factory each time: + * dedupeRequest("my-key", () => fetchSomething()) + * If a request with that key is already pending, the existing Promise is + * returned and the factory is not called again. + * + * 2. Curried wrapper — supply a key-based fetcher once, get back a + * single-arg function you can call repeatedly: + * const get = dedupeRequest((key) => fetchSomething(key)) + * get("my-key") + */ +const _inflight = new Map>(); + +export function dedupeRequest(key: string, factory: () => Promise): Promise; +export function dedupeRequest(fn: (key: string) => Promise): (key: string) => Promise; +export function dedupeRequest( + keyOrFn: string | ((key: string) => Promise), + factory?: () => Promise, +): Promise | ((key: string) => Promise) { + // Curried wrapper form + if (typeof keyOrFn === 'function') { + const fn = keyOrFn; + return (key: string) => dedupeRequest(key, () => fn(key)); + } + + // Direct call form + const key = keyOrFn; + if (_inflight.has(key)) return _inflight.get(key) as Promise; + const p = factory!().finally(() => _inflight.delete(key)); + _inflight.set(key, p); + return p; +} \ No newline at end of file diff --git a/src/core/async/createPaginatedQuery.ts b/src/core/async/createPaginatedQuery.ts new file mode 100644 index 0000000..912b282 --- /dev/null +++ b/src/core/async/createPaginatedQuery.ts @@ -0,0 +1,25 @@ +export interface PaginatedQuery { + fetchPage(page: number): Promise; + reset(): void; + hasMore(): boolean; +} + +export interface PaginatedQueryConfig { + fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>; +} + +export function createPaginatedQuery( + config: PaginatedQueryConfig, +): PaginatedQuery { + let _hasMore = true; + + return { + async fetchPage(page) { + const { items, hasNextPage } = await config.fetcher(page); + _hasMore = hasNextPage; + return items; + }, + reset() { _hasMore = true; }, + hasMore() { return _hasMore; }, + }; +} diff --git a/src/core/async/fetchWithRetry.ts b/src/core/async/fetchWithRetry.ts new file mode 100644 index 0000000..f9ad4cc --- /dev/null +++ b/src/core/async/fetchWithRetry.ts @@ -0,0 +1,31 @@ +export interface RetryOptions { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + shouldRetry?: (err: unknown, attempt: number) => boolean; +} + +export async function fetchWithRetry( + fetcher: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + baseDelayMs = 500, + maxDelayMs = 10_000, + shouldRetry = () => true, + } = options; + + let lastErr: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fetcher(); + } catch (err) { + lastErr = err; + if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err; + const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs); + await new Promise(r => setTimeout(r, delay)); + } + } + throw lastErr; +} diff --git a/src/core/async/index.ts b/src/core/async/index.ts new file mode 100644 index 0000000..a3304b1 --- /dev/null +++ b/src/core/async/index.ts @@ -0,0 +1,3 @@ +export * from './fetchWithRetry'; +export * from './batchRequests'; +export * from './createPaginatedQuery'; diff --git a/src/lib/auth.ts b/src/core/auth.ts similarity index 74% rename from src/lib/auth.ts rename to src/core/auth.ts index 1a1fbe5..40f72b9 100644 --- a/src/lib/auth.ts +++ b/src/core/auth.ts @@ -1,4 +1,4 @@ -import { store, updateSettings } from "../store/state.svelte"; +import { store, updateSettings } from "@store/state.svelte"; export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; @@ -16,37 +16,25 @@ function basicHeader(user: string, pass: string): Record { return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; } -export function fetchAuthenticated( - url: string, - init: RequestInit, - signal?: AbortSignal, -): Promise { +export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise { const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode === "BASIC_AUTH") { const user = store.settings.serverAuthUser?.trim() ?? ""; const pass = store.settings.serverAuthPass?.trim() ?? ""; return fetch(url, { - ...init, - signal, - credentials: "omit", - headers: { - ...(init.headers as Record ?? {}), - ...(user && pass ? basicHeader(user, pass) : {}), - }, + ...init, signal, credentials: "omit", + headers: { ...(init.headers as Record ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) }, }); } - return fetch(url, { ...init, signal, credentials: "omit" }); } export async function loginBasic(user: string, pass: string): Promise { const res = await fetch(`${getServerBase()}/api/graphql`, { - method: "POST", - credentials: "omit", - headers: { "Content-Type": "application/json", ...basicHeader(user, pass) }, - body: JSON.stringify({ query: "{ __typename }" }), - signal: AbortSignal.timeout(5000), + method: "POST", credentials: "omit", + headers: { "Content-Type": "application/json", ...basicHeader(user, pass) }, + body: JSON.stringify({ query: "{ __typename }" }), + signal: AbortSignal.timeout(5000), }); if (!res.ok) throw new Error(`Authentication failed (${res.status})`); updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass }); @@ -60,34 +48,25 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport const base = getServerBase(); const mode = store.settings.serverAuthMode ?? "NONE"; const s = store.settings; - try { const headers: Record = { "Content-Type": "application/json" }; - if (mode === "BASIC_AUTH") { const user = s.serverAuthUser?.trim() ?? ""; const pass = s.serverAuthPass?.trim() ?? ""; if (user && pass) Object.assign(headers, basicHeader(user, pass)); } - const res = await fetch(`${base}/api/graphql`, { - method: "POST", - credentials: "omit", - headers, - body: JSON.stringify({ query: "{ __typename }" }), - signal: AbortSignal.timeout(2000), + method: "POST", credentials: "omit", headers, + body: JSON.stringify({ query: "{ __typename }" }), + signal: AbortSignal.timeout(2000), }); - if (res.ok) return "ok"; - if (res.status === 401) { const wwwAuth = res.headers.get("WWW-Authenticate") ?? ""; - if (/basic/i.test(wwwAuth)) { if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" }); return "auth_required"; } - if (/bearer/i.test(wwwAuth)) { if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" }); } else if (mode === "NONE") { @@ -95,7 +74,6 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport } return "unsupported_mode"; } - return "unreachable"; } catch { return "unreachable"; } } diff --git a/src/lib/imageCache.ts b/src/core/cache/imageCache.ts similarity index 78% rename from src/lib/imageCache.ts rename to src/core/cache/imageCache.ts index d366350..ea7ac97 100644 --- a/src/lib/imageCache.ts +++ b/src/core/cache/imageCache.ts @@ -1,11 +1,11 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; -import { store } from "../store/state.svelte"; +import { store } from "@store/state.svelte"; const cache = new Map(); const inflight = new Map>(); - const MAX_CONCURRENT = 6; -let active = 0; +let active = 0; +let drainScheduled = false; interface QueueEntry { url: string; @@ -41,34 +41,33 @@ function insertSorted(entry: QueueEntry) { } function drain() { + drainScheduled = false; while (active < MAX_CONCURRENT && queue.length > 0) { const entry = queue.shift()!; active++; doFetch(entry.url) .then(entry.resolve, entry.reject) - .finally(() => { - inflight.delete(entry.url); - active--; - drain(); - }); + .finally(() => { inflight.delete(entry.url); active--; drain(); }); } } +function scheduleDrain() { + if (drainScheduled) return; + drainScheduled = true; + requestAnimationFrame(drain); +} + function enqueue(url: string, priority: number): Promise { - const promise = new Promise((resolve, reject) => { - insertSorted({ url, priority, resolve, reject }); - }); + const promise = new Promise((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); }); inflight.set(url, promise); - drain(); + scheduleDrain(); return promise; } export function getBlobUrl(url: string, priority = 0): Promise { if (!url) return Promise.resolve(""); - const cached = cache.get(url); if (cached) return Promise.resolve(cached); - const existing = inflight.get(url); if (existing) { const idx = queue.findIndex(e => e.url === url); @@ -79,7 +78,6 @@ export function getBlobUrl(url: string, priority = 0): Promise { } return existing; } - return enqueue(url, priority); } @@ -92,13 +90,15 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void { export function revokeBlobUrl(url: string): void { const blob = cache.get(url); - if (blob) { - URL.revokeObjectURL(blob); - cache.delete(url); - } + if (blob) { URL.revokeObjectURL(blob); cache.delete(url); } +} + +export function deprioritizeQueue(): void { + for (const entry of queue) entry.priority = 0; + queue.sort((a, b) => b.priority - a.priority); } export function clearBlobCache(): void { cache.forEach(blob => URL.revokeObjectURL(blob)); cache.clear(); -} +} \ No newline at end of file diff --git a/src/core/cache/index.ts b/src/core/cache/index.ts new file mode 100644 index 0000000..92ee52c --- /dev/null +++ b/src/core/cache/index.ts @@ -0,0 +1,3 @@ +export * from './memoryCache'; +export * from './imageCache'; +export * from './queryCache'; diff --git a/src/core/cache/memoryCache.ts b/src/core/cache/memoryCache.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/core/cache/queryCache.ts b/src/core/cache/queryCache.ts new file mode 100644 index 0000000..ddcbcff --- /dev/null +++ b/src/core/cache/queryCache.ts @@ -0,0 +1,161 @@ +interface Entry { + promise: Promise; + fetchedAt: number; +} + +const store = new Map>(); +const subs = new Map void>>(); +const groups = new Map>(); + +export const DEFAULT_TTL_MS = 5 * 60 * 1_000; + +function notify(key: string) { subs.get(key)?.forEach(cb => cb()); } + +function registerGroups(key: string, group?: string | string[]) { + if (!group) return; + for (const tag of Array.isArray(group) ? group : [group]) { + if (!groups.has(tag)) groups.set(tag, new Set()); + groups.get(tag)!.add(key); + } +} + +export const cache = { + get(key: string, fetcher: () => Promise, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise { + const existing = store.get(key) as Entry | undefined; + if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise; + const promise = fetcher().catch(err => { + if (err?.name !== "AbortError") store.delete(key); + return Promise.reject(err); + }) as Promise; + store.set(key, { promise, fetchedAt: Date.now() }); + registerGroups(key, group); + promise.then(() => notify(key)).catch(() => {}); + return promise; + }, + + set(key: string, value: T, group?: string | string[]) { + store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() }); + registerGroups(key, group); + notify(key); + }, + + update(key: string, fn: (prev: T) => T) { + const existing = store.get(key) as Entry | undefined; + if (!existing) return; + const next = existing.promise.then(fn); + store.set(key, { promise: next, fetchedAt: Date.now() }); + next.then(() => notify(key)).catch(() => {}); + }, + + has(key: string): boolean { return store.has(key); }, + + ageOf(key: string): number | undefined { + const e = store.get(key); + return e ? Date.now() - e.fetchedAt : undefined; + }, + + clear(key: string) { store.delete(key); notify(key); }, + + clearGroup(tag: string) { + const keys = groups.get(tag); + if (!keys) return; + for (const key of keys) { store.delete(key); notify(key); } + groups.delete(tag); + }, + + clearAll() { + const allKeys = [...store.keys()]; + store.clear(); groups.clear(); + allKeys.forEach(notify); + }, + + subscribe(key: string, cb: () => void): () => void { + if (!subs.has(key)) subs.set(key, new Set()); + subs.get(key)!.add(cb); + return () => subs.get(key)?.delete(cb); + }, +}; + +export const CACHE_GROUPS = { + LIBRARY: "g:library", + SOURCES: "g:sources", +} as const; + +export const CACHE_KEYS = { + LIBRARY: "library", + ALL_MANGA: "all_manga_unfiltered", + CATEGORIES: "categories", + SEARCH: "search_all_manga", + SOURCES: "sources", + POPULAR: "popular", + GENRE: (genre: string) => `genre:${genre}`, + MANGA: (id: number) => `manga:${id}`, + CHAPTERS: (id: number) => `chapters:${id}`, + + sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string { + const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); + return `pages:${sourceId}:${type}:${q}`; + }, + + sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string { + const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); + return `page:${sourceId}:${type}:${page}:${q}`; + }, +} as const; + +const inflight = new Map>(); + +export function deduped(key: string, fetcher: () => Promise): Promise { + if (inflight.has(key)) return inflight.get(key) as Promise; + const p = fetcher().finally(() => inflight.delete(key)); + inflight.set(key, p); + return p; +} + +const _pageSets = new Map>(); + +export interface PageSet { + add(page: number): void; + pages(): Set; + next(): number; + clear(): void; +} + +export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet { + const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query); + return { + add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); }, + pages() { return new Set(_pageSets.get(key) ?? []); }, + next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; }, + clear() { _pageSets.delete(key); }, + }; +} + +const FRECENCY_KEY = "moku-source-frecency"; +const MAX_FRECENCY_SOURCES = 4; +type FrecencyMap = Record; + +function loadFrecency(): FrecencyMap { + try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; } + catch { return {}; } +} + +function saveFrecency(map: FrecencyMap) { + try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {} +} + +export function recordSourceAccess(sourceId: string) { + if (!sourceId || sourceId === "0") return; + const map = loadFrecency(); + map[sourceId] = (map[sourceId] ?? 0) + 1; + saveFrecency(map); +} + +export function getTopSources(sources: T[]): T[] { + const map = loadFrecency(); + const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 })); + if (withScore.some(x => x.score > 0)) { + return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s); + } + return sources.slice(0, MAX_FRECENCY_SOURCES); +} diff --git a/src/lib/keybinds.ts b/src/core/keybinds/defaultBinds.ts similarity index 67% rename from src/lib/keybinds.ts rename to src/core/keybinds/defaultBinds.ts index 8823f99..7f989dd 100644 --- a/src/lib/keybinds.ts +++ b/src/core/keybinds/defaultBinds.ts @@ -1,5 +1,3 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; - export interface Keybinds { turnPageRight: string; turnPageLeft: string; @@ -47,28 +45,3 @@ export const KEYBIND_LABELS: Record = { toggleBookmark: "Toggle bookmark", toggleMarker: "Toggle marker", }; - -export function eventToKeybind(e: KeyboardEvent): string { - if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return ""; - const parts: string[] = []; - if (e.ctrlKey) parts.push("ctrl"); - if (e.altKey) parts.push("alt"); - if (e.shiftKey) parts.push("shift"); - if (e.metaKey) parts.push("meta"); - parts.push(e.key); - return parts.join("+"); -} - -export function matchesKeybind(e: KeyboardEvent, bind: string): boolean { - return eventToKeybind(e) === bind; -} - -export async function toggleFullscreen(): Promise { - try { - const win = getCurrentWindow(); - const isFs = await win.isFullscreen(); - await win.setFullscreen(!isFs); - } catch (e) { - console.warn("toggleFullscreen unavailable:", e); - } -} diff --git a/src/core/keybinds/index.ts b/src/core/keybinds/index.ts new file mode 100644 index 0000000..5bb9877 --- /dev/null +++ b/src/core/keybinds/index.ts @@ -0,0 +1,3 @@ +export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine"; +export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds"; +export type { Keybinds } from "./defaultBinds"; diff --git a/src/core/keybinds/keybindEngine.ts b/src/core/keybinds/keybindEngine.ts new file mode 100644 index 0000000..f905c37 --- /dev/null +++ b/src/core/keybinds/keybindEngine.ts @@ -0,0 +1,25 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; + +export function eventToKeybind(e: KeyboardEvent): string { + if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return ""; + const parts: string[] = []; + if (e.ctrlKey) parts.push("ctrl"); + if (e.altKey) parts.push("alt"); + if (e.shiftKey) parts.push("shift"); + if (e.metaKey) parts.push("meta"); + parts.push(e.key); + return parts.join("+"); +} + +export function matchesKeybind(e: KeyboardEvent, bind: string): boolean { + return eventToKeybind(e) === bind; +} + +export async function toggleFullscreen(): Promise { + try { + const win = getCurrentWindow(); + await win.setFullscreen(!await win.isFullscreen()); + } catch (e) { + console.warn("toggleFullscreen unavailable:", e); + } +} diff --git a/src/core/theme.ts b/src/core/theme.ts new file mode 100644 index 0000000..7cbe378 --- /dev/null +++ b/src/core/theme.ts @@ -0,0 +1,36 @@ +import { store } from "@store/state.svelte"; + +let themeStyleEl: HTMLStyleElement | null = null; + +export function applyTheme() { + const themeId = store.settings.theme ?? "dark"; + const isCustom = themeId.startsWith("custom:"); + + if (!isCustom) { + themeStyleEl?.remove(); + themeStyleEl = null; + document.documentElement.setAttribute("data-theme", themeId); + return; + } + + const custom = store.settings.customThemes?.find(t => t.id === themeId); + if (!custom) { + themeStyleEl?.remove(); + themeStyleEl = null; + document.documentElement.setAttribute("data-theme", "dark"); + return; + } + + const vars = Object.entries(custom.tokens) + .map(([k, v]) => ` --${k}: ${v};`) + .join("\n"); + const css = `[data-theme="custom"] {\n${vars}\n}`; + + if (!themeStyleEl) { + themeStyleEl = document.createElement("style"); + themeStyleEl.id = "moku-custom-theme"; + document.head.appendChild(themeStyleEl); + } + themeStyleEl.textContent = css; + document.documentElement.setAttribute("data-theme", "custom"); +} diff --git a/src/core/ui/idle.ts b/src/core/ui/idle.ts new file mode 100644 index 0000000..97d5d12 --- /dev/null +++ b/src/core/ui/idle.ts @@ -0,0 +1,23 @@ +import { store } from "@store/state.svelte"; + +const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const; + +export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void { + let timer: ReturnType | null = null; + + function reset() { + if (timer) clearTimeout(timer); + const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000; + if (ms === 0) return; + timer = setTimeout(onIdle, ms); + onActive(); + } + + IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true })); + reset(); + + return () => { + if (timer) clearTimeout(timer); + IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset)); + }; +} diff --git a/src/core/ui/index.ts b/src/core/ui/index.ts new file mode 100644 index 0000000..a54f13a --- /dev/null +++ b/src/core/ui/index.ts @@ -0,0 +1,2 @@ +export * from './idle'; +export * from './zoom'; \ No newline at end of file diff --git a/src/core/ui/zoom.ts b/src/core/ui/zoom.ts new file mode 100644 index 0000000..700a072 --- /dev/null +++ b/src/core/ui/zoom.ts @@ -0,0 +1,40 @@ +import { store } from "@store/state.svelte"; + +let _appliedZoom: number = -1; +let _vhRafId: number | null = null; + +export function applyZoom() { + const uiZoom = store.settings.uiZoom ?? 1.0; + if (uiZoom === _appliedZoom) return; + _appliedZoom = uiZoom; + + document.documentElement.style.setProperty("--ui-zoom", String(uiZoom)); + document.documentElement.style.setProperty("--ui-scale", String(uiZoom)); + document.documentElement.style.zoom = `${uiZoom * 100}%`; + + if (_vhRafId !== null) cancelAnimationFrame(_vhRafId); + _vhRafId = requestAnimationFrame(() => { + _vhRafId = null; + document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`); + }); +} + +export function handleZoomKey(e: KeyboardEvent) { + if (!e.ctrlKey) return; + const current = store.settings.uiZoom ?? 1.0; + if (e.key === "=" || e.key === "+") { + e.preventDefault(); + store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); + } else if (e.key === "-") { + e.preventDefault(); + store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); + } else if (e.key === "0") { + e.preventDefault(); + store.settings.uiZoom = 1.0; + } +} + +export function mountZoomKey(): () => void { + window.addEventListener("keydown", handleZoomKey); + return () => window.removeEventListener("keydown", handleZoomKey); +} diff --git a/src/core/updater.ts b/src/core/updater.ts new file mode 100644 index 0000000..005f012 --- /dev/null +++ b/src/core/updater.ts @@ -0,0 +1,40 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getVersion } from "@tauri-apps/api/app"; +import { addToast } from "@store/state.svelte"; + +function parse(tag: string): 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); + } + return 0; +} + +export async function checkForUpdateSilently(): Promise { + try { + const [currentVersion, releases] = await Promise.all([ + getVersion(), + invoke>("list_releases"), + ]); + + 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/, ""); + + if (compare(parse(latestTag), parse(currentVersion)) < 0) { + addToast({ + kind: "info", + title: `Update available — v${latestTag}`, + body: "Open Settings → About to install.", + duration: 8000, + }); + } + } catch {} +} diff --git a/src/lib/util.ts b/src/core/util.ts similarity index 51% rename from src/lib/util.ts rename to src/core/util.ts index ea5403e..acb9212 100644 --- a/src/lib/util.ts +++ b/src/core/util.ts @@ -1,16 +1,43 @@ -import { clsx, type ClassValue } from "clsx"; -import type { Source } from "./types"; +import type { Manga, Source } from "@types"; +import type { Settings } from "@types"; -export function cn(...inputs: ClassValue[]) { - return clsx(inputs); +// ── Class utility ───────────────────────────────────────────────────────────── + +export { clsx as cn } from "clsx"; + +// ── Time / formatting ───────────────────────────────────────────────────────── + +export function timeAgo(ts: number): string { + const diff = Date.now() - ts, m = Math.floor(diff / 60000); + if (m < 1) return "Just now"; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d ago`; + return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); } -// ── NSFW genre filtering ────────────────────────────────────────────────────── +export function dayLabel(ts: number): string { + const d = new Date(ts), now = new Date(); + if (d.toDateString() === now.toDateString()) return "Today"; + const yest = new Date(now); yest.setDate(now.getDate() - 1); + if (d.toDateString() === yest.toDateString()) return "Yesterday"; + return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); +} + +export function formatReadTime(m: number): string { + if (m < 1) return "< 1 min"; + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60), r = m % 60; + return r === 0 ? `${h}h` : `${h}h ${r}m`; +} + +// ── NSFW filtering ──────────────────────────────────────────────────────────── /** - * Default substrings used when no user-configured list is available. - * The Settings > Content tab lets users add/remove entries from this list, - * which is stored as settings.nsfwFilteredTags. + * Default genre substrings used when no user-configured list is available. + * Stored as settings.nsfwFilteredTags; editable in Settings > Content. */ export const DEFAULT_NSFW_TAGS = [ "adult", @@ -27,55 +54,39 @@ export const DEFAULT_NSFW_TAGS = [ ]; /** - * Returns true if the manga carries at least one genre tag matching any of - * the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags - * as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted. + * Returns true if the manga's genre list contains any of the given substrings. + * Falls back to DEFAULT_NSFW_TAGS if no tag list is provided. */ export function isNsfwManga( manga: { genre?: string[] | null }, tags: string[] = DEFAULT_NSFW_TAGS, ): boolean { - return (manga.genre ?? []).some((g) => { - const normalized = g.toLowerCase().trim(); - return tags.some((sub) => normalized.includes(sub)); - }); + return (manga.genre ?? []).some(g => + tags.some(sub => g.toLowerCase().trim().includes(sub)) + ); } /** * Single authoritative NSFW gate used by all views. + * Returns true when the manga should be HIDDEN. Priority order: + * 1. Source in blockedSourceIds → always hidden, even when showNsfw is on. + * 2. showNsfw globally enabled → only blocked sources are hidden. + * 3. Source in allowedSourceIds → skip isNsfw flag, but genre tags still apply. + * 4. source.isNsfw flag → hidden. + * 5. Genre tag match → hidden. * - * Returns true when the manga should be HIDDEN. Checks in order: - * 1. showNsfw disabled globally → skip everything, hide by source flag or genre match. - * 2. Source is in blockedSourceIds → always hide regardless of showNsfw. - * 3. Source is in allowedSourceIds → always show (bypasses isNsfw flag only, genre tags still apply). - * 4. Source isNsfw flag → hide unless source is allowed. - * 5. Genre tag match → hide. - * - * Usage: items.filter(m => !shouldHideNsfw(m, settings)) + * Usage: items.filter(m => !shouldHideNsfw(m, settings)) */ export function shouldHideNsfw( - manga: { - genre?: string[] | null; - source?: { id?: string; isNsfw?: boolean } | null; - }, - settings: { - showNsfw: boolean; - nsfwFilteredTags: string[]; - nsfwAllowedSourceIds: string[]; - nsfwBlockedSourceIds: string[]; - }, + manga: Pick, + settings: Pick, ): boolean { const srcId = manga.source?.id; - // Explicit block always wins, even when showNsfw is on if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true; - - // If NSFW is globally allowed, only explicit blocks apply if (settings.showNsfw) return false; - // Source is explicitly allowed — skip the isNsfw flag check, but still filter genres const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId)); - if (!sourceAllowed && manga.source?.isNsfw) return true; return isNsfwManga(manga, settings.nsfwFilteredTags); @@ -83,21 +94,11 @@ export function shouldHideNsfw( /** * Gate for Source objects — parallel to shouldHideNsfw for manga. - * - * Priority: - * 1. Blocked list → always hidden, even when showNsfw is on. - * 2. Allowed list → always shown, even if isNsfw is true. - * 3. Fallback → hide when showNsfw is off and source.isNsfw is true. - * - * Usage: sources.filter(s => !shouldHideSource(s, settings)) + * Usage: sources.filter(s => !shouldHideSource(s, settings)) */ export function shouldHideSource( - source: { id: string; isNsfw: boolean }, - settings: { - showNsfw: boolean; - nsfwAllowedSourceIds: string[]; - nsfwBlockedSourceIds: string[]; - }, + source: Pick, + settings: Pick, ): boolean { if (settings.nsfwBlockedSourceIds.includes(source.id)) return true; if (settings.nsfwAllowedSourceIds.includes(source.id)) return false; @@ -106,6 +107,11 @@ export function shouldHideSource( // ── Source deduplication ────────────────────────────────────────────────────── +/** + * Deduplicates sources by name. When multiple sources share a name, + * the preferred language wins; otherwise falls back to alphabetical by lang. + * The local source (id "0") is always excluded. + */ export function dedupeSources(sources: Source[], preferredLang: string): Source[] { const byName = new Map(); for (const src of sources) { @@ -115,7 +121,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[ } const picked: Source[] = []; for (const group of byName.values()) { - const preferred = group.find((s) => s.lang === preferredLang); + const preferred = group.find(s => s.lang === preferredLang); picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]); } return picked; @@ -123,12 +129,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[ // ── Manga deduplication ─────────────────────────────────────────────────────── -/** - * Normalizes a title for fuzzy matching. - * Strips punctuation, articles, and common source-specific suffixes so that - * "The Greatest Estate Developer" and "Yeokdaegeum Yeongji Seolgyesa" won't - * match on title alone — but their identical descriptions will catch them. - */ +/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */ export function normalizeTitle(title: string): string { return title .toLowerCase() @@ -139,76 +140,61 @@ export function normalizeTitle(title: string): string { .trim(); } -/** - * Normalizes a string for fingerprinting — strip all non-alpha, collapse spaces. - */ +/** Strips all non-alphanumeric chars and collapses whitespace. */ function norm(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim(); } /** - * Description fingerprint — first 200 normalized chars. - * Long enough to reliably identify the same series across sources even when - * translations differ in punctuation or minor wording. - * Returns null if too short (< 60 chars) to be a reliable signal. + * First 200 normalized chars of a description — reliable cross-source fingerprint. + * Returns null if too short (< 60 chars) to be a trustworthy signal. */ function descFingerprint(desc: string | null | undefined): string | null { if (!desc) return null; const n = norm(desc); - if (n.length < 60) return null; - return n.slice(0, 200); + return n.length >= 60 ? n.slice(0, 200) : null; } /** - * Author fingerprint — normalized concatenation of author + artist. - * Used as a tie-breaker / additional signal alongside description. - * Two manga with the same authors AND same description are almost certainly - * the same series. Returns null if no author info. + * Normalized author + artist concatenation for tie-breaking. + * Returns null if no author info available. */ function authorFingerprint(author?: string | null, artist?: string | null): string | null { const parts = [author, artist].filter(Boolean).map(s => norm(s!)); - if (!parts.length) return null; - return parts.sort().join("|"); + return parts.length ? parts.sort().join("|") : null; } /** - * Deduplicates manga by: - * 1. Normalized title - * 2. Description fingerprint (first 200 chars) - * 3. Author + description together - * 4. User-defined links (mangaLinks from store) — explicit "same series" overrides + * Deduplicates manga across sources using title, description, and author signals, + * plus explicit user-defined links (settings.mangaLinks). * - * Pass `links` as `settings.mangaLinks` to honour user-registered pairs. - * When two entries match, the PREFERRED one is kept: - * - Library membership wins - * - Otherwise higher downloadCount wins - * - Otherwise first occurrence wins + * When two entries match, the better one is kept: + * - Library membership wins over non-library. + * - Otherwise higher downloadCount wins. + * - Otherwise first occurrence wins. */ export function dedupeMangaByTitle(items: T[], links: Record = {}): T[] { const byTitle = new Map(); const byDesc = new Map(); const byAuthorDesc = new Map(); - // id → index in out[] const byId = new Map(); - const out: T[] = []; + const out: T[] = []; for (const m of items) { const tk = normalizeTitle(m.title); const dk = descFingerprint(m.description); const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null; - // Check user-defined links first (explicit override) - const linkedIds = links[m.id] ?? []; - const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); - + const linkedIds = links[m.id] ?? []; + const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); const existingIdx = linkedIdx ?? byTitle.get(tk) ?? @@ -217,7 +203,7 @@ export function dedupeMangaByTitle (existing.downloadCount ?? 0)); @@ -243,7 +229,7 @@ export function dedupeMangaByTitle(items: T[]): T[] { const seen = new Set(); @@ -252,4 +238,4 @@ export function dedupeMangaById(items: T[]): T[] { if (!seen.has(m.id)) { seen.add(m.id); out.push(m); } } return out; -} +} \ No newline at end of file diff --git a/src/styles/animations.css b/src/design/base/animations.css similarity index 51% rename from src/styles/animations.css rename to src/design/base/animations.css index 8e75f29..1234923 100644 --- a/src/styles/animations.css +++ b/src/design/base/animations.css @@ -1,7 +1,3 @@ -/* ───────────────────────────────────────────── - Moku — Animations - ───────────────────────────────────────────── */ - @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } @@ -34,26 +30,19 @@ @keyframes shimmer { from { background-position: -200% 0; } - to { background-position: 200% 0; } + to { background-position: 200% 0; } } -/* Utility classes */ -.anim-fade-in { animation: fadeIn 0.14s ease both; } -.anim-fade-up { animation: fadeUp 0.18s ease both; } +.anim-fade-in { animation: fadeIn 0.14s ease both; } +.anim-fade-up { animation: fadeUp 0.18s ease both; } .anim-fade-down { animation: fadeDown 0.18s ease both; } -.anim-scale-in { animation: scaleIn 0.14s ease both; } -.anim-pulse { animation: pulse 1.6s ease infinite; } -.anim-spin { animation: spin 0.7s linear infinite; } +.anim-scale-in { animation: scaleIn 0.14s ease both; } +.anim-pulse { animation: pulse 1.6s ease infinite; } +.anim-spin { animation: spin 0.7s linear infinite; } -/* Skeleton shimmer */ .skeleton { - background: linear-gradient( - 90deg, - var(--bg-raised) 25%, - var(--bg-overlay) 50%, - var(--bg-raised) 75% - ); + background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.4s ease infinite; border-radius: var(--radius-sm); -} \ No newline at end of file +} diff --git a/src/design/base/index.css b/src/design/base/index.css new file mode 100644 index 0000000..ffd5908 --- /dev/null +++ b/src/design/base/index.css @@ -0,0 +1,4 @@ +@import "./reset.css"; +@import "./animations.css"; +@import "./scrollbars.css"; +@import "./typography.css"; \ No newline at end of file diff --git a/src/design/base/reset.css b/src/design/base/reset.css new file mode 100644 index 0000000..2b05743 --- /dev/null +++ b/src/design/base/reset.css @@ -0,0 +1,41 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + overflow: hidden; + background: var(--bg-void); + color: var(--text-primary); +} + +#app { + height: 100%; +} + +button { + cursor: pointer; + font: inherit; + color: inherit; + background: none; + border: none; + padding: 0; +} + +input, textarea, select { + font: inherit; + color: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +ul, ol { list-style: none; } + +img, svg { display: block; max-width: 100%; } + +p { margin: 0; } \ No newline at end of file diff --git a/src/design/base/scrollbars.css b/src/design/base/scrollbars.css new file mode 100644 index 0000000..9bbf59e --- /dev/null +++ b/src/design/base/scrollbars.css @@ -0,0 +1,9 @@ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +*::-webkit-scrollbar { width: 4px; height: 4px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 99px; } +*::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } \ No newline at end of file diff --git a/src/design/base/typography.css b/src/design/base/typography.css new file mode 100644 index 0000000..0a52eb1 --- /dev/null +++ b/src/design/base/typography.css @@ -0,0 +1,9 @@ +body { + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: var(--weight-normal); + line-height: var(--leading-base); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} \ No newline at end of file diff --git a/src/design/themes/high-contrast.css b/src/design/themes/high-contrast.css new file mode 100644 index 0000000..acf7260 --- /dev/null +++ b/src/design/themes/high-contrast.css @@ -0,0 +1,25 @@ +[data-theme="high-contrast"] { + --bg-void: #000000; + --bg-base: #080808; + --bg-surface: #0d0d0d; + --bg-raised: #111111; + --bg-overlay: #171717; + --bg-subtle: #1e1e1e; + + --border-dim: #252525; + --border-base: #303030; + --border-strong: #3e3e3e; + --border-focus: #5a7a5a; + + --text-primary: #ffffff; + --text-secondary: #e8e6e0; + --text-muted: #b0aea8; + --text-faint: #6e6c68; + --text-disabled: #303030; + + --accent: #7aaa7a; + --accent-dim: #2e4a2e; + --accent-muted: #1e2e1e; + --accent-fg: #bcd8bc; + --accent-bright: #9fcf9f; +} diff --git a/src/design/themes/index.css b/src/design/themes/index.css new file mode 100644 index 0000000..245464d --- /dev/null +++ b/src/design/themes/index.css @@ -0,0 +1,5 @@ +@import "./high-contrast.css"; +@import "./light-contrast.css"; +@import "./light.css"; +@import "./midnight.css"; +@import "./warm.css"; \ No newline at end of file diff --git a/src/design/themes/light-contrast.css b/src/design/themes/light-contrast.css new file mode 100644 index 0000000..1d1d8e3 --- /dev/null +++ b/src/design/themes/light-contrast.css @@ -0,0 +1,29 @@ +[data-theme="light-contrast"] { + --bg-void: #d8d4ce; + --bg-base: #e2deda; + --bg-surface: #ece8e2; + --bg-raised: #f5f2ec; + --bg-overlay: #ffffff; + --bg-subtle: #e4e0d8; + + --border-dim: #c4c0b8; + --border-base: #b0aca4; + --border-strong: #989490; + --border-focus: #3a5a3a; + + --text-primary: #080806; + --text-secondary: #181612; + --text-muted: #38342e; + --text-faint: #706c64; + --text-disabled: #b0aca4; + + --accent: #2a5a2a; + --accent-dim: #b0ccb0; + --accent-muted: #c8dcc8; + --accent-fg: #183818; + --accent-bright: #1e4e1e; + + --color-error: #8a1a1a; + --color-error-bg: #f8e0e0; + --color-read: #e0dcd4; +} diff --git a/src/design/themes/light.css b/src/design/themes/light.css new file mode 100644 index 0000000..c081d2e --- /dev/null +++ b/src/design/themes/light.css @@ -0,0 +1,32 @@ +[data-theme="light"] { + --bg-void: #e8e6e2; + --bg-base: #eeece8; + --bg-surface: #f4f2ee; + --bg-raised: #faf8f4; + --bg-overlay: #ffffff; + --bg-subtle: #f0ede8; + + --border-dim: #dedad4; + --border-base: #d0ccc6; + --border-strong: #bbb6ae; + --border-focus: #5a7a5a; + + --text-primary: #1a1916; + --text-secondary: #2e2c28; + --text-muted: #5a5750; + --text-faint: #9a9890; + --text-disabled: #c8c4bc; + + --accent: #4a724a; + --accent-dim: #c8dcc8; + --accent-muted: #deeade; + --accent-fg: #2a5a2a; + --accent-bright: #3a6a3a; + + --color-error: #a03030; + --color-error-bg: #fce8e8; + --color-success: #2a6a2a; + --color-info: #2a4a7a; + --color-info-bg: #e8eef8; + --color-read: #e8e4dc; +} diff --git a/src/design/themes/midnight.css b/src/design/themes/midnight.css new file mode 100644 index 0000000..2455908 --- /dev/null +++ b/src/design/themes/midnight.css @@ -0,0 +1,25 @@ +[data-theme="midnight"] { + --bg-void: #050810; + --bg-base: #080c18; + --bg-surface: #0c1020; + --bg-raised: #101428; + --bg-overlay: #151a30; + --bg-subtle: #1a2038; + + --border-dim: #1a2035; + --border-base: #222840; + --border-strong: #2c3450; + --border-focus: #4a5c8a; + + --text-primary: #eeeef8; + --text-secondary: #c0c4d8; + --text-muted: #808498; + --text-faint: #404860; + --text-disabled: #202840; + + --accent: #6a7ab8; + --accent-dim: #252d50; + --accent-muted: #181e38; + --accent-fg: #a8b4e8; + --accent-bright: #8896d0; +} diff --git a/src/design/themes/warm.css b/src/design/themes/warm.css new file mode 100644 index 0000000..63422cb --- /dev/null +++ b/src/design/themes/warm.css @@ -0,0 +1,25 @@ +[data-theme="warm"] { + --bg-void: #0c0a06; + --bg-base: #100e08; + --bg-surface: #16130c; + --bg-raised: #1c1810; + --bg-overlay: #221e14; + --bg-subtle: #28241a; + + --border-dim: #201c10; + --border-base: #2c2818; + --border-strong: #3a3420; + --border-focus: #6a5a30; + + --text-primary: #f5f0e0; + --text-secondary: #d8d0b0; + --text-muted: #988c60; + --text-faint: #584e30; + --text-disabled: #302a18; + + --accent: #c0902a; + --accent-dim: #3a2c10; + --accent-muted: #261e0c; + --accent-fg: #e0b860; + --accent-bright: #d0a040; +} diff --git a/src/design/tokens/colors.css b/src/design/tokens/colors.css new file mode 100644 index 0000000..4e6b2eb --- /dev/null +++ b/src/design/tokens/colors.css @@ -0,0 +1,35 @@ +:root { + --bg-void: #080808; + --bg-base: #0c0c0c; + --bg-surface: #101010; + --bg-raised: #151515; + --bg-overlay: #1a1a1a; + --bg-subtle: #202020; + + --border-dim: #1c1c1c; + --border-base: #242424; + --border-strong: #2e2e2e; + --border-focus: #4a5c4a; + + --text-primary: #f0efec; + --text-secondary: #c8c6c0; + --text-muted: #8a8880; + --text-faint: #4e4d4a; + --text-disabled: #2a2a28; + + --accent: #6b8f6b; + --accent-dim: #2a3d2a; + --accent-muted: #1a251a; + --accent-fg: #a8c4a8; + --accent-bright: #8fb88f; + + --color-error: #c47a7a; + --color-error-bg: #1f1212; + --color-success: #7aab7a; + --color-info: #7a9ec4; + --color-info-bg: #121a1f; + --color-read: #2e2e2c; + + --dot-active: var(--accent); + --dot-inactive: var(--text-faint); +} diff --git a/src/design/tokens/index.css b/src/design/tokens/index.css new file mode 100644 index 0000000..a94bed5 --- /dev/null +++ b/src/design/tokens/index.css @@ -0,0 +1,8 @@ +@import "./colors.css"; +@import "./typography.css"; +@import "./spacing.css"; +@import "./radius.css"; +@import "./motion.css"; +@import "./shadows.css"; +@import "./zindex.css"; +@import "../themes/index.css"; \ No newline at end of file diff --git a/src/design/tokens/motion.css b/src/design/tokens/motion.css new file mode 100644 index 0000000..6064a42 --- /dev/null +++ b/src/design/tokens/motion.css @@ -0,0 +1,5 @@ +:root { + --t-fast: 0.08s ease; + --t-base: 0.14s ease; + --t-slow: 0.22s ease; +} diff --git a/src/design/tokens/radius.css b/src/design/tokens/radius.css new file mode 100644 index 0000000..d19a858 --- /dev/null +++ b/src/design/tokens/radius.css @@ -0,0 +1,8 @@ +:root { + --radius-sm: 3px; + --radius-md: 5px; + --radius-lg: 7px; + --radius-xl: 10px; + --radius-2xl: 14px; + --radius-full: 9999px; +} diff --git a/src/design/tokens/shadows.css b/src/design/tokens/shadows.css new file mode 100644 index 0000000..5a4260f --- /dev/null +++ b/src/design/tokens/shadows.css @@ -0,0 +1,2 @@ +:root { +} diff --git a/src/design/tokens/spacing.css b/src/design/tokens/spacing.css new file mode 100644 index 0000000..83ce6dd --- /dev/null +++ b/src/design/tokens/spacing.css @@ -0,0 +1,12 @@ +:root { + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + + --sidebar-width: 52px; +} diff --git a/src/design/tokens/typography.css b/src/design/tokens/typography.css new file mode 100644 index 0000000..e7e57b7 --- /dev/null +++ b/src/design/tokens/typography.css @@ -0,0 +1,28 @@ +:root { + --font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace; + --font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; + + --text-2xs: 10px; + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-md: 14px; + --text-lg: 15px; + --text-xl: 17px; + --text-2xl: 20px; + --text-3xl: 24px; + + --weight-normal: 400; + --weight-medium: 500; + --weight-semi: 600; + + --leading-none: 1; + --leading-tight: 1.3; + --leading-snug: 1.45; + --leading-base: 1.6; + + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-wide: 0.06em; + --tracking-wider: 0.1em; +} diff --git a/src/design/tokens/zindex.css b/src/design/tokens/zindex.css new file mode 100644 index 0000000..aebac7d --- /dev/null +++ b/src/design/tokens/zindex.css @@ -0,0 +1,5 @@ +:root { + --z-reader: 50; + --z-modal: 100; + --z-settings: 150; +} diff --git a/src/design/utilities/layout.css b/src/design/utilities/layout.css new file mode 100644 index 0000000..e69de29 diff --git a/src/design/utilities/text.css b/src/design/utilities/text.css new file mode 100644 index 0000000..e69de29 diff --git a/src/design/utilities/visibility.css b/src/design/utilities/visibility.css new file mode 100644 index 0000000..e69de29 diff --git a/src/components/pages/GenreDrillPage.svelte b/src/features/discover/components/GenreDrillPage.svelte similarity index 71% rename from src/components/pages/GenreDrillPage.svelte rename to src/features/discover/components/GenreDrillPage.svelte index 45037e0..08356d1 100644 --- a/src/components/pages/GenreDrillPage.svelte +++ b/src/features/discover/components/GenreDrillPage.svelte @@ -1,38 +1,24 @@ + +
+ + + {#if hasMultipleLangs && kw_showAdvanced} +
+
+ Languages +
+ + +
+
+
+ {#each availableLangs as lang (lang)} + + {/each} +
+
+
+ Searching {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} +
+
+ {/if} +
+ +{#if !kw_query.trim()} + {#if popularLoading && popularResults.length === 0} +
+ {#each Array(24) as _, i (i)}
{/each} +
+ {:else if popularResults.length > 0} +
+ Popular right now +
+
+ {#each popularResults as m (m.id)} + + {/each} + {#if popularLoading} + {#each Array(12) as _, i (i)}
{/each} + {/if} +
+ {:else} +
+ +

Search across sources

+

+ {#if hasMultipleLangs} + {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""} + {:else} + {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} + {/if} +

+
+ {/if} +{:else} + {#if kw_flatResults.length > 0} +
+ {kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} +
+
+ {#each kw_flatResults as m (m.id)} + + {/each} + {#if kw_anyLoading} + {#each Array(6) as _, i (i)}
{/each} + {/if} +
+ {:else if kw_anyLoading} +
+ {#each Array(12) as _, i (i)}
{/each} +
+ {:else if kw_allDone && !kw_hasResults} +
+

No results for "{kw_query.trim()}"

+

Try a different spelling or fewer words

+
+ {/if} +{/if} + + + + \ No newline at end of file diff --git a/src/features/discover/components/Search.svelte b/src/features/discover/components/Search.svelte new file mode 100644 index 0000000..a3d972f --- /dev/null +++ b/src/features/discover/components/Search.svelte @@ -0,0 +1,298 @@ + + +
+
+

Search

+
+ + + +
+
+ + {#if tab === "keyword"} + (pendingPrefill = "")} + onPreview={setPreviewManga} + /> + {:else if tab === "tag"} + + {:else} + + {/if} +
+ + \ No newline at end of file diff --git a/src/features/discover/components/SourceTab.svelte b/src/features/discover/components/SourceTab.svelte new file mode 100644 index 0000000..b5d40e3 --- /dev/null +++ b/src/features/discover/components/SourceTab.svelte @@ -0,0 +1,285 @@ + + +
+ +
+
+ Language + +
+ + {#if loadingSources} +
+ +
+ {:else} +
+ {#each src_visibleSources as src (src.id)} + + {/each} + {#if src_visibleSources.length === 0} +

No sources for this language

+ {/if} +
+ {/if} +
+ + +
+ {#if !src_activeSource} +
+ +

Browse a source

+

Select a source to see its popular titles, or search within it.

+
+ {:else} +
+
+ { (e.target as HTMLImageElement).style.display = "none"; }} /> + {src_activeSource.displayName} + {#if src_loadingBrowse} + + {:else if src_browseResults.length > 0} + {src_browseResults.length} results + {/if} +
+
+ +
+ + +
+ + {#if src_loadingBrowse && src_browseResults.length === 0} +
+ {#each Array(18) as _, i (i)} +
+ {/each} +
+ {:else if src_browseResults.length > 0} +
+ {#each src_browseResults as m, i (m.id)} + + {/each} + {#if src_hasNextPage} +
+ +
+ {/if} +
+ {:else if !src_loadingBrowse} +
+

No results

+

Try a different search term.

+
+ {/if} + {/if} +
+
+ + \ No newline at end of file diff --git a/src/features/discover/components/TagTab.svelte b/src/features/discover/components/TagTab.svelte new file mode 100644 index 0000000..42c153d --- /dev/null +++ b/src/features/discover/components/TagTab.svelte @@ -0,0 +1,474 @@ + + +
+ +
+
+ + + {#if tag_tagFilter} + + {/if} +
+
+
Status
+ {#each MANGA_STATUSES as { value, label } (value)} + + {/each} +
Genre
+ {#each tag_filteredGenres as tag (tag)} + + {/each} + {#if tag_filteredGenres.length === 0} +

No matching genres

+ {/if} +
+
+ + +
+ {#if !tag_hasActiveFilters} +
+ +

Browse by tag

+

Select a status or genre to find matching manga.

+
+ {:else} + +
+
+ {#each tag_activeStatuses as status (status)} + + {MANGA_STATUSES.find((s) => s.value === status)?.label ?? status} + + + {/each} + {#each tag_activeTags as tag (tag)} + + {tag} + + + {/each} +
+
+ {#if tag_activeTags.length > 1} +
+ + +
+ {/if} + + +
+
+ + +
+ + {#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0} + {tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")} + {:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0} + {tag_activeTags[0]} + {:else} + {[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)} + {/if} + {#if tag_searchSources} + + sources + {/if} + + {#if tag_loadingLocal} + + {:else} + + {tag_totalVisible}{tag_localHasNext ? "+" : ""} results + {#if tag_searchSources && sourceCacheReady} + · {sourceCache.size} cached + {/if} + + {/if} +
+ + + {#if tag_loadingLocal} +
+ {#each Array(48) as _, i (i)} +
+ {/each} +
+ {:else if tag_mergedResults.length > 0} +
+ {#each tag_mergedResults as m, i (m.id)} + + {/each} + {#if tag_loadingMoreLocal} + {#each Array(12) as _, i (i)} +
+ {/each} + {/if} +
+ {:else} +
+

No results

+

+ {#if tag_searchSources}Try OR mode or broader tags. + {:else}Try OR mode, enable Sources, or check your library. + {/if} +

+
+ {/if} + {/if} +
+
+ + \ No newline at end of file diff --git a/src/features/discover/index.ts b/src/features/discover/index.ts new file mode 100644 index 0000000..d086c34 --- /dev/null +++ b/src/features/discover/index.ts @@ -0,0 +1,2 @@ +export { default as Search } from "./components/Search.svelte"; +export * from "./lib/searchFilter"; diff --git a/src/features/discover/lib/searchFilter.ts b/src/features/discover/lib/searchFilter.ts new file mode 100644 index 0000000..002e3c6 --- /dev/null +++ b/src/features/discover/lib/searchFilter.ts @@ -0,0 +1,138 @@ +import type { Settings } from "@types"; +import { shouldHideNsfw } from "@core/util"; + +export const PAGE_SIZE = 50; +export const INITIAL_PAGES = 3; +export const MAX_SOURCES = 12; +export const CONCURRENCY = 4; + +export function parseTags(f: string): string[] { + return f.split("+").map((t) => t.trim()).filter(Boolean); +} + +export function tagsLabel(tags: string[]): string { + if (tags.length === 1) return tags[0]; + return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1]; +} + +export function matchesAllTags(m: { genre?: string[] }, tags: string[]): boolean { + const g = (m.genre ?? []).map((x) => x.toLowerCase()); + return tags.every((t) => g.includes(t.toLowerCase())); +} + +export async function runConcurrent( + items: T[], + fn: (item: T) => Promise, + signal: AbortSignal, +): Promise { + let i = 0; + async function worker() { + while (i < items.length) { + if (signal.aborted) return; + await fn(items[i++]).catch(() => {}); + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); +} + +export type TagMode = "AND" | "OR"; + +export interface CachedManga { + id: number; + title: string; + thumbnailUrl: string; + inLibrary: boolean; + status: string; + genre: string[]; + lowerGenres: string[]; + sourceId: string; + genreEnriched: boolean; +} + + +export const COMMON_GENRES = [ + "Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance", + "Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports", + "Supernatural", "Mecha", "Historical", "Psychological", "School Life", + "Shounen", "Seinen", "Josei", "Shoujo", "Isekai", "Martial Arts", + "Magic", "Music", "Cooking", "Medical", "Military", "Harem", "Ecchi", +] as const; + +export const MANGA_STATUSES: { value: string; label: string }[] = [ + { value: "ONGOING", label: "Ongoing" }, + { value: "COMPLETED", label: "Completed" }, + { value: "HIATUS", label: "Hiatus" }, + { value: "ABANDONED", label: "Abandoned" }, + { value: "UNKNOWN", label: "Unknown" }, +]; + + +export function buildTagFilter( + tags: string[], + mode: TagMode, + statuses: string[], +): Record { + const genrePart: Record | null = + tags.length === 0 ? null : + mode === "AND" + ? { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) } + : { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) }; + + const statusPart: Record | null = + statuses.length === 0 ? null : + statuses.length === 1 + ? { status: { equalTo: statuses[0] } } + : { or: statuses.map((s) => ({ status: { equalTo: s } })) }; + + if (!genrePart && !statusPart) return {}; + if (genrePart && !statusPart) return genrePart; + if (!genrePart && statusPart) return statusPart; + return { and: [genrePart, statusPart] }; +} + + +export function filterSourceCache( + sourceCache: Map, + tags: string[], + mode: TagMode, + statuses: string[], + settings: Pick, +): CachedManga[] { + return [...sourceCache.values()].filter((m) => { + if (shouldHideNsfw(m as any, settings)) return false; + + const statusMatch = + statuses.length === 0 || statuses.includes(m.status); + + let genreMatch = true; + if (tags.length > 0) { + const lower = m.lowerGenres; + if (mode === "AND") { + genreMatch = tags.every((t) => lower.some((g) => g.includes(t.toLowerCase()))); + } else { + genreMatch = tags.some((t) => lower.some((g) => g.includes(t.toLowerCase()))); + } + } + + return statusMatch && genreMatch; + }); +} + + +export function toCachedManga( + m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string }, + srcId: string, +): CachedManga { + const genre = m.genre ?? []; + return { + id: m.id, + title: m.title, + thumbnailUrl: m.thumbnailUrl, + inLibrary: m.inLibrary, + status: m.status ?? "UNKNOWN", + genre, + lowerGenres: genre.map((g) => g.toLowerCase()), + sourceId: srcId, + genreEnriched: genre.length > 0, + }; +} \ No newline at end of file diff --git a/src/features/downloads/components/DownloadItem.svelte b/src/features/downloads/components/DownloadItem.svelte new file mode 100644 index 0000000..dd7e377 --- /dev/null +++ b/src/features/downloads/components/DownloadItem.svelte @@ -0,0 +1,145 @@ + + +
+ {#if manga?.thumbnailUrl} +
+ +
+ {/if} +
+ {#if manga?.title}{manga.title}{/if} + {item.chapter.name} + {#if pages > 0} + {isActive ? `${prog.done} / ${prog.total} pages` : `${prog.total} pages`} + {/if} + {#if isActive} +
+
+
+ {/if} +
+
+ {item.state} + {#if !isActive} + + {/if} +
+
+ + diff --git a/src/features/downloads/components/DownloadQueue.svelte b/src/features/downloads/components/DownloadQueue.svelte new file mode 100644 index 0000000..6311a4a --- /dev/null +++ b/src/features/downloads/components/DownloadQueue.svelte @@ -0,0 +1,51 @@ + + +{#if loading} +
+{:else if queue.length === 0} +
Queue is empty.
+{:else} +
+ {#each queue as item, i (item.chapter.id)} + + {/each} +
+{/if} + + diff --git a/src/features/downloads/components/Downloads.svelte b/src/features/downloads/components/Downloads.svelte new file mode 100644 index 0000000..d54aeda --- /dev/null +++ b/src/features/downloads/components/Downloads.svelte @@ -0,0 +1,142 @@ + + +
+
+

Downloads

+
+ + +
+
+ +
+
+
+ + {downloadStore.togglingPlay + ? (downloadStore.isRunning ? "Pausing…" : "Starting…") + : downloadStore.isRunning ? "Downloading" : "Paused"} + + {downloadStore.queue.length} queued +
+ + downloadStore.dequeue(id)} + /> +
+
+ + diff --git a/src/features/downloads/index.ts b/src/features/downloads/index.ts new file mode 100644 index 0000000..7c55a51 --- /dev/null +++ b/src/features/downloads/index.ts @@ -0,0 +1,2 @@ +export { downloadStore } from "./store/downloadState.svelte"; +export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue"; diff --git a/src/features/downloads/lib/downloadPoller.ts b/src/features/downloads/lib/downloadPoller.ts new file mode 100644 index 0000000..a85ba8a --- /dev/null +++ b/src/features/downloads/lib/downloadPoller.ts @@ -0,0 +1,61 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { gql } from "@api/client"; +import { GET_DOWNLOAD_STATUS } from "@api/queries/downloads"; +import { addToast, setActiveDownloads } from "@store/state.svelte"; +import type { DownloadStatus, DownloadQueueItem } from "@types/index"; + +let prevQueue: DownloadQueueItem[] = []; + +function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) { + for (const item of prev) { + if (item.state !== "DOWNLOADING") continue; + if (!next.some(q => q.chapter.id === item.chapter.id)) { + const manga = item.chapter.manga; + addToast({ + kind: "success", + title: "Chapter downloaded", + body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name, + duration: 4000, + }); + } + } +} + +function applyQueue(next: DownloadQueueItem[]) { + detectCompletions(prevQueue, next); + prevQueue = next; + setActiveDownloads(next.map(item => ({ + chapterId: item.chapter.id, + mangaId: item.chapter.mangaId, + progress: item.progress, + }))); +} + +export async function mountDownloadPoller(): Promise<() => void> { + const win = getCurrentWindow(); + let paused = false; + let interval: ReturnType; + + const poll = () => { + if (paused) return; + gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) + .then(d => applyQueue(d.downloadStatus.queue)) + .catch(console.error); + }; + + poll(); + interval = setInterval(poll, 2000); + + const onVisibility = () => { paused = document.hidden; }; + document.addEventListener("visibilitychange", onVisibility); + + const unlistenFocus = await win.onFocusChanged(({ payload: focused }) => { + paused = !focused; + }); + + return () => { + clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibility); + unlistenFocus(); + }; +} diff --git a/src/features/downloads/lib/downloadQueue.ts b/src/features/downloads/lib/downloadQueue.ts new file mode 100644 index 0000000..66d2ff2 --- /dev/null +++ b/src/features/downloads/lib/downloadQueue.ts @@ -0,0 +1,25 @@ +import type { DownloadQueueItem, ActiveDownload } from "@types/index"; + +export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] { + return queue.map((item) => ({ + chapterId: item.chapter.id, + mangaId: item.chapter.mangaId, + progress: item.progress, + })); +} + +export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] { + return queue.filter((i) => i.chapter.id !== chapterId); +} + +export function optimisticToggle(state: string, wasRunning: boolean): string { + return wasRunning ? "STOPPED" : "STARTED"; +} + +export function isRunning(state: string | undefined): boolean { + return state === "STARTED"; +} + +export function pageProgress(progress: number, pageCount: number): { done: number; total: number } { + return { done: Math.round(progress * pageCount), total: pageCount }; +} diff --git a/src/features/downloads/store/downloadState.svelte.ts b/src/features/downloads/store/downloadState.svelte.ts new file mode 100644 index 0000000..829c54c --- /dev/null +++ b/src/features/downloads/store/downloadState.svelte.ts @@ -0,0 +1,69 @@ +import { gql } from "@api/client"; +import { GET_DOWNLOAD_STATUS } from "@api/queries"; +import { START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "@api/mutations"; +import { setActiveDownloads } from "@store/state.svelte"; +import type { DownloadStatus } from "@types/index"; +import { toActiveDownloads, optimisticRemove, isRunning } from "../lib/downloadQueue"; + +class DownloadStore { + status: DownloadStatus | null = $state(null); + loading = $state(true); + togglingPlay = $state(false); + clearing = $state(false); + dequeueing = $state(new Set()); + + get queue() { return this.status?.queue ?? []; } + get isRunning() { return isRunning(this.status?.state); } + + applyStatus(ds: DownloadStatus) { + this.status = ds; + setActiveDownloads(toActiveDownloads(ds.queue)); + } + + async poll() { + gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) + .then((d) => this.applyStatus(d.downloadStatus)) + .catch(console.error) + .finally(() => this.loading = false); + } + + async togglePlay() { + if (this.togglingPlay) return; + this.togglingPlay = true; + const wasRunning = this.isRunning; + if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" }; + try { + if (wasRunning) { + const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER); + this.applyStatus(d.stopDownloader.downloadStatus); + } else { + const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER); + this.applyStatus(d.startDownloader.downloadStatus); + } + } catch (e) { console.error(e); this.poll(); } + finally { this.togglingPlay = false; } + } + + async clear() { + if (this.clearing) return; + this.clearing = true; + if (this.status) this.status = { ...this.status, queue: [] }; + setActiveDownloads([]); + try { + const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER); + this.applyStatus(d.clearDownloader.downloadStatus); + } catch (e) { console.error(e); this.poll(); } + finally { this.clearing = false; } + } + + async dequeue(chapterId: number) { + if (this.dequeueing.has(chapterId)) return; + this.dequeueing = new Set(this.dequeueing).add(chapterId); + if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) }; + try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); } + catch (e) { console.error(e); this.poll(); } + finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); } + } +} + +export const downloadStore = new DownloadStore(); diff --git a/src/features/extensions/components/ExtensionCard.svelte b/src/features/extensions/components/ExtensionCard.svelte new file mode 100644 index 0000000..f92e0eb --- /dev/null +++ b/src/features/extensions/components/ExtensionCard.svelte @@ -0,0 +1,107 @@ + + +
+
+ ((e.target as HTMLImageElement).style.display = "none")} + /> +
+ {base} + + {primary.lang.toUpperCase()} + v{primary.versionName} + +
+ + {#if working.has(primary.pkgName)} + + {:else if primary.hasUpdate} +
+ + +
+ {:else if primary.isInstalled} + + {:else} + + {/if} + + {#if hasVariants} + + {/if} +
+ + {#if expanded && hasVariants} +
+ {#each variants as v} +
+ {v.lang.toUpperCase()} + {v.name} + v{v.versionName} + {#if v.hasUpdate}{/if} +
+ {#if working.has(v.pkgName)} + + {:else if v.hasUpdate} + + {:else if v.isInstalled} + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/features/extensions/components/ExtensionFilters.svelte b/src/features/extensions/components/ExtensionFilters.svelte new file mode 100644 index 0000000..1c7dead --- /dev/null +++ b/src/features/extensions/components/ExtensionFilters.svelte @@ -0,0 +1,87 @@ + + +
+

Extensions

+ +
+ {#each FILTERS as f} + + {/each} +
+ +
+
+ + onSearch((e.target as HTMLInputElement).value)} /> +
+ + + +
+
+ +{#if availableLangs.length > 1} +
+ + {#each availableLangs as lang} + + {/each} +
+{/if} + + diff --git a/src/features/extensions/components/Extensions.svelte b/src/features/extensions/components/Extensions.svelte new file mode 100644 index 0000000..27174bc --- /dev/null +++ b/src/features/extensions/components/Extensions.svelte @@ -0,0 +1,272 @@ + + +
+ search = q} + onLang={(l) => langFilter = l} + onPanel={openPanel} + onRefresh={fetchFromRepo} + /> + + {#if panel === "apk"} +
+
+ Install from APK URL + +
+
+ installError = null} + onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} + use:focusOnMount + /> + +
+ {#if installError}
{installError}
{/if} +
+ {/if} + + {#if panel === "repos"} +
+
+ Extension Repositories + +
+ {#if reposLoading} +
+ {:else} + {#if repos.length === 0} +
No repos configured.
+ {:else} +
+ {#each repos as url} +
+ {url} + +
+ {/each} +
+ {/if} +
+ repoError = null} + onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} + /> + +
+ {#if repoError}
{repoError}
{/if} + {/if} +
+ {/if} + + {#if loading} +
+ {:else if groups.length === 0} +
No extensions found.
+ {:else} +
+ {#each groups as { base, primary, variants }} + + {/each} +
+ {/if} +
+ + diff --git a/src/features/extensions/index.ts b/src/features/extensions/index.ts new file mode 100644 index 0000000..173f6cd --- /dev/null +++ b/src/features/extensions/index.ts @@ -0,0 +1,2 @@ +export { default as Extensions } from "./components/Extensions.svelte"; +export * from "./lib/extensionHelpers"; diff --git a/src/features/extensions/lib/extensionHelpers.ts b/src/features/extensions/lib/extensionHelpers.ts new file mode 100644 index 0000000..9394178 --- /dev/null +++ b/src/features/extensions/lib/extensionHelpers.ts @@ -0,0 +1,55 @@ +import type { Extension } from "@types/index"; + +export type Filter = "installed" | "available" | "updates" | "all"; +export type Panel = null | "apk" | "repos"; + +export function baseName(name: string): string { + return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); +} + +export function matchesFilter(ext: Extension, filter: Filter): boolean { + if (filter === "installed") return ext.isInstalled; + if (filter === "available") return !ext.isInstalled; + if (filter === "updates") return ext.hasUpdate; + return true; +} + +export interface ExtensionGroup { + base: string; + primary: Extension; + variants: Extension[]; +} + +export function groupExtensions( + extensions: Extension[], + preferredLang: string | undefined, +): ExtensionGroup[] { + const map = new Map(); + for (const ext of extensions) { + const key = baseName(ext.name); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(ext); + } + return Array.from(map.entries()).map(([base, all]) => { + const primary = + all.find((v) => v.lang === preferredLang) ?? + all.find((v) => v.lang === "en") ?? + all[0]; + return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) }; + }); +} + +export function validateUrl(url: string, ext?: string): string | null { + if (!url.startsWith("http://") && !url.startsWith("https://")) + return "URL must start with http:// or https://"; + if (ext && !url.endsWith(ext)) + return `URL must point to a ${ext} file`; + return null; +} + +export const FILTERS: { id: Filter; label: string }[] = [ + { id: "installed", label: "Installed" }, + { id: "available", label: "Available" }, + { id: "updates", label: "Updates" }, + { id: "all", label: "All" }, +]; diff --git a/src/features/home/components/ActivityFeed.svelte b/src/features/home/components/ActivityFeed.svelte new file mode 100644 index 0000000..3fe539f --- /dev/null +++ b/src/features/home/components/ActivityFeed.svelte @@ -0,0 +1,194 @@ + + +
+
+ Recent Activity + {#if entries.length > 0} + + {/if} +
+ +
+ {#if entries.length > 0} + {#each entries as entry (entry.chapterId)} + + {/each} + {:else} +
+ {#each Array(5) as _, i} +
+
+
+
+
+
+
+
+ {/each} +
+ +
+
+ {/if} +
+
+ + diff --git a/src/features/home/components/HeroSlotPicker.svelte b/src/features/home/components/HeroSlotPicker.svelte new file mode 100644 index 0000000..f30ac77 --- /dev/null +++ b/src/features/home/components/HeroSlotPicker.svelte @@ -0,0 +1,194 @@ + + + + + diff --git a/src/features/home/components/HeroStage.svelte b/src/features/home/components/HeroStage.svelte new file mode 100644 index 0000000..9f0cd1f --- /dev/null +++ b/src/features/home/components/HeroStage.svelte @@ -0,0 +1,581 @@ + + +
+ {#if heroThumb} +
+ {:else} +
+ {/if} +
+ + + +
+ {#if activeSlot?.kind === "empty"} +

Nothing here yet

+

+ {activeSlot.slotIndex === 0 + ? "Read a manga to see it here" + : "Pin a manga or keep reading to fill this slot"} +

+ {#if activeSlot.slotIndex !== 0} + + {/if} + {:else} +
+ {#if activeSlot?.kind === "continue"} + Reading + {:else} + Pinned + {/if} + {#each (heroManga?.genre ?? []).slice(0, 3) as g} + + {/each} +
+ +

{heroTitle}

+ {#if heroManga?.author}

{heroManga.author}

{/if} + + {#if heroEntry} +

+ + {heroEntry.chapterName} + {#if heroEntry.pageNumber > 1} · p.{heroEntry.pageNumber}{/if} + {timeAgo(heroEntry.readAt)} +

+ {/if} + + {#if heroManga?.description} +

{heroManga.description}

+ {/if} + +
+ {#if activeSlot?.kind === "continue"} + + {:else if heroManga} + + {/if} + {#if activeSlot?.slotIndex !== 0} + {#if activeSlot?.kind === "pinned"} + + {:else} + + {/if} + {/if} +
+ {/if} + +
+ +
+ {#each resolvedSlots as slot, i} + + {/each} +
+ + {activeIdx + 1}/{TOTAL_SLOTS} +
+
+ +
+
+ Up Next +
+ + {#if activeSlot?.kind === "empty"} +

No chapters to show

+ {:else if loadingHeroChapters} + {#each Array(4) as _} +
+
+
+
+
+
+
+ {/each} + {:else if heroChapters.length === 0} +

No chapters available

+ {:else} + {#each heroChapters as ch (ch.id)} + {@const isCurrent = heroEntry?.chapterId === ch.id} + + {/each} + {#if heroManga} + + {/if} + {/if} +
+
+ + \ No newline at end of file diff --git a/src/features/home/components/Home.svelte b/src/features/home/components/Home.svelte new file mode 100644 index 0000000..3c80d66 --- /dev/null +++ b/src/features/home/components/Home.svelte @@ -0,0 +1,312 @@ + + +
+
+ +
+ { if (heroManga) store.activeManga = heroManga; }} + /> +
+ + setNavPage("history")} + onopenlibrary={() => setNavPage("library")} + /> + +
+ { if (m) store.previewManga = m; }} + onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }} + /> +
+ +
+ +
+
+ +{#if pickerOpen && pickerSlotIndex !== null} + +{/if} + + diff --git a/src/features/home/components/StatsGrid.svelte b/src/features/home/components/StatsGrid.svelte new file mode 100644 index 0000000..a60f201 --- /dev/null +++ b/src/features/home/components/StatsGrid.svelte @@ -0,0 +1,132 @@ + + +
+
+ Your Stats +
+
+
+
+
+ {stats.currentStreakDays} + Day streak +
+
+
+
+
+ {stats.totalChaptersRead} + Chapters read +
+
+
+
+
+ {formatReadTime(stats.totalMinutesRead)} + Read time +
+
+
+
+
+ {stats.totalMangaRead} + Series started +
+
+
+
+
+ {updateCount} + New updates +
+
+
+
+
+ {stats.longestStreakDays}d + Best streak +
+
+
+
+ + diff --git a/src/features/home/components/UpdatesRow.svelte b/src/features/home/components/UpdatesRow.svelte new file mode 100644 index 0000000..ae4cce7 --- /dev/null +++ b/src/features/home/components/UpdatesRow.svelte @@ -0,0 +1,187 @@ + + +
+
+ + Updates + {#if lastRefresh}{timeAgoRefresh(lastRefresh)}{/if} + + {#if updates.length > 0} + + {/if} +
+ + {#if updates.length > 0} +
{ e.preventDefault(); handleRowWheel(e); }}> + {#each updates as u (u.mangaId)} + {@const m = libraryManga.find(x => x.id === u.mangaId)} + + {/each} +
+ {:else} +

{lastRefresh ? "No new chapters found" : "Check for updates in the library"}

+ {/if} +
+ + diff --git a/src/features/home/index.ts b/src/features/home/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/home/lib/homeHelpers.ts b/src/features/home/lib/homeHelpers.ts new file mode 100644 index 0000000..bbcc961 --- /dev/null +++ b/src/features/home/lib/homeHelpers.ts @@ -0,0 +1,35 @@ +export function timeAgo(ts: number): string { + const diff = Date.now() - ts, m = Math.floor(diff / 60000); + if (m < 1) return "Just now"; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d ago`; + return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +export function formatReadTime(mins: number): string { + if (mins < 1) return `${Math.round(mins * 60)}s`; + if (mins < 60) return `${Math.round(mins)}m`; + const h = Math.floor(mins / 60), r = Math.round(mins % 60); + if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`; + const d = Math.floor(h / 24), rh = h % 24; + return rh === 0 ? `${d}d` : `${d}d ${rh}h`; +} + +export function timeAgoRefresh(ts: number): string { + if (!ts) return ""; + const diff = Date.now() - ts, m = Math.floor(diff / 60000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +export function handleRowWheel(e: WheelEvent) { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; + e.stopPropagation(); +} diff --git a/src/features/library/components/Library.svelte b/src/features/library/components/Library.svelte new file mode 100644 index 0000000..32ccd73 --- /dev/null +++ b/src/features/library/components/Library.svelte @@ -0,0 +1,628 @@ + + + + +{#if ctx} + ctx = null} /> +{/if} +{#if emptyCtx} + emptyCtx = null} /> +{/if} + + \ No newline at end of file diff --git a/src/features/library/components/LibraryFilters.svelte b/src/features/library/components/LibraryFilters.svelte new file mode 100644 index 0000000..f75d854 --- /dev/null +++ b/src/features/library/components/LibraryFilters.svelte @@ -0,0 +1,113 @@ + + +
+ + + {#if filterPanelOpen} + + {/if} +
+ + diff --git a/src/features/library/components/LibraryGrid.svelte b/src/features/library/components/LibraryGrid.svelte new file mode 100644 index 0000000..c39135b --- /dev/null +++ b/src/features/library/components/LibraryGrid.svelte @@ -0,0 +1,220 @@ + + +{#if selectMode} +
+
+ + {selectedIds.size} selected + +
+
+ {#if visibleCategories.length} +
+ + {#if bulkMoveOpen} +
+ {#each visibleCategories as cat} + + {/each} +
+ {/if} +
+ {/if} + +
+
+{/if} + +
+ {#if loading} +
+ {#each Array(12) as _} +
+
+
+
+ {/each} +
+ {:else if filtered.length === 0} +
+ {libraryFilter === "library" ? "No manga saved to library — browse sources to add some." + : libraryFilter === "downloaded" ? "No downloaded manga." + : "No manga in this folder yet. Right-click manga anywhere to assign them."} +
+ {:else} +
+ {#each visibleManga as m (m.id)} + {@const isSelected = selectedIds.has(m.id)} + {@const isCompleted = !m.unreadCount && (m.chapterCount ?? 0) > 0} + + {/each} +
+ {#if hasMore} +
+ +
+ {/if} + {/if} +
+ + diff --git a/src/features/library/components/LibraryToolbar.svelte b/src/features/library/components/LibraryToolbar.svelte new file mode 100644 index 0000000..11cfc19 --- /dev/null +++ b/src/features/library/components/LibraryToolbar.svelte @@ -0,0 +1,241 @@ + + +
+ Library + +
+ {#if anims && tabIndicator.width > 0} + + {/if} + {#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]} + + {/each} + {#each visibleCategories as cat, idx} + {#if dragInsertIdx === idx && activeDragKind === "tab"} + + {/if} + + {#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1} + + {/if} + {/each} +
+ +
+
+ + onSearchChange((e.target as HTMLInputElement).value)} /> +
+ + + + + +
+ + {#if sortPanelOpen} + + {/if} +
+ + +
+
+ + \ No newline at end of file diff --git a/src/features/library/index.ts b/src/features/library/index.ts new file mode 100644 index 0000000..4b21145 --- /dev/null +++ b/src/features/library/index.ts @@ -0,0 +1,3 @@ +export { default as Library } from "./components/Library.svelte"; +export { sortLibrary, librarySorter } from "./lib/librarySort"; +export * from "./store/libraryState.svelte"; diff --git a/src/features/library/lib/librarySort.ts b/src/features/library/lib/librarySort.ts new file mode 100644 index 0000000..677663f --- /dev/null +++ b/src/features/library/lib/librarySort.ts @@ -0,0 +1,52 @@ +import { createSorter } from "@core/algorithms/sort"; +import type { Manga } from "@types"; +import type { LibrarySortMode, LibrarySortDir } from "@store/state.svelte"; + +export const librarySorter = createSorter({ + defaultField: "az", + defaultDir: "asc", + fields: [ + { + key: "az", + comparator: (a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), + }, + { + key: "unreadCount", + comparator: (a, b) => (a.unreadCount ?? 0) - (b.unreadCount ?? 0), + }, + { + key: "totalChapters", + comparator: (a, b) => (a.chapterCount ?? 0) - (b.chapterCount ?? 0), + }, + { + key: "recentlyAdded", + comparator: (a, b) => a.id - b.id, + }, + { + key: "recentlyRead", + comparator: (a, b, ctx) => { + const map = ctx?.recentlyReadMap as Map | undefined; + const ra = map?.get(a.id) ?? 0; + const rb = map?.get(b.id) ?? 0; + return ra - rb; + }, + }, + { + key: "latestFetched", + comparator: (a, b) => a.id - b.id, + }, + { + key: "latestUploaded", + comparator: (a, b) => a.id - b.id, + }, + ], +}); + +export function sortLibrary( + items: Manga[], + mode: LibrarySortMode, + dir: LibrarySortDir, + recentlyReadMap?: Map, +): Manga[] { + return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined); +} diff --git a/src/features/library/store/libraryState.svelte.ts b/src/features/library/store/libraryState.svelte.ts new file mode 100644 index 0000000..412e0e0 --- /dev/null +++ b/src/features/library/store/libraryState.svelte.ts @@ -0,0 +1,45 @@ +import { store, updateSettings, setCategories, setLibraryUpdates, addToast } from "@store/state.svelte"; +import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte"; +import type { Category } from "@types"; + +export { store }; + +export function setTabSort(tab: string, mode: LibrarySortMode, dir?: LibrarySortDir) { + const prev = store.settings.libraryTabSort[tab]; + const newDir = dir ?? prev?.dir ?? "asc"; + updateSettings({ + libraryTabSort: { ...store.settings.libraryTabSort, [tab]: { mode, dir: newDir } }, + }); +} + +export function toggleTabSortDir(tab: string) { + const prev = store.settings.libraryTabSort[tab]; + const mode = prev?.mode ?? "az"; + const dir = prev?.dir === "asc" ? "desc" : "asc"; + setTabSort(tab, mode, dir); +} + +export function setTabStatus(tab: string, status: LibraryStatusFilter) { + updateSettings({ + libraryTabStatus: { ...store.settings.libraryTabStatus, [tab]: status }, + }); +} + +export function toggleTabFilter(tab: string, filter: LibraryContentFilter) { + const current = store.settings.libraryTabFilters?.[tab] ?? {}; + updateSettings({ + libraryTabFilters: { + ...(store.settings.libraryTabFilters ?? {}), + [tab]: { ...current, [filter]: !current[filter] }, + }, + }); +} + +export function clearTabFilters(tab: string) { + updateSettings({ + libraryTabStatus: { ...store.settings.libraryTabStatus, [tab]: "ALL" }, + libraryTabFilters: { ...(store.settings.libraryTabFilters ?? {}), [tab]: {} }, + }); +} + +export { setCategories, setLibraryUpdates, addToast }; diff --git a/src/features/reader/components/PageView.svelte b/src/features/reader/components/PageView.svelte new file mode 100644 index 0000000..d5f705d --- /dev/null +++ b/src/features/reader/components/PageView.svelte @@ -0,0 +1,215 @@ + + +
1} + style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""} + role="presentation" + tabindex="-1" + onclick={handleTap} + ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }} + onmousedown={onInspectMouseDown} + onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }} + onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }} +> + + {#if loading} +
+ {/if} + {#if error} +

{error}

+ {/if} + + {#if style === "longstrip"} + {#each stripToRender as chunk} + {#each chunk.urls as url, i} + {#await resolveUrl(url, chunk.urls.length - i)} + {chunk.chapterName} – Page {i + 1} + {:then src} + {chunk.chapterName} – Page {i + 1} + {/await} + {/each} + {/each} +
+ + {:else if style === "fade" && pageReady} +
+ {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} + Page {store.pageNumber} + {:then src} + Page {store.pageNumber} + {/await} +
+ + {:else if style === "double" && pageReady} +
+ {#if pageGroups.length} +
+ {#each currentGroup as pg, i} + {#await resolveUrl(store.pageUrls[pg - 1], 999)} + Page {pg} + {:then src} + Page {pg} + {/await} + {/each} +
+ {:else} +
+ {/if} +
+ + {:else if pageReady} +
+ {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} + Page {store.pageNumber} + {:then src} + Page {store.pageNumber} + {/await} +
+ {/if} + +
+ + \ No newline at end of file diff --git a/src/features/reader/components/Reader.svelte b/src/features/reader/components/Reader.svelte new file mode 100644 index 0000000..07e388b --- /dev/null +++ b/src/features/reader/components/Reader.svelte @@ -0,0 +1,513 @@ + + + + \ No newline at end of file diff --git a/src/features/reader/components/ReaderControls.svelte b/src/features/reader/components/ReaderControls.svelte new file mode 100644 index 0000000..6c093e2 --- /dev/null +++ b/src/features/reader/components/ReaderControls.svelte @@ -0,0 +1,373 @@ + + +
+ +
+ + + + {store.activeManga?.title} + / + {displayChapter?.name} + + + {store.pageNumber} / {visibleChunkLastPage || "…"} +
+ +
+
+ + + +
+
+ + + +
+ {#if readerState.zoomOpen} +
+
+ { onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} /> +
+ +
+ {/if} +
+ + + + + +
+ {#if style === "double"} + + {/if} + {#if style === "longstrip"} + + + {/if} + {#if !autoNext} + + {/if} +
+ + + +
+ + + {#if readerState.markerOpen} + + {/if} +
+ + + +
+ + {#if readerState.winOpen} + + {/if} +
+
+ +
+ + \ No newline at end of file diff --git a/src/features/reader/components/ReaderOverlay.svelte b/src/features/reader/components/ReaderOverlay.svelte new file mode 100644 index 0000000..7ad828a --- /dev/null +++ b/src/features/reader/components/ReaderOverlay.svelte @@ -0,0 +1,84 @@ + + +{#if showResumeBanner} + +{/if} + +{#if readerState.dlOpen && store.activeChapter} + {@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)} + +{/if} + + \ No newline at end of file diff --git a/src/features/reader/components/ReaderProgressBar.svelte b/src/features/reader/components/ReaderProgressBar.svelte new file mode 100644 index 0000000..97d6094 --- /dev/null +++ b/src/features/reader/components/ReaderProgressBar.svelte @@ -0,0 +1,112 @@ + + +
+ + + {#if sliderMax > 1} +
readerState.sliderHover = true} + onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }} + onmousedown={(e) => { + readerState.sliderDragging = true; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); + }} + onmousemove={(e) => { + if (!readerState.sliderDragging) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); + }} + onmouseup={() => readerState.sliderDragging = false} + > +
+
+
+
+ + {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id} + {@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber} + {@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0} +
+ {/if} + + {#each activeChapterMarkers as m (m.id)} + {@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber} + {@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0} +
+ {/each} + + {#if readerState.sliderHover || readerState.sliderDragging} +
+ {sliderPage} / {sliderMax} +
+ {/if} +
+ {/if} + + +
+ + \ No newline at end of file diff --git a/src/features/reader/index.ts b/src/features/reader/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/reader/lib/chapterActions.ts b/src/features/reader/lib/chapterActions.ts new file mode 100644 index 0000000..5dcb547 --- /dev/null +++ b/src/features/reader/lib/chapterActions.ts @@ -0,0 +1,76 @@ +import { gql } from "@api/client"; +import { store, addHistory, addBookmark, removeBookmark, + checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte"; +import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters"; +import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; + +const AVG_MIN_PER_PAGE = 0.33; + +export function getMangaPrefs() { + const mangaId = store.activeManga?.id; + if (!mangaId) return DEFAULT_MANGA_PREFS; + return { ...DEFAULT_MANGA_PREFS, ...(store.settings.mangaPrefs?.[mangaId] ?? {}) }; +} + +export function markChapterRead(id: number, markedRead: Set) { + if (markedRead.has(id)) return; + markedRead.add(id); + const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter; + const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15; + const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE)); + if (store.activeManga && chapter) { + addHistory( + { mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, readAt: Date.now() }, + true, minutes, + ); + } + gql(MARK_CHAPTER_READ, { id, isRead: true }) + .then(() => { + const mangaId = store.activeManga?.id; + if (!mangaId) return; + const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c); + checkAndMarkCompleted(mangaId, updated); + const prefs = getMangaPrefs(); + if (prefs.deleteOnRead) { + const ch = store.activeChapterList.find(c => c.id === id); + if (ch?.isDownloaded) { + const delayMs = (prefs.deleteDelayHours ?? 0) * 3_600_000; + const doDelete = () => gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [id] }).catch(console.error); + if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs); + } + } + if (prefs.downloadAhead > 0) { + const list = store.activeChapterList; + const idx = list.findIndex(c => c.id === id); + if (idx >= 0) { + const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead).filter(c => !c.isDownloaded && !c.isRead).map(c => c.id); + if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error); + } + } + if (prefs.maxKeepChapters > 0) { + const downloaded = store.activeChapterList.filter(c => c.isDownloaded).sort((a, b) => a.sourceOrder - b.sourceOrder); + const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters)); + if (excess.length) gql(DELETE_DOWNLOADED_CHAPTERS, { ids: excess.map(c => c.id) }).catch(console.error); + } + }) + .catch(e => { markedRead.delete(id); console.error(e); }); +} + +export function toggleBookmark( + displayChapter: import("@types").Chapter | null | undefined, + pageNumber: number, +) { + const ch = displayChapter; + const manga = store.activeManga; + if (!ch || !manga) return; + const isBookmarked = !!store.bookmarks.find( + b => b.mangaId === manga.id && b.chapterId === ch.id && b.pageNumber === pageNumber, + ); + if (isBookmarked) { + removeBookmark(ch.id); + } else { + const existing = store.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== ch.id); + if (existing) removeBookmark(existing.chapterId); + addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber }); + } +} diff --git a/src/features/reader/lib/chapterLoader.ts b/src/features/reader/lib/chapterLoader.ts new file mode 100644 index 0000000..3fbdd26 --- /dev/null +++ b/src/features/reader/lib/chapterLoader.ts @@ -0,0 +1,48 @@ +import { store, openReader } from "@store/state.svelte"; +import { readerState } from "../store/readerState.svelte"; +import { fetchPages } from "./pageLoader"; + +export function scheduleResumeDismiss() { + setTimeout(() => { readerState.resumeFading = true; }, 1500); + setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500); +} + +export async function loadChapter( + id: number, + useBlob: boolean, + abortCtrl: { current: AbortController | null }, + startAtLastPage: { current: boolean }, + markedRead: Set, + adjacent: { next: { id: number } | null }, +) { + abortCtrl.current?.abort(); + const ctrl = new AbortController(); + abortCtrl.current = ctrl; + startAtLastPage.current = false; + markedRead.clear(); + readerState.resetForChapter(); + store.pageUrls = []; + + const bookmark = store.bookmarks.find(b => b.chapterId === id); + const resumeTo = bookmark ? bookmark.pageNumber : 0; + readerState.resumePage = resumeTo > 1 ? resumeTo : 0; + readerState.resumeDismissed = false; + readerState.resumeVisible = resumeTo > 1; + if (resumeTo > 1) scheduleResumeDismiss(); + + store.pageNumber = 1; + try { + const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0); + if (ctrl.signal.aborted) return; + store.pageUrls = urls; + if (startAtLastPage.current) store.pageNumber = urls.length; + else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo); + readerState.pageReady = true; + readerState.loading = false; + if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {}); + } catch (e: any) { + if (ctrl.signal.aborted) return; + readerState.error = e instanceof Error ? e.message : String(e); + readerState.loading = false; + } +} \ No newline at end of file diff --git a/src/features/reader/lib/index.ts b/src/features/reader/lib/index.ts new file mode 100644 index 0000000..506ba70 --- /dev/null +++ b/src/features/reader/lib/index.ts @@ -0,0 +1,14 @@ +export { readerState } from "./store/readerState.svelte"; +export type { PageStyle } from "./store/readerState.svelte"; +export { PAGE_STYLES, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "./store/readerState.svelte"; + +export { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups, clearPageCache } from "./lib/pageLoader"; +export { setupScrollTracking, appendNextChapter } from "./lib/scrollHandler"; +export type { StripChapter, ScrollHandlerCallbacks } from "./lib/scrollHandler"; +export { createReaderKeyHandler } from "./lib/readerKeybinds"; +export type { ReaderKeyActions } from "./lib/readerKeybinds"; + +export { markChapterRead, getMangaPrefs, toggleBookmark } from "./lib/chapterActions"; +export { goForward, goBack, jumpToPage, animateFade } from "./lib/navigation"; +export { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "./lib/zoomHelpers"; +export { loadChapter, scheduleResumeDismiss } from "./lib/chapterLoader"; diff --git a/src/features/reader/lib/navigation.ts b/src/features/reader/lib/navigation.ts new file mode 100644 index 0000000..2448e80 --- /dev/null +++ b/src/features/reader/lib/navigation.ts @@ -0,0 +1,84 @@ +import { store, openReader, closeReader } from "@store/state.svelte"; +import { readerState } from "../store/readerState.svelte"; +import type { Chapter } from "@types"; + +interface Adjacent { + prev: Chapter | null; + next: Chapter | null; +} + +export function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: () => void) { + if (!readerState.pageGroups.length) return; + const gi = readerState.pageGroups.findIndex(g => g.includes(store.pageNumber)); + if (forward) { + if (gi < readerState.pageGroups.length - 1) store.pageNumber = readerState.pageGroups[gi + 1][0]; + else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); } + else closeReader(); + } else { + if (gi > 0) store.pageNumber = readerState.pageGroups[gi - 1][0]; + else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); } + } +} + +export async function animateFade(fn: () => void) { + readerState.fadingOut = true; + await new Promise(r => setTimeout(r, 100)); + fn(); + readerState.fadingOut = false; +} + +export function goForward( + style: string, + adjacent: Adjacent, + lastPage: number, + onMaybeMarkRead: () => void, + startAtLastPage: () => void, +) { + if (readerState.loading) return; + if (style === "longstrip") { + if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } + return; + } + if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; } + if (!store.pageUrls.length) return; + if (store.pageNumber < lastPage) { + if (style === "fade") animateFade(() => { store.pageNumber++; }); + else store.pageNumber++; + } else if (adjacent.next) { + onMaybeMarkRead(); + store.pageNumber = 1; + openReader(adjacent.next, store.activeChapterList); + } else closeReader(); +} + +export function goBack( + style: string, + adjacent: Adjacent, + startAtLastPage: () => void, +) { + if (readerState.loading) return; + if (style === "longstrip") { + if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); } + return; + } + if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; } + if (!store.pageUrls.length) return; + if (store.pageNumber > 1) { + if (style === "fade") animateFade(() => { store.pageNumber--; }); + else store.pageNumber--; + } else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); } +} + +export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) { + if (style === "longstrip") { + const chId = readerState.visibleChapterId ?? store.activeChapter?.id; + containerEl?.querySelector(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" }); + return; + } + if (style === "double" && readerState.pageGroups.length) { + const group = readerState.pageGroups[page - 1]; + if (group) store.pageNumber = group[0]; + } else { + store.pageNumber = Math.max(1, Math.min(lastPage, page)); + } +} diff --git a/src/features/reader/lib/pageLoader.ts b/src/features/reader/lib/pageLoader.ts new file mode 100644 index 0000000..d582c39 --- /dev/null +++ b/src/features/reader/lib/pageLoader.ts @@ -0,0 +1,103 @@ +import { gql, plainThumbUrl } from "@api/client"; +import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache"; +import { dedupeRequest } from "@core/async/batchRequests"; +import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; + +export interface PageLoaderOptions { + useBlob: () => boolean; +} + +const pageCache = new Map(); +const inflight = new Map>(); +const resolvedUrlCache = new Map>(); +const preloadedUrls = new Set(); +const aspectCache = new Map(); + +export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise { + if (!useBlob) return Promise.resolve(url); + if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority)); + return resolvedUrlCache.get(url)!; +} + +export function fetchPages( + chapterId: number, + useBlob: boolean, + signal?: AbortSignal, + priorityPage = 0, +): Promise { + const cached = pageCache.get(chapterId); + if (cached) return Promise.resolve(cached); + if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); + + if (!inflight.has(chapterId)) { + const p = dedupeRequest(`chapter-pages:${chapterId}`, () => + gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) + .then(d => { + const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); + if (useBlob) { + if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999); + preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length); + } + pageCache.set(chapterId, urls); + return urls; + }) + ).finally(() => inflight.delete(chapterId)); + inflight.set(chapterId, p); + } + + const base = inflight.get(chapterId)!; + if (!signal) return base; + return new Promise((resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + base.then(resolve, reject); + }); +} + +export function measureAspect(url: string, useBlob: boolean): Promise { + if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); + return resolveUrl(url, useBlob).then(src => new Promise(res => { + const img = new Image(); + img.onload = () => { + const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; + aspectCache.set(url, r); + res(r); + }; + img.onerror = () => res(0.67); + img.src = src; + })); +} + +export function preloadImage(url: string, useBlob: boolean): void { + if (preloadedUrls.has(url)) return; + preloadedUrls.add(url); + resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); +} + +export function buildPageGroups( + urls: string[], + aspects: number[], + offsetSpreads: boolean, +): number[][] { + const groups: number[][] = [[1]]; + if (offsetSpreads) groups.push([2]); + let i = offsetSpreads ? 3 : 2; + while (i <= urls.length) { + const a = aspects[i - 1]; + if (a > 1.2 || i === urls.length) { groups.push([i++]); } + else { groups.push([i, i + 1]); i += 2; } + } + return groups; +} + +export function clearPageCache(chapterId?: number): void { + if (chapterId !== undefined) { + pageCache.delete(chapterId); + inflight.delete(chapterId); + } else { + pageCache.clear(); + inflight.clear(); + resolvedUrlCache.clear(); + preloadedUrls.clear(); + aspectCache.clear(); + } +} \ No newline at end of file diff --git a/src/features/reader/lib/readerKeybinds.ts b/src/features/reader/lib/readerKeybinds.ts new file mode 100644 index 0000000..a1ea077 --- /dev/null +++ b/src/features/reader/lib/readerKeybinds.ts @@ -0,0 +1,59 @@ +import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "@core/keybinds"; +import type { Keybinds } from "@core/keybinds"; + +export interface ReaderKeyActions { + goNext: () => void; + goPrev: () => void; + closeReader: () => void; + goToPage: (page: number) => void; + lastPage: () => number; + adjustZoom: (delta: number) => void; + resetZoom: () => void; + cycleStyle: () => void; + toggleDirection: () => void; + openSettings: () => void; + toggleBookmark: () => void; + toggleMarker: () => void; + chapterNext: () => void; + chapterPrev: () => void; + closePopovers: () => boolean; + getKeybinds: () => Keybinds; +} + +const ZOOM_STEP = 0.10; + +export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardEvent) => void { + return function onKey(e: KeyboardEvent) { + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; + + if (e.key === "Escape") { + e.preventDefault(); + if (actions.closePopovers()) return; + actions.closeReader(); + return; + } + + if (e.ctrlKey) { + if (e.key === "=" || e.key === "+") { e.preventDefault(); actions.adjustZoom(ZOOM_STEP); return; } + if (e.key === "-") { e.preventDefault(); actions.adjustZoom(-ZOOM_STEP); return; } + if (e.key === "0") { e.preventDefault(); actions.resetZoom(); return; } + } + + const kb = actions.getKeybinds(); + + if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); actions.closeReader(); } + else if (matchesKeybind(e, kb.turnPageRight)) { e.preventDefault(); actions.goNext(); } + else if (matchesKeybind(e, kb.turnPageLeft)) { e.preventDefault(); actions.goPrev(); } + else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); actions.goToPage(1); } + else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); actions.goToPage(actions.lastPage()); } + else if (matchesKeybind(e, kb.turnChapterRight)) { e.preventDefault(); actions.chapterNext(); } + else if (matchesKeybind(e, kb.turnChapterLeft)) { e.preventDefault(); actions.chapterPrev(); } + else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); actions.cycleStyle(); } + else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); actions.toggleDirection(); } + else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); } + else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); } + else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); } + else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); } + }; +} diff --git a/src/features/reader/lib/scrollHandler.ts b/src/features/reader/lib/scrollHandler.ts new file mode 100644 index 0000000..a438cf7 --- /dev/null +++ b/src/features/reader/lib/scrollHandler.ts @@ -0,0 +1,110 @@ +export const READ_LINE_PCT = 0.50; + +export interface StripChapter { + chapterId: number; + chapterName: string; + urls: string[]; +} + +export interface ScrollHandlerCallbacks { + onPageChange: (page: number) => void; + onChapterChange: (chapterId: number) => void; + onMarkRead: (chapterId: number) => void; + onAppend: () => void; + getStripChapters: () => StripChapter[]; + getPageUrls: () => string[]; + shouldAutoMark: () => boolean; +} + +export function setupScrollTracking( + containerEl: HTMLElement, + callbacks: ScrollHandlerCallbacks, +): () => void { + const { + onPageChange, onChapterChange, onMarkRead, + onAppend, getStripChapters, getPageUrls, shouldAutoMark, + } = callbacks; + + function onScroll() { + const imgs = containerEl.querySelectorAll("img[data-local-page]"); + if (!imgs.length) return; + + const containerTop = containerEl.getBoundingClientRect().top; + const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT; + + let activePage: number | null = null; + let activeChId: number | null = null; + + for (const img of imgs) { + if (img.getBoundingClientRect().top <= readLineY) { + activePage = Number(img.dataset.localPage); + activeChId = Number(img.dataset.chapter); + } else break; + } + + if (activePage === null) { + activePage = Number(imgs[0].dataset.localPage); + activeChId = Number(imgs[0].dataset.chapter); + } + + if (activePage !== null) onPageChange(activePage); + if (activeChId) onChapterChange(activeChId); + + if (shouldAutoMark() && activePage !== null && activeChId) { + const chunks = getStripChapters(); + const chunk = chunks.find(c => c.chapterId === activeChId); + const total = chunk ? chunk.urls.length : getPageUrls().length; + if (total > 0 && activePage >= total) onMarkRead(activeChId); + } + + const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40; + if (atBottom && shouldAutoMark()) { + const chunks = getStripChapters(); + const last = chunks[chunks.length - 1]; + if (last) onMarkRead(last.chapterId); + } + } + + function onScrollAppend() { + const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; + if (pct >= 0.80) onAppend(); + } + + containerEl.addEventListener("scroll", onScroll, { passive: true }); + containerEl.addEventListener("scroll", onScrollAppend, { passive: true }); + + return () => { + containerEl.removeEventListener("scroll", onScroll); + containerEl.removeEventListener("scroll", onScrollAppend); + }; +} + +export function appendNextChapter( + stripChapters: StripChapter[], + chapterList: { id: number; name: string }[], + fetchPages: (chapterId: number) => Promise, + preloadImage: (url: string) => void, + onAppended: (next: StripChapter) => void, + onDone: () => void, +): void { + if (!stripChapters.length) return; + + const lastChunk = stripChapters[stripChapters.length - 1]; + const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId); + if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return; + + const next = chapterList[lastIdx + 1]; + if (!next || stripChapters.some(c => c.chapterId === next.id)) return; + + fetchPages(next.id) + .then(urls => { + urls.slice(0, 6).forEach(preloadImage); + return urls; + }) + .then(urls => { + if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; } + onAppended({ chapterId: next.id, chapterName: next.name, urls }); + onDone(); + }) + .catch(() => onDone()); +} diff --git a/src/features/reader/lib/zoomHelpers.ts b/src/features/reader/lib/zoomHelpers.ts new file mode 100644 index 0000000..8932b2d --- /dev/null +++ b/src/features/reader/lib/zoomHelpers.ts @@ -0,0 +1,38 @@ +import { readerState } from "../store/readerState.svelte"; + +export function clampZoom(z: number): number { + const { ZOOM_MIN, ZOOM_MAX } = readerState; + return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000; +} + +export function captureZoomAnchor( + containerEl: HTMLElement | null, + style: string, + out: { el: HTMLElement | null; offset: number }, +) { + if (!containerEl || style !== "longstrip") return; + const imgs = containerEl.querySelectorAll("img[data-local-page]"); + const containerTop = containerEl.getBoundingClientRect().top; + for (const img of imgs) { + const rect = img.getBoundingClientRect(); + if (rect.bottom > containerTop) { + out.el = img; + out.offset = rect.top - containerTop; + return; + } + } +} + +export function restoreZoomAnchor( + containerEl: HTMLElement | null, + out: { el: HTMLElement | null; offset: number }, +) { + if (!out.el || !containerEl) return; + const el = out.el; + out.el = null; + requestAnimationFrame(() => { + const containerTop = containerEl!.getBoundingClientRect().top; + const newRect = el.getBoundingClientRect(); + containerEl!.scrollTop += (newRect.top - containerTop) - out.offset; + }); +} diff --git a/src/features/reader/store/readerState.svelte.ts b/src/features/reader/store/readerState.svelte.ts new file mode 100644 index 0000000..c9bfdce --- /dev/null +++ b/src/features/reader/store/readerState.svelte.ts @@ -0,0 +1,107 @@ +import type { MarkerColor } from "@store/state.svelte"; +import type { StripChapter } from "../lib/scrollHandler"; + +export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const; +export type PageStyle = typeof PAGE_STYLES[number]; + +export const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"]; +export const MARKER_COLOR_HEX: Record = { + yellow: "#c4a94a", + red: "#c47a7a", + blue: "#7a9ec4", + green: "#7aab7a", + purple: "#a07ac4", +}; + +export const ZOOM_STEP = 0.05; +export const ZOOM_MIN = 0.1; +export const ZOOM_MAX = 1.0; + +class ReaderState { + loading = $state(true); + error = $state(null); + pageReady = $state(false); + pageGroups = $state([]); + stripChapters = $state([]); + visibleChapterId = $state(null); + + uiVisible = $state(true); + isFullscreen = $state(false); + + dlOpen = $state(false); + zoomOpen = $state(false); + winOpen = $state(false); + nextN = $state(5); + dlBusy = $state(false); + + fadingOut = $state(false); + sliderDragging = $state(false); + sliderHover = $state(false); + + resumePage = $state(0); + resumeDismissed = $state(false); + resumeFading = $state(false); + resumeVisible = $state(false); + stripResumeReady = $state(false); + + markerOpen = $state(false); + markerNote = $state(""); + markerColor = $state("yellow"); + markerEditId = $state(""); + + inspectScale = $state(1); + inspectPanX = $state(0); + inspectPanY = $state(0); + + containerWidth = $state(0); + + resetForChapter() { + this.loading = true; + this.error = null; + this.pageReady = false; + this.pageGroups = []; + this.stripChapters = []; + this.visibleChapterId = null; + this.fadingOut = false; + this.markerOpen = false; + } + + resetResume() { + this.resumePage = 0; + this.resumeDismissed = false; + this.resumeVisible = false; + this.stripResumeReady = false; + } + + resetInspect() { + this.inspectScale = 1; + this.inspectPanX = 0; + this.inspectPanY = 0; + } + + closeAllPopovers(): boolean { + if (this.markerOpen) { this.markerOpen = false; return true; } + if (this.zoomOpen) { this.zoomOpen = false; return true; } + if (this.dlOpen) { this.dlOpen = false; return true; } + if (this.winOpen) { this.winOpen = false; return true; } + return false; + } + + openMarker(editId: string, note: string, color: MarkerColor) { + this.markerEditId = editId; + this.markerNote = note; + this.markerColor = color; + this.markerOpen = true; + this.zoomOpen = false; + this.dlOpen = false; + this.winOpen = false; + } + + clearMarkerPopover() { + this.markerOpen = false; + this.markerNote = ""; + this.markerEditId = ""; + } +} + +export const readerState = new ReaderState(); diff --git a/src/features/series/components/ChapterList.svelte b/src/features/series/components/ChapterList.svelte new file mode 100644 index 0000000..d7f88ef --- /dev/null +++ b/src/features/series/components/ChapterList.svelte @@ -0,0 +1,173 @@ + + +
+ {#if loadingChapters && sortedChapters.length === 0} + {#if viewMode === "grid"} + {#each Array(24) as _}
{/each} + {:else} + {#each Array(8) as _} +
+
+
+
+ {/each} + {/if} + + {:else if viewMode === "grid"} + {#each sortedChapters as ch, i} + {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} + {@const isGridSelected = selectedIds.has(ch.id)} + + {/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} + + diff --git a/src/features/series/components/SeriesActions.svelte b/src/features/series/components/SeriesActions.svelte new file mode 100644 index 0000000..ddd8732 --- /dev/null +++ b/src/features/series/components/SeriesActions.svelte @@ -0,0 +1,642 @@ + + +
+
+ {#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} +
+
+ + diff --git a/src/features/series/components/SeriesDetail.svelte b/src/features/series/components/SeriesDetail.svelte new file mode 100644 index 0000000..98e8e5c --- /dev/null +++ b/src/features/series/components/SeriesDetail.svelte @@ -0,0 +1,778 @@ + + +{#if store.activeManga} + + +{#if migrateOpen && manga} + migrateOpen = false} + onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }} + /> +{/if} + +{#if trackingOpen && store.activeManga} + trackingOpen = false} /> +{/if} + +{#if autoOpen && store.activeManga} + autoOpen = false} /> +{/if} + +{#if markersOpen && store.activeManga} + +{/if} + +{#if linkPickerOpen} + +{/if} +{/if} + + diff --git a/src/features/series/components/SeriesHeader.svelte b/src/features/series/components/SeriesHeader.svelte new file mode 100644 index 0000000..f2a9b1c --- /dev/null +++ b/src/features/series/components/SeriesHeader.svelte @@ -0,0 +1,313 @@ + + + + + diff --git a/src/features/series/index.ts b/src/features/series/index.ts new file mode 100644 index 0000000..a17bd27 --- /dev/null +++ b/src/features/series/index.ts @@ -0,0 +1,10 @@ +export { default as SeriesDetail } from "./components/SeriesDetail.svelte"; +export { default as SeriesHeader } from "./components/SeriesHeader.svelte"; +export { default as SeriesActions } from "./components/SeriesActions.svelte"; +export { default as ChapterList } from "./components/ChapterList.svelte"; +export { default as AutomationPanel } from "./panels/AutomationPanel.svelte"; +export { default as MarkersPanel } from "./panels/MarkersPanel.svelte"; +export { default as MigrateModal } from "./panels/MigrateModal.svelte"; +export { default as TrackingPanel } from "./panels/TrackingPanel.svelte"; +export { buildChapterList, chaptersAscending } from "./lib/chapterList"; +export type { ChapterDisplayPrefs, ChapterSortMode, ChapterSortDir } from "./lib/chapterList"; diff --git a/src/features/series/lib/chapterList.ts b/src/features/series/lib/chapterList.ts new file mode 100644 index 0000000..74bc190 --- /dev/null +++ b/src/features/series/lib/chapterList.ts @@ -0,0 +1,79 @@ +import type { Chapter } from "@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/features/series/lib/mangaPrefs.ts b/src/features/series/lib/mangaPrefs.ts new file mode 100644 index 0000000..e4a6d5e --- /dev/null +++ b/src/features/series/lib/mangaPrefs.ts @@ -0,0 +1,17 @@ +import { store, updateSettings } from "@store/state.svelte"; +import { DEFAULT_MANGA_PREFS } from "@store/state.svelte"; +import type { MangaPrefs } from "@store/state.svelte"; + +export function getPref(mangaId: number, key: K): MangaPrefs[K] { + const prefs = store.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: { + ...store.settings.mangaPrefs, + [mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value }, + }, + }); +} diff --git a/src/components/series/AutomationPanel.svelte b/src/features/series/panels/AutomationPanel.svelte similarity index 76% rename from src/components/series/AutomationPanel.svelte rename to src/features/series/panels/AutomationPanel.svelte index 32ea89f..4b81506 100644 --- a/src/components/series/AutomationPanel.svelte +++ b/src/features/series/panels/AutomationPanel.svelte @@ -1,35 +1,18 @@ + \ No newline at end of file diff --git a/src/components/series/TrackingPanel.svelte b/src/features/series/panels/TrackingPanel.svelte similarity index 84% rename from src/components/series/TrackingPanel.svelte rename to src/features/series/panels/TrackingPanel.svelte index 2df27b0..c1f8148 100644 --- a/src/components/series/TrackingPanel.svelte +++ b/src/features/series/panels/TrackingPanel.svelte @@ -1,18 +1,11 @@ - + \ No newline at end of file diff --git a/src/features/settings/components/Settings.css b/src/features/settings/components/Settings.css new file mode 100644 index 0000000..09962a3 --- /dev/null +++ b/src/features/settings/components/Settings.css @@ -0,0 +1,1305 @@ +/* ── Animations ───────────────────────────────────────────────────── */ +@keyframes s-fade-in { from { opacity: 0 } to { opacity: 1 } } +@keyframes s-scale-in { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } } +@keyframes s-pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.55 } } +@keyframes s-icon-down { from { transform: translateY(-5px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } +@keyframes s-icon-up { from { transform: translateY( 5px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } + + +/* ── Backdrop & Modal Shell ───────────────────────────────────────── */ +.s-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: var(--z-settings); + display: flex; align-items: center; justify-content: center; + animation: s-fade-in 0.14s ease both; +} + +.s-modal { + width: min(760px, calc(100vw - 40px)); + height: min(640px, calc(100vh - 72px)); + max-height: calc(100vh - 72px); + display: flex; + background: var(--bg-surface); + border: 1px solid var(--border-base); + border-radius: var(--radius-2xl); + overflow: visible; + position: relative; + animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both; + box-shadow: + 0 0 0 1px rgba(255,255,255,0.04) inset, + 0 24px 80px rgba(0,0,0,0.7), + 0 8px 24px rgba(0,0,0,0.4); +} + + +/* ── Sidebar ──────────────────────────────────────────────────────── */ +.s-sidebar { + width: 168px; + flex-shrink: 0; + background: var(--bg-base); + border-right: 1px solid var(--border-dim); + padding: var(--sp-4) var(--sp-2) var(--sp-3); + display: flex; + flex-direction: column; + gap: 1px; + overflow-y: auto; + border-radius: var(--radius-2xl) 0 0 var(--radius-2xl); +} + +.s-sidebar-title { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + padding: 0 var(--sp-2) var(--sp-4); +} + +.s-nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px var(--sp-2); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--text-faint); + background: none; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + transition: background var(--t-fast), color var(--t-fast); +} +.s-nav-item:hover { background: var(--bg-raised); color: var(--text-secondary); } +.s-nav-item.active { background: var(--accent-muted); color: var(--accent-fg); } +.s-nav-item.anims { transition: background var(--t-base), color var(--t-base), transform 80ms ease; } +.s-nav-item.anims:hover { transform: translateX(2px); } +.s-nav-item.anims:active { transform: scale(0.97); } + +.s-nav-icon { display: flex; align-items: center; flex-shrink: 0; } +.s-nav-icon.slide-down { animation: s-icon-down 160ms cubic-bezier(0.22,1,0.36,1) both; } +.s-nav-icon.slide-up { animation: s-icon-up 160ms cubic-bezier(0.22,1,0.36,1) both; } + + +/* ── Content Area ─────────────────────────────────────────────────── */ +.s-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; + border-radius: 0 var(--radius-2xl) var(--radius-2xl) 0; +} + +.s-content-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-4) var(--sp-5) var(--sp-3); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} + +.s-content-header-left { + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--text-faint); +} + +.s-header-icon { display: flex; align-items: center; } +.s-header-icon.slide-down { animation: s-icon-down 180ms cubic-bezier(0.22,1,0.36,1) both; } +.s-header-icon.slide-up { animation: s-icon-up 180ms cubic-bezier(0.22,1,0.36,1) both; } + +.s-content-title { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-primary); + letter-spacing: 0.01em; +} + +.s-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-md); + color: var(--text-faint); + background: none; + border: none; + cursor: pointer; + transition: color var(--t-base), background var(--t-base); +} +.s-close-btn:hover { color: var(--text-muted); background: var(--bg-raised); } + +.s-content-body { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-base) transparent; +} +.s-content-body::-webkit-scrollbar { width: 4px; } +.s-content-body::-webkit-scrollbar-track { background: transparent; } +.s-content-body::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 2px; } + + +/* ── Panel & Section ──────────────────────────────────────────────── */ +.s-panel { + display: flex; + flex-direction: column; + padding: var(--sp-4) var(--sp-5) var(--sp-6); + gap: var(--sp-2); +} + +/* Card-style section */ +.s-section { + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.s-section-title { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + padding: var(--sp-3) var(--sp-4) var(--sp-2); + border-bottom: 1px solid var(--border-dim); + display: flex; + align-items: center; + justify-content: space-between; +} + +.s-section-body { + display: flex; + flex-direction: column; +} + + +/* ── Row Primitives ───────────────────────────────────────────────── */ +.s-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px var(--sp-4); + gap: var(--sp-4); + transition: background var(--t-fast); + border-bottom: 1px solid var(--border-dim); +} +.s-row:last-child { border-bottom: none; } +.s-row:hover { background: var(--bg-overlay); } + +.s-row-info { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.s-label { + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: 1.3; +} + +.s-desc { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + line-height: var(--leading-snug); +} + + +/* ── Toggle Switch ────────────────────────────────────────────────── */ +.s-toggle { + position: relative; + width: 34px; + height: 19px; + border-radius: var(--radius-full); + border: 1px solid var(--border-strong); + background: var(--bg-overlay); + cursor: pointer; + flex-shrink: 0; + transition: background var(--t-base), border-color var(--t-base); +} +.s-toggle.on { background: var(--accent); border-color: var(--accent); } + +.s-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--text-faint); + transition: transform var(--t-base), background var(--t-base); +} +.s-toggle.on .s-toggle-thumb { + transform: translateX(15px); + background: var(--bg-void); +} + + +/* ── Stepper ──────────────────────────────────────────────────────── */ +.s-stepper { + display: flex; + align-items: center; + gap: var(--sp-1); + flex-shrink: 0; +} + +.s-step-btn { + font-family: var(--font-ui); + font-size: var(--text-sm); + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); + background: var(--bg-surface); + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.s-step-btn:hover:not(:disabled) { + color: var(--text-secondary); + border-color: var(--border-strong); + background: var(--bg-raised); +} +.s-step-btn:disabled { opacity: 0.3; cursor: default; } + +.s-step-val { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-secondary); + letter-spacing: var(--tracking-wide); + min-width: 44px; + text-align: center; +} + + +/* ── Select Dropdown ──────────────────────────────────────────────── */ +.s-select { position: relative; flex-shrink: 0; } + +.s-select-btn { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-sm); + color: var(--text-secondary); + background: var(--bg-surface); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + padding: 5px 10px; + cursor: pointer; + min-width: 140px; + transition: border-color var(--t-base), background var(--t-base); +} +.s-select-btn:hover { + border-color: var(--border-strong); + background: var(--bg-overlay); +} + +.s-select-caret { + color: var(--text-faint); + transition: transform var(--t-base); + flex-shrink: 0; + margin-left: auto; +} +.s-select-caret.open { transform: rotate(180deg); } + +.s-select-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 100%; + background: var(--bg-raised); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + padding: var(--sp-1); + z-index: 9999; + box-shadow: 0 8px 28px rgba(0,0,0,0.45); + animation: s-scale-in 0.1s ease both; + transform-origin: top right; +} + +.s-select-option { + display: block; + width: 100%; + padding: 6px var(--sp-3); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background var(--t-fast), color var(--t-fast); +} +.s-select-option:hover { background: var(--bg-overlay); color: var(--text-primary); } +.s-select-option.active { color: var(--accent-fg); background: var(--accent-muted); } + + +/* ── Text Input ───────────────────────────────────────────────────── */ +.s-input { + background: var(--bg-surface); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + padding: 6px 10px; + color: var(--text-primary); + font-size: var(--text-sm); + outline: none; + width: 200px; + flex-shrink: 0; + transition: border-color var(--t-base), background var(--t-base); + font-family: inherit; +} +.s-input:focus { border-color: var(--accent-dim); background: var(--bg-overlay); } +.s-input::placeholder { color: var(--text-faint); } +.s-input.error { border-color: var(--color-error); } +.s-input.mono { font-family: monospace; font-size: var(--text-xs); } +.s-input.full { width: 100%; flex: 1; min-width: 0; flex-shrink: 1; } + +/* Number input — hide spinners */ +.s-input[type=number] { -moz-appearance: textfield; } +.s-input[type=number]::-webkit-inner-spin-button, +.s-input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } + + +/* ── Slider ───────────────────────────────────────────────────────── */ +.s-slider-row { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-4); +} + +.s-slider { + flex: 1; + accent-color: var(--accent); + cursor: pointer; +} + +.s-slider-val { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-secondary); + width: 42px; + text-align: center; + padding: 3px 4px; + background: var(--bg-surface); + border: 1px solid var(--border-dim); + border-radius: var(--radius-sm); + outline: none; + transition: border-color var(--t-base); + -moz-appearance: textfield; +} +.s-slider-val::-webkit-inner-spin-button, +.s-slider-val::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } +.s-slider-val:focus { border-color: var(--accent-dim); } + +.s-slider-unit { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-faint); + margin-left: calc(var(--sp-1) * -1); +} + +.s-presets { + display: flex; + gap: var(--sp-1); + flex-wrap: wrap; + padding: 0 var(--sp-4) var(--sp-3); +} + +.s-preset { + font-family: var(--font-ui); + font-size: var(--text-2xs); + padding: 2px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); + background: none; + color: var(--text-faint); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.s-preset:hover { color: var(--text-muted); border-color: var(--border-strong); } +.s-preset.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); } + + +/* ── Buttons ──────────────────────────────────────────────────────── */ +/* Base */ +.s-btn { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + white-space: nowrap; + padding: 5px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border-dim); + background: none; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base), filter var(--t-base); +} +.s-btn:hover:not(:disabled) { + color: var(--text-secondary); + border-color: var(--border-strong); + background: var(--bg-raised); +} +.s-btn:disabled { opacity: 0.35; cursor: default; } + +/* Accent */ +.s-btn-accent { + background: var(--accent-muted); + border-color: var(--accent-dim); + color: var(--accent-fg); +} +.s-btn-accent:hover:not(:disabled) { filter: brightness(1.12); background: var(--accent-muted); } + +/* Danger */ +.s-btn-danger { + border-color: color-mix(in srgb, var(--color-error) 45%, transparent); + color: var(--color-error); + background: none; +} +.s-btn-danger:hover:not(:disabled) { + background: var(--color-error-bg); + border-color: var(--color-error); +} + +/* Icon-only reset button */ +.s-btn-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + font-size: var(--text-sm); + color: var(--text-faint); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.s-btn-icon:hover:not(:disabled) { + color: var(--text-muted); + border-color: var(--border-dim); + background: var(--bg-overlay); +} +.s-btn-icon:disabled { opacity: 0.3; cursor: default; } +.s-btn-icon.danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); } +.s-btn-icon.accent:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent-dim); } + +.s-btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } + + +/* ── Status Pill ──────────────────────────────────────────────────── */ +.s-pill { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + padding: 2px 8px; + border-radius: var(--radius-full); + border: 1px solid var(--border-dim); + color: var(--text-faint); + background: var(--bg-overlay); + cursor: default; + flex-shrink: 0; +} +.s-pill.on { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } +.s-pill.warn { border-color: var(--color-error); color: var(--color-error); background: var(--color-error-bg); } + + +/* ── Banner / Alert ───────────────────────────────────────────────── */ +.s-banner { + font-family: var(--font-ui); + font-size: var(--text-xs); + line-height: var(--leading-snug); + border-radius: var(--radius-md); + padding: var(--sp-3) var(--sp-4); + letter-spacing: var(--tracking-wide); + margin: var(--sp-3) var(--sp-4) 0; +} +.s-banner-error { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); } +.s-banner-warn { color: #d97706; background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.3); } +.s-banner-info { color: var(--color-info); background: var(--color-info-bg); border: 1px solid color-mix(in srgb, var(--color-info) 35%, transparent); } +.s-banner code { font-family: monospace; font-size: 10px; background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; } + + +/* ── Password field with eye toggle ──────────────────────────────── */ +.s-field-wrap { position: relative; flex-shrink: 0; } +.s-field-wrap .s-input { padding-right: 34px; } +.s-eye-btn { + position: absolute; + right: 8px; top: 50%; transform: translateY(-50%); + display: flex; align-items: center; justify-content: center; + padding: 0; border: none; background: none; + color: var(--text-faint); cursor: pointer; + transition: color var(--t-base); +} +.s-eye-btn:hover { color: var(--text-muted); } + + +/* ── Segmented control (auth mode, etc.) ─────────────────────────── */ +.s-segment { + display: flex; + gap: 2px; + background: var(--bg-overlay); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + padding: 2px; + flex-shrink: 0; +} + +.s-segment-btn { + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 4px 12px; + border-radius: var(--radius-sm); + border: none; + background: none; + color: var(--text-faint); + cursor: pointer; + white-space: nowrap; + transition: color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast); +} +.s-segment-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-raised); } +.s-segment-btn.active { + background: var(--bg-surface); + color: var(--text-primary); + box-shadow: 0 1px 4px rgba(0,0,0,0.35); +} +.s-segment-btn:disabled { opacity: 0.4; cursor: default; } + + +/* ── Collapsible section ──────────────────────────────────────────── */ +.s-collapsible-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--sp-3) var(--sp-4); + background: none; + border: none; + cursor: pointer; + transition: background var(--t-fast); + color: inherit; +} +.s-collapsible-trigger:hover { background: var(--bg-overlay); } + +.s-collapsible-caret { + color: var(--text-faint); + transition: transform var(--t-base); + flex-shrink: 0; +} +.s-collapsible-caret.open { transform: rotate(180deg); } + +.s-collapsible-body { + border-top: 1px solid var(--border-dim); +} + + +/* ── Storage bar ──────────────────────────────────────────────────── */ +.s-storage-wrap { + padding: var(--sp-3) var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.s-storage-header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.s-storage-label { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-muted); + letter-spacing: var(--tracking-wide); +} + +.s-storage-used { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.s-storage-bar { + height: 5px; + background: var(--bg-overlay); + border-radius: var(--radius-full); + overflow: hidden; +} + +.s-storage-fill { + height: 100%; + background: var(--accent); + border-radius: var(--radius-full); + transition: width 0.4s ease; +} +.s-storage-fill.warn { background: #d97706; } +.s-storage-fill.critical { background: var(--color-error); } + +.s-storage-footer { + display: flex; + justify-content: space-between; + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + word-break: break-all; +} + + +/* ── Tracker row ──────────────────────────────────────────────────── */ +.s-tracker-row { + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 10px var(--sp-4); + gap: var(--sp-3); + border-bottom: 1px solid var(--border-dim); + transition: background var(--t-fast); +} +.s-tracker-row:last-child { border-bottom: none; } +.s-tracker-row:hover { background: var(--bg-overlay); } +.s-tracker-row.expanded { background: var(--bg-overlay); } + +.s-tracker-identity { + display: flex; + align-items: center; + gap: var(--sp-3); + flex: 1; + min-width: 0; +} + +.s-tracker-action { flex-shrink: 0; } + +.s-tracker-expand { + flex-basis: 100%; + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding-top: var(--sp-2); + padding-left: calc(34px + var(--sp-3)); +} + +.s-tracker-logo { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--border-dim); + background: var(--bg-overlay); +} + +.s-tracker-name { + font-size: var(--text-sm); + color: var(--text-secondary); +} + +/* ── OAuth inline flow ────────────────────────────────────────────── */ +.s-oauth-flow { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-4); + border-top: 1px solid var(--border-dim); + background: var(--bg-overlay); +} + +.s-oauth-hint { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + line-height: var(--leading-snug); +} + +.s-oauth-btns { display: flex; align-items: center; gap: var(--sp-2); } + + +/* ── Keybinds ─────────────────────────────────────────────────────── */ +.s-kb-hint { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + padding: var(--sp-3) var(--sp-4) var(--sp-2); +} + +.s-kb-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px var(--sp-4); + border-bottom: 1px solid var(--border-dim); + gap: var(--sp-4); + transition: background var(--t-fast); +} +.s-kb-row:last-child { border-bottom: none; } +.s-kb-row:hover { background: var(--bg-overlay); } + +.s-kb-label { + font-size: var(--text-sm); + color: var(--text-secondary); + flex: 1; +} + +.s-kb-right { display: flex; align-items: center; gap: var(--sp-2); } + +.s-kb-bind { + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 4px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); + background: var(--bg-surface); + color: var(--text-secondary); + cursor: pointer; + min-width: 96px; + text-align: center; + transition: border-color var(--t-base), color var(--t-base), background var(--t-base); +} +.s-kb-bind:hover { border-color: var(--border-strong); } +.s-kb-bind.listening { + border-color: var(--accent); + color: var(--accent-fg); + background: var(--accent-muted); + animation: s-pulse 1s ease infinite; +} + + +/* ── Theme grid ───────────────────────────────────────────────────── */ +.s-theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); +} + +.s-theme-card { + background: var(--bg-surface); + border: 1px solid var(--border-dim); + border-radius: var(--radius-lg); + overflow: hidden; + cursor: pointer; + text-align: left; + position: relative; + transition: border-color var(--t-base), box-shadow var(--t-base); +} +.s-theme-card:hover { border-color: var(--border-strong); box-shadow: 0 2px 14px rgba(0,0,0,0.25); } +.s-theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } + +.s-theme-preview { + height: 68px; + overflow: hidden; +} + +.s-theme-preview-bg { + width: 100%; + height: 100%; + display: flex; +} + +.s-theme-preview-sidebar { + width: 22%; + height: 100%; + flex-shrink: 0; +} + +.s-theme-preview-content { + flex: 1; + padding: 8px 6px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.s-theme-preview-accent { + height: 6px; + width: 48%; + border-radius: 3px; +} + +.s-theme-preview-text { + height: 4px; + width: 100%; + border-radius: 2px; +} + +.s-theme-info { + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.s-theme-name { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-secondary); +} + +.s-theme-desc { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.s-theme-check { + position: absolute; + top: 6px; + right: 6px; + font-size: 10px; + color: var(--accent-fg); + background: var(--accent-muted); + border: 1px solid var(--accent-dim); + border-radius: var(--radius-sm); + padding: 1px 5px; +} + +.s-theme-new { + border-style: dashed; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-2); + min-height: 100px; + color: var(--text-faint); + transition: border-color var(--t-base), background var(--t-base), color var(--t-base); +} +.s-theme-new:hover { + border-color: var(--accent-dim); + background: var(--accent-muted); + color: var(--accent-fg); +} + +.s-theme-actions { + display: none; + position: absolute; + top: 5px; + left: 5px; + gap: 3px; + z-index: 1; +} +.s-theme-card:hover .s-theme-actions { display: flex; } + +.s-theme-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + font-size: 10px; + cursor: pointer; + border: 1px solid var(--border-base); + background: var(--bg-overlay); + transition: background var(--t-base), color var(--t-base), border-color var(--t-base); +} +.s-theme-action-btn.edit { color: var(--text-muted); } +.s-theme-action-btn.edit:hover { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); } +.s-theme-action-btn.delete { color: var(--text-faint); } +.s-theme-action-btn.delete:hover { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); } + + +/* ── Folder / Category list ───────────────────────────────────────── */ +.s-folder-create { + display: flex; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-dim); +} + +.s-folder-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 8px var(--sp-4); + border-bottom: 1px solid var(--border-dim); + transition: background var(--t-fast); +} +.s-folder-row:last-child { border-bottom: none; } +.s-folder-row:hover { background: var(--bg-overlay); } + +.s-folder-name { + flex: 1; + font-size: var(--text-sm); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.s-folder-count { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + flex-shrink: 0; +} + + +/* ── Release list ─────────────────────────────────────────────────── */ +.s-release-scroll { + max-height: 300px; + overflow-y: auto; + padding: 0 var(--sp-2); + scrollbar-width: thin; + scrollbar-color: var(--border-base) transparent; +} +.s-release-scroll::-webkit-scrollbar { width: 4px; } +.s-release-scroll::-webkit-scrollbar-track { background: transparent; } +.s-release-scroll::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 2px; } + +.s-release-row { + border-radius: var(--radius-md); + border: 1px solid transparent; + overflow: hidden; + transition: border-color var(--t-fast); + margin-bottom: 2px; +} +.s-release-row.current { border-color: color-mix(in srgb, var(--accent) 28%, transparent); } + +.s-release-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px var(--sp-3); + gap: var(--sp-3); + transition: background var(--t-fast); +} +.s-release-header:hover { background: var(--bg-overlay); } + +.s-release-meta { + display: flex; + align-items: center; + gap: var(--sp-2); + flex: 1; + min-width: 0; +} + +.s-release-tag { + font-family: var(--font-ui); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + color: var(--text-secondary); + letter-spacing: var(--tracking-wide); +} + +.s-release-badge { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + padding: 1px 6px; + border-radius: var(--radius-full); + border: 1px solid var(--accent-dim); + color: var(--accent-fg); + background: var(--accent-muted); +} + +.s-release-date { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.s-release-body { + padding: var(--sp-2) var(--sp-4) var(--sp-3); + border-top: 1px solid var(--border-dim); + background: var(--bg-overlay); +} + +.s-release-body pre { + font-family: monospace; + font-size: 11px; + color: var(--text-faint); + white-space: pre-wrap; + word-break: break-word; + line-height: var(--leading-base); + margin: 0; +} + + +/* ── Update progress ──────────────────────────────────────────────── */ +.s-update-progress { + padding: var(--sp-3) var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.s-update-bar { height: 3px; background: var(--bg-overlay); border-radius: 2px; overflow: hidden; } +.s-update-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; } + +.s-update-labels { + display: flex; + justify-content: space-between; + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.s-update-ready { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + background: color-mix(in srgb, var(--accent) 8%, transparent); + border-top: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); +} + +.s-update-ready-label { + flex: 1; + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--accent-fg); + letter-spacing: var(--tracking-wide); +} + + +/* ── Content filter tags ──────────────────────────────────────────── */ +.s-tag-grid { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-4); +} + +.s-tag { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 4px 8px 4px 7px; + border-radius: var(--radius-full); + border: 1px solid var(--border-base); + background: var(--bg-surface); + color: var(--text-secondary); +} + +.s-tag-remove { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + border: none; + background: none; + color: var(--text-faint); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; + transition: color var(--t-fast), background var(--t-fast); +} +.s-tag-remove:hover { color: var(--color-error); background: var(--color-error-bg); } + +.s-tag-add { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 0 var(--sp-4) var(--sp-3); + min-width: 0; + overflow: hidden; +} + + +/* ── Source override list ─────────────────────────────────────────── */ +.s-source-list { display: flex; flex-direction: column; } + +.s-source-row { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 9px var(--sp-4); + border-bottom: 1px solid var(--border-dim); + transition: background var(--t-fast); +} +.s-source-row:last-child { border-bottom: none; } +.s-source-row:hover { background: var(--bg-overlay); } +.s-source-row.allowed { background: color-mix(in srgb, var(--color-success) 5%, transparent); } +.s-source-row.blocked { background: color-mix(in srgb, var(--color-error) 5%, transparent); } + +.s-source-icon { + width: 30px; + height: 30px; + border-radius: var(--radius-md); + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--border-dim); + background: var(--bg-overlay); +} + +.s-source-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.s-source-name { + font-size: var(--text-sm); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.s-source-meta { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.s-source-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; } + +.s-source-action-btn { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + padding: 3px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); + background: none; + color: var(--text-faint); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.s-source-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); } +.s-source-action-btn.allow { color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 40%, transparent); background: color-mix(in srgb, var(--color-success) 8%, transparent); } +.s-source-action-btn.block { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); } + + +/* ── Dev tools ────────────────────────────────────────────────────── */ +.s-dev-grid { + display: grid; + grid-template-columns: 68px 1fr; + gap: 1px 12px; + padding: var(--sp-3) var(--sp-4); +} + +.s-dev-key { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--text-faint); + padding: 4px 0; + display: flex; + align-items: center; +} + +.s-dev-val { + font-family: monospace; + font-size: 11px; + color: var(--text-secondary); + padding: 4px 0; + display: flex; + align-items: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.s-dev-pill-group { display: flex; gap: 4px; } + +.s-dev-pill { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wider); + font-weight: var(--weight-medium); + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); + background: none; + color: var(--text-faint); + cursor: pointer; + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); + display: flex; + align-items: center; + justify-content: center; +} +.s-dev-pill:hover { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); } +.s-dev-pill.success:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } +.s-dev-pill.error:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg); } +.s-dev-pill.download:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } + + +/* ── Migration banner ─────────────────────────────────────────────── */ +.s-migrate-banner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-4); + padding: var(--sp-3) var(--sp-4); + background: color-mix(in srgb, var(--color-info) 7%, transparent); + border: 1px solid color-mix(in srgb, var(--color-info) 25%, transparent); + border-radius: var(--radius-md); + margin: var(--sp-3) var(--sp-4) 0; +} +.s-migrate-body { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; } +.s-migrate-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-info); letter-spacing: var(--tracking-wide); } +.s-migrate-paths { font-family: monospace; font-size: 10px; color: var(--text-faint); word-break: break-all; } +.s-migrate-bar { height: 3px; background: var(--bg-overlay); border-radius: 2px; overflow: hidden; margin-top: var(--sp-1); } +.s-migrate-fill { height: 100%; background: var(--color-info); border-radius: 2px; transition: width 0.15s; } +.s-migrate-actions { display: flex; flex-direction: column; gap: var(--sp-1); flex-shrink: 0; align-items: flex-end; } + + +/* ── Misc helpers ─────────────────────────────────────────────────── */ +.s-empty { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + padding: var(--sp-4); + text-align: center; +} + +.s-search-wrap { + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-dim); +} + +.s-pin-error { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--color-error); + letter-spacing: var(--tracking-wide); +} \ No newline at end of file diff --git a/src/features/settings/components/Settings.svelte b/src/features/settings/components/Settings.svelte new file mode 100644 index 0000000..cc9e88b --- /dev/null +++ b/src/features/settings/components/Settings.svelte @@ -0,0 +1,173 @@ + + + \ No newline at end of file diff --git a/src/features/settings/components/ThemeEditor.svelte b/src/features/settings/components/ThemeEditor.svelte new file mode 100644 index 0000000..5a63e73 --- /dev/null +++ b/src/features/settings/components/ThemeEditor.svelte @@ -0,0 +1,501 @@ + + + + + + + \ No newline at end of file diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/settings/sections/AboutSettings.svelte b/src/features/settings/sections/AboutSettings.svelte new file mode 100644 index 0000000..fef71ab --- /dev/null +++ b/src/features/settings/sections/AboutSettings.svelte @@ -0,0 +1,216 @@ + + +
+ +
+

Moku

+
+
+ A manga reader frontend for Suwayomi / Tachidesk. + Built with Tauri + Svelte. +
+
+
+ +
+

Version

+
+
+
Installedv{appVersion}
+ +
+ {#if onLatestVersion} +
+ ✓ You're on the latest version. +
+ {/if} + {#if updatePhase === "downloading" && IS_WINDOWS} +
+
+
+
+
+ Downloading {targetTag ?? "update"}… + {fmtProgress()} +
+
+ {/if} + {#if updatePhase === "ready"} +
+ {targetTag} downloaded — restart to finish installing. + + +
+ {/if} + {#if updatePhase === "error"} +
+ {updateError} + +
+ {/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 isExpanded && release.body.trim()} +
+
{release.body.trim()}
+
+ {/if} +
+ {/each} +
+ {/if} +
+
+ +
+

Links

+ +
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/AppearanceSettings.svelte b/src/features/settings/sections/AppearanceSettings.svelte new file mode 100644 index 0000000..f0b0249 --- /dev/null +++ b/src/features/settings/sections/AppearanceSettings.svelte @@ -0,0 +1,93 @@ + + +
+ +
+

Theme

+
+ {#each THEMES as theme} + {@const active = (store.settings.theme ?? "dark") === theme.id} +
+ +
+ {/each} + + {#each store.settings.customThemes ?? [] as custom} + {@const active = store.settings.theme === custom.id} +
+
+ + +
+ + {#if active}{/if} +
+ {/each} + + +
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/ContentSettings.svelte b/src/features/settings/sections/ContentSettings.svelte new file mode 100644 index 0000000..d8c931f --- /dev/null +++ b/src/features/settings/sections/ContentSettings.svelte @@ -0,0 +1,170 @@ + + +
+ +
+

Content Filter

+
+ +
+
+ +
+

+ Blocked Genre Tags + +

+
+
+ Manga matching any of these substrings are filtered. Case-insensitive, partial match. +
+ {#if tagsRevealed} +
+ {#each (store.settings.nsfwFilteredTags ?? []) as tag} + + + {tag} + + + {/each} +
+ {/if} +
+ { if (e.key === "Enter") addTag(); }} /> + + +
+
+
+ +
+

Source Overrides

+
+
+ Allow lets a source through even if flagged NSFW. Block always hides it. +
+
+ +
+ {#if contentSourcesLoading} +

Loading sources…

+ {:else if contentSources.length === 0} +

No sources found — check your server connection.

+ {:else} +
+ {#each contentSourcesFiltered as group (group.name)} + {@const ids = group.sources.map(s => s.id)} + {@const allowed = store.settings.nsfwAllowedSourceIds ?? []} + {@const blocked = store.settings.nsfwBlockedSourceIds ?? []} + {@const isAllowed = ids.every(id => allowed.includes(id))} + {@const isBlocked = ids.every(id => blocked.includes(id))} +
+ +
+ {group.name} + {group.sources[0].isNsfw ? "NSFW · " : ""}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()} +
+
+ + +
+
+ {/each} +
+ {/if} +
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/DevtoolsSettings.svelte b/src/features/settings/sections/DevtoolsSettings.svelte new file mode 100644 index 0000000..abb704e --- /dev/null +++ b/src/features/settings/sections/DevtoolsSettings.svelte @@ -0,0 +1,131 @@ + + +
+ +
+

Toasts

+
+
+
Fire test toastTriggers each kind with realistic content
+
+ {#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]} + + {/each} +
+
+
+
+ +
+

Previews

+
+
+
Idle splashDismiss with any click or key
+ +
+
+
+ +
+ + {#if expOpen} +
+
+ 3D tilt cards — hover to preview +
+ {#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card} + +
+ {card.title} + {card.sub} +
+
+ {/each} +
+
+
+ {/if} +
+ +
+

Runtime

+
+
+ Filter {store.libraryFilter} + Folders {store.categories.filter(c => c.id !== 0).map(c => c.name).join(", ") || "none"} + History {store.history.length} entries + Cache {perfSnapshot?.cacheEntries ?? "—"} entries + Toasts {store.toasts.length} queued + Version {appVersion} · {import.meta.env.MODE} +
+
+
+ {#if perfSnapshot && perfSnapshot.cacheEntries > 0} + {perfSnapshot.cacheKeys.join(", ")} + Oldest: {fmtAge(perfSnapshot.oldestEntryMs)} · Newest: {fmtAge(perfSnapshot.newestEntryMs)} + {/if} +
+ +
+
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/FoldersSettings.svelte b/src/features/settings/sections/FoldersSettings.svelte new file mode 100644 index 0000000..d9aeb5b --- /dev/null +++ b/src/features/settings/sections/FoldersSettings.svelte @@ -0,0 +1,158 @@ + + +
+ +
+

Manage Folders

+
+
+ Folders are stored as Suwayomi categories. Changes sync across all clients. +
+ {#if catsError} +
{catsError}
+ {/if} +
+ e.key === "Enter" && createFolder()} /> + +
+ {#if catsLoading} +

Loading folders…

+ {:else if store.categories.filter(c => c.id !== 0).length === 0} +

No folders yet. Create one above.

+ {:else} + {@const displayCats = store.categories + .filter(c => c.id !== 0) + .sort((a, b) => { + const defaultId = store.settings.defaultLibraryCategoryId ?? null; + if (a.id === defaultId) return -1; + if (b.id === defaultId) return 1; + return a.order - b.order; + })} + {#each displayCats as cat, i} +
+ {#if editingId === cat.id} + { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }} + onblur={commitEdit} use:focusInput /> + + {:else} + + {cat.name} + {cat.mangas?.nodes.length ?? 0} manga + + + + + + + {/if} +
+ {/each} + {/if} +
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/GeneralSettings.svelte b/src/features/settings/sections/GeneralSettings.svelte new file mode 100644 index 0000000..f0715f9 --- /dev/null +++ b/src/features/settings/sections/GeneralSettings.svelte @@ -0,0 +1,110 @@ + + +
+ +
+

Interface Scale

+
+
+ updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })} + class="s-slider" /> + { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }); }} + onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = "50"; } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = "200"; } }} + /> + % + +
+
+ {#each [50,60,70,80,90,100,110,125,150,175,200] as v} + + {/each} +
+
+
+ +
+

Server

+
+
+
Server URLBase URL of your Suwayomi instance
+ updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" /> +
+ +
+
+ +
+

Inactivity

+
+
+
Idle screen timeoutShow the Moku idle splash after this much inactivity
+
+ + {#if selectOpen === "idle-timeout"} +
+ {#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
+ +
+

Integrations

+
+ +
+
+ +
+

Animations

+
+ +
+
+ +
+

Language

+
+
+
+ Preferred source language + Used to pre-select languages in Search and deduplicate sources +
+ updateSettings({ preferredExtensionLang: e.currentTarget.value.trim().toLowerCase() })} + placeholder="en" spellcheck="false" /> +
+
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/KeybindsSettings.svelte b/src/features/settings/sections/KeybindsSettings.svelte new file mode 100644 index 0000000..7fc6ec3 --- /dev/null +++ b/src/features/settings/sections/KeybindsSettings.svelte @@ -0,0 +1,53 @@ + + +
+
+

+ Keyboard Shortcuts + +

+

Click a binding to rebind, then press the new key combination.

+
+ {#each Object.keys(KEYBIND_LABELS) as key} + {@const k = key as keyof Keybinds} + {@const isListening = listeningKey === k} + {@const isDefault = store.settings.keybinds[k] === DEFAULT_KEYBINDS[k]} +
+ {KEYBIND_LABELS[k]} +
+ + +
+
+ {/each} +
+
+
\ No newline at end of file diff --git a/src/features/settings/sections/LibrarySettings.svelte b/src/features/settings/sections/LibrarySettings.svelte new file mode 100644 index 0000000..88853c2 --- /dev/null +++ b/src/features/settings/sections/LibrarySettings.svelte @@ -0,0 +1,65 @@ + + +
+ +
+

Display

+
+ + +
+
+ +
+

Chapters

+
+
+
Default sort directionInitial chapter list order when opening a manga
+
+ + {#if selectOpen === "sort-dir"} +
+ {#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
+ +
+

History

+
+
+
Reading history{store.history.length} entries
+ +
+
+
Wipe all dataHistory, stats, pins, and manga links
+ +
+
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/PerformanceSettings.svelte b/src/features/settings/sections/PerformanceSettings.svelte new file mode 100644 index 0000000..48fd9e3 --- /dev/null +++ b/src/features/settings/sections/PerformanceSettings.svelte @@ -0,0 +1,152 @@ + + +
+ +
+

Render Limit

+
+
+
+ Items per page + Lower = faster on large libraries +
+
+ + {store.settings.renderLimit ?? 48} + +
+
+
+ {#each [12, 24, 48, 96, 200] as v} + + {/each} +
+
+
+ +
+

Rendering

+
+ +
+
+ +
+

Idle / Splash Screen

+
+ +
+
+ +
+

Interface

+
+ +
+
+ +
+

Session Cache

+
+
+
+ Cache entries + In-memory, cleared on restart +
+
+ {perfSnapshot?.cacheEntries ?? 0} entries + +
+
+ {#if perfSnapshot && perfSnapshot.cacheEntries > 0} +
+
Oldest entry
+ {fmtAge(perfSnapshot.oldestEntryMs)} +
+
+
Newest entry
+ {fmtAge(perfSnapshot.newestEntryMs)} +
+
+
+ Cached keys + {perfSnapshot.cacheKeys.join(", ")} +
+
+ {/if} +
+
+ +
+

Cache

+
+
+
Image cacheWebview page image cache
+ +
+
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/ReaderSettings.svelte b/src/features/settings/sections/ReaderSettings.svelte new file mode 100644 index 0000000..d3bddd5 --- /dev/null +++ b/src/features/settings/sections/ReaderSettings.svelte @@ -0,0 +1,141 @@ + + +
+ +
+

Page Layout

+
+
+
Default layoutHow chapters open by default
+
+ + {#if selectOpen === "page-style"} +
+ {#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
Reading directionLeft-to-right for most manga, right-to-left for Japanese
+
+ + {#if selectOpen === "reading-dir"} +
+ {#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]} + + {/each} +
+ {/if} +
+
+ + + +
+
+ +
+

Fit & Zoom

+
+
+
Default fit modeHow pages are scaled to fill the reader on open
+
+ + {#if selectOpen === "fit-mode"} +
+ {#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]} + + {/each} +
+ {/if} +
+
+
+ updateSettings({ readerZoom: Number(e.currentTarget.value) / 100 })} + class="s-slider" /> + { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 10 && n <= 400) updateSettings({ readerZoom: n / 100 }); }} + onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 10) { updateSettings({ readerZoom: 0.1 }); e.currentTarget.value = "10"; } else if (n > 400) { updateSettings({ readerZoom: 4.0 }); e.currentTarget.value = "400"; } }} + /> + % + +
+
+ {#each [50, 75, 100, 125, 150, 200] as v} + + {/each} +
+ +
+
+ +
+

Behaviour

+
+ + + {#if !(store.settings.autoNextChapter ?? false)} + + {/if} + +
+
Pages to preloadHow many pages ahead to fetch in the background while reading
+
+ + {store.settings.preloadPages} + +
+
+
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/SecuritySettings.svelte b/src/features/settings/sections/SecuritySettings.svelte new file mode 100644 index 0000000..1148cd9 --- /dev/null +++ b/src/features/settings/sections/SecuritySettings.svelte @@ -0,0 +1,361 @@ + + +
+ + {#if secError} +
{secError}
+ {/if} + +
+

+ Server Authentication + + {store.settings.serverAuthMode === "BASIC_AUTH" ? "Basic Auth" : + store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login — unsupported" : + store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login — unsupported" : "Disabled"} + +

+
+ {#if authModeUnsupported} +
+ {store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : "UI Login"} is not supported — only Basic Auth works here. Switch your server to basic_auth and set the mode to Basic. +
+ {/if} +
+
ModeHow Suwayomi verifies requests
+
+ {#each [{ value: "NONE", label: "None" }, { value: "BASIC_AUTH", label: "Basic" }] as opt} + + {/each} +
+
+ {#if authMode !== "NONE"} +
+
Username
+ +
+
+
Password
+
+ + +
+
+ {/if} + {#if store.settings.serverAuthMode === "BASIC_AUTH"} +
+ Images are proxied through Tauri when Basic Auth is active, which reduces loading speed. +
+ {/if} +
+
+
+ {#if store.settings.serverAuthMode !== "NONE"} + + {/if} + +
+
+
+
+ +
+

App Lock

+
+ + {#if store.settings.appLockEnabled} +
+
PIN4–8 digits
+
+ { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }} + onkeydown={(e) => e.key === "Enter" && commitPin()} + autocomplete="off" style="width:120px;letter-spacing:0.2em" /> + +
+
+ {#if pinError}
{pinError}
{/if} + {/if} +
+
+ +
+

SOCKS Proxy

+
+ + {#if socksEnabled} +
+
Version
+
+ + {#if selectOpen === "socks-ver"} +
+ {#each [[4,"SOCKS4"],[5,"SOCKS5"]] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
Host
+ +
+
+
Port
+ +
+
+
UsernameOptional
+ +
+
+
PasswordOptional
+
+ + +
+
+
+
+ +
+ {/if} +
+
+ +
+

FlareSolverr

+
+ + {#if flareEnabled} +
+
URLFlareSolverr instance address
+ +
+
+
TimeoutMax wait per request, in seconds
+
+ + {flareTimeout}s + +
+
+
+
Session nameReuse browser session across requests
+ +
+
+
Session TTLMinutes before session is refreshed
+
+ + {flareTtl}m + +
+
+ +
+
+ +
+ {/if} +
+
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/StorageSettings.svelte b/src/features/settings/sections/StorageSettings.svelte new file mode 100644 index 0000000..ddf6147 --- /dev/null +++ b/src/features/settings/sections/StorageSettings.svelte @@ -0,0 +1,629 @@ + + +
+ + {#if migrateFrom && !isExternalServer} +
+
+ Manga found at previous path — move to new location? + {migrateFrom} → {migrateTo} + {#if migrateProgress && migrateProgress.total > 0} +
+ {migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total} + {/if} + {#if migrateError}{migrateError}{/if} +
+
+ + +
+
+ {/if} + +
+

+ Disk Usage + +

+
+ {#if storageLoading} +

Reading filesystem…

+ {:else if storageError} +

{storageError}

+ {:else if isExternalServer} +

Disk usage is unavailable for external servers — filesystem access requires a local connection.

+ {:else if multiStorageInfos.length > 0} + {#each multiStorageInfos as info} + {@const limitGb = store.settings.storageLimitGb ?? null} + {@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null} + {@const available = info.manga_bytes + info.free_bytes} + {@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available} + {@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0} +
+
+ {info.label} + {fmtBytes(info.manga_bytes)} of {fmtBytes(cap)} +
+
+
90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%">
+
+ +
+ {/each} + {:else} +

No download path configured.

+ {/if} +
+
+ +
+

Downloads Path

+
+ {#if isExternalServer} +
+ Connected to an external server. The path below is read from the server — changes here will update the server's config directly. +
+ {/if} +
+ e.key === "Enter" && savePaths()} + oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined }; }} /> + {#if !isExternalServer} + + {/if} +
+
+
+ {#if pathsFieldError.dl} + {pathsFieldError.dl} + {/if} + {#if pathsError} + {pathsError} + {/if} +
+
+ {#if pathsFieldError.dl && !isExternalServer} + + {/if} + +
+
+
+
+ +
+

Storage Limit

+
+
+
+ Warn when limit is reached + {store.settings.storageLimitGb === null ? "No limit set" : `Warn above ${store.settings.storageLimitGb} GB`} +
+ {#if store.settings.storageLimitGb === null} + + {:else} +
+ + { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }); }} /> + GB + + +
+ {/if} +
+
+
+ +
+ + {#if advStorageOpen} +
+
+
+ Local source path + Read manga already on disk without an extension. Leave blank if unused. +
+
+
+ e.key === "Enter" && savePaths()} + oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined }; }} /> + {#if pathsFieldError.loc && !isExternalServer} + + {/if} +
+ {#if pathsFieldError.loc}{pathsFieldError.loc}{/if} +
+
+ + {#each extraScanDirs as dir} +
+
+ {dir} + Extra scan directory +
+ +
+ {/each} + +
+
+ Additional scan path + Include an extra directory in disk usage readings +
+
+ e.key === "Enter" && addExtraScanDir()} /> + +
+
+ +
+
+ +
+
+ {/if} +
+ +
+ + {#if backupSectionOpen} +
+
+
+ Create backup + Snapshot your library, categories, and tracker links +
+ +
+ + {#if backupError} +
{backupError}
+ {/if} + + {#if backupList.length === 0} +

No backups yet — create one above.

+ {:else} + {#each backupList as backup} +
+ + {backup.name} + + +
+ {/each} + {/if} + +
+
+ Restore from file + {restoreFile ? restoreFile.name : "Select a .tachibk file"} +
+ +
+ + {#if restoreFile} +
+
+
+ + +
+
+ {/if} + + {#if validateError} +
{validateError}
+ {/if} + + {#if validateResult} + {#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0} +
✓ All sources and trackers present
+ {:else} + {#if validateResult.missingSources.length > 0} +
+
+ Missing sources + {validateResult.missingSources.map(s => s.name).join(", ")} +
+
+ {/if} + {#if validateResult.missingTrackers.length > 0} +
+
+ Missing trackers + {validateResult.missingTrackers.map(t => t.name).join(", ")} +
+
+ {/if} + {/if} + {/if} + + {#if restoreError} +
{restoreError}
+ {/if} + + {#if restoreStatus} +
+
+ + {restoreStatus.state === "SUCCESS" ? "✓ Restore complete" : + restoreStatus.state === "FAILURE" ? "✗ Restore failed" : "Restoring…"} + + {#if restoreStatus.totalManga > 0} + {restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga + {/if} +
+ {#if restoreStatus.state !== "SUCCESS" && restoreStatus.state !== "FAILURE" && restoreStatus.totalManga > 0} +
+
+
+ {/if} +
+ {/if} +
+ {/if} +
+ +
\ No newline at end of file diff --git a/src/features/settings/sections/TrackingSettings.svelte b/src/features/settings/sections/TrackingSettings.svelte new file mode 100644 index 0000000..ee54002 --- /dev/null +++ b/src/features/settings/sections/TrackingSettings.svelte @@ -0,0 +1,151 @@ + + +
+ +
+

Connected Trackers

+
+ {#if trackersError} +
{trackersError}
+ {/if} + {#if trackersLoading} +

Loading trackers…

+ {:else} + {#each trackers as tracker} +
+
+ +
+ {tracker.name} + + {tracker.isLoggedIn ? "Connected" : "Not connected"} + +
+
+
+ {#if tracker.isLoggedIn} + + {:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id} + + {/if} +
+ {#if oauthTrackerId === tracker.id} +
+

Browser opened {tracker.name} login — authorise then paste the callback URL below.

+ { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }} + use:focusEl /> +
+ + +
+
+ {/if} + {#if credsTrackerId === tracker.id} +
+ e.key === "Escape" && cancelCredentials()} use:focusEl /> + { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} /> +
+ + +
+
+ {/if} +
+ {/each} + {/if} +
+
+ +
\ No newline at end of file diff --git a/src/features/tracking/components/Tracking.svelte b/src/features/tracking/components/Tracking.svelte new file mode 100644 index 0000000..73dc031 --- /dev/null +++ b/src/features/tracking/components/Tracking.svelte @@ -0,0 +1,667 @@ + + +
+ +
+
+

Tracking

+ +
+ + {#if !loading && loggedIn.length > 0} +
+ + {#each loggedIn as t} + + {/each} +
+ +
+
+ + +
+ + +
+ {/if} +
+ +
+ {#if loading} +
+ +
+ + {:else if error} +
+ {error} + +
+ + {:else if loggedIn.length === 0} +
+ No trackers connected. + Settings → Tracking to connect AniList, MAL, or others. +
+ + {:else if filtered.length === 0} +
+ {searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."} + {#if searchQuery || statusFilter !== "all"} + + {/if} +
+ + {:else} +
+ {#each filtered as record (record.tracker.id + ":" + record.id)} + {@const isBusy = updatingId === record.id} + {@const isSyncing = syncingId === record.id} + {@const progress = calcProgress(record.lastChapterRead, record.totalChapters)} + {@const stars = scoreToStars(record.displayScore, record.tracker.scores)} + +
+ +
+
openManga(record)} + onkeydown={(e) => e.key === "Enter" && openManga(record)} + > + {#if record.manga?.thumbnailUrl} + + {:else} +
+ {/if} +
+ +
+ {#if record.private} + + {/if} + {#if isSyncing} + + {:else} + + {/if} + {#if record.remoteUrl} + + + + {/if} + +
+ +
+ +
+
+ +
+
+ {#each Array(5) as _, i} + + {/each} +
+ +
openManga(record)} + onkeydown={(e) => e.key === "Enter" && openManga(record)} + > + {record.title} + {#if record.manga?.title && record.manga.title !== record.title} + {record.manga.title} + {/if} +
+ +
+ + +
+ + {#if editingChapter === record.id} + + {:else} +
openChapterEditor(record)} + onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)} + > +
+ + {#if progress !== null} + Ch. {record.lastChapterRead} / {record.totalChapters} + {:else if record.lastChapterRead > 0} + Ch. {record.lastChapterRead} read + {:else} + Set chapter… + {/if} + + {#if progress !== null} + {Math.round(progress)}% + {/if} +
+
+
+
+
+ {/if} +
+
+ {/each} +
+ {/if} +
+
+ +{#if confirmUnbind} + {@const r = confirmUnbind} + +{/if} + + diff --git a/src/features/tracking/index.ts b/src/features/tracking/index.ts new file mode 100644 index 0000000..fdb557d --- /dev/null +++ b/src/features/tracking/index.ts @@ -0,0 +1,2 @@ +export { default as Tracking } from "./components/Tracking.svelte"; +export * from "./lib/trackingSync"; diff --git a/src/features/tracking/lib/trackingSync.ts b/src/features/tracking/lib/trackingSync.ts new file mode 100644 index 0000000..698523a --- /dev/null +++ b/src/features/tracking/lib/trackingSync.ts @@ -0,0 +1,111 @@ +import type { Tracker, TrackRecord } from "@types/index"; + +export interface TrackerWithRecords extends Tracker { + trackRecords: { nodes: TrackRecord[] }; +} + +export interface FlatRecord extends TrackRecord { + tracker: Tracker; +} + +export type SortKey = "title" | "status" | "score" | "progress"; + +export function flattenRecords(trackers: TrackerWithRecords[]): FlatRecord[] { + return trackers + .filter((t) => t.isLoggedIn) + .flatMap((t) => + t.trackRecords.nodes.map((r) => ({ + ...r, + trackerId: r.trackerId ?? t.id, + tracker: t as Tracker, + })) + ); +} + +export function dedupeStatuses(trackers: TrackerWithRecords[]): { value: number; name: string }[] { + const seen = new Map(); + for (const t of trackers.filter((t) => t.isLoggedIn)) + for (const s of t.statuses ?? []) + seen.set(`${s.value}:${s.name}`, s); + return [...seen.values()]; +} + +export function filterRecords( + records: FlatRecord[], + trackerId: number | "all", + statusFilter: number | "all", + query: string, +): FlatRecord[] { + let list = trackerId === "all" + ? records + : records.filter((r) => Number(r.trackerId) === Number(trackerId)); + + if (statusFilter !== "all") + list = list.filter((r) => Number(r.status) === Number(statusFilter)); + + if (query.trim()) { + const q = query.toLowerCase(); + list = list.filter((r) => + r.title.toLowerCase().includes(q) || + r.manga?.title?.toLowerCase().includes(q) + ); + } + + return list; +} + +export function sortRecords(records: FlatRecord[], sortBy: SortKey): FlatRecord[] { + return [...records].sort((a, b) => { + if (sortBy === "title") return a.title.localeCompare(b.title); + if (sortBy === "status") return a.status - b.status; + if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0"); + if (sortBy === "progress") { + const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0; + const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0; + return bp - ap; + } + return 0; + }); +} + +export function scoreToStars(score: string | undefined, scores: string[] | undefined): number { + if (!score || !scores || scores.length === 0) return 0; + const idx = scores.indexOf(score); + if (idx < 0) return 0; + return Math.round((idx / (scores.length - 1)) * 5); +} + +export function calcProgress(lastChapterRead: number, totalChapters: number): number | null { + if (totalChapters <= 0) return null; + return Math.min(100, (lastChapterRead / totalChapters) * 100); +} + +export function patchTracker( + trackers: TrackerWithRecords[], + trackerId: number, + updated: Partial & { id: number }, +): TrackerWithRecords[] { + return trackers.map((t) => + t.id !== trackerId ? t : { + ...t, + trackRecords: { + nodes: t.trackRecords.nodes.map((r) => + r.id === updated.id ? { ...r, ...updated } : r + ), + }, + } + ); +} + +export function removeRecord( + trackers: TrackerWithRecords[], + trackerId: number, + recordId: number, +): TrackerWithRecords[] { + return trackers.map((t) => + t.id !== trackerId ? t : { + ...t, + trackRecords: { nodes: t.trackRecords.nodes.filter((r) => r.id !== recordId) }, + } + ); +} diff --git a/src/lib/cache.ts b/src/lib/cache.ts deleted file mode 100644 index ef3b1ee..0000000 --- a/src/lib/cache.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Session-level request cache — v3. - * - * Key design decisions (preserved from v1/v2): - * - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd). - * - On real errors the entry is evicted so the next call retries. - * - AbortErrors do NOT evict — cancellation ≠ failure. - * - Subscribers are notified when a key is explicitly cleared or updated. - * - * v3 additions: - * - cache.set(): direct write without a fetcher — for optimistic updates and - * post-mutation cache patching. Notifies subscribers immediately. - * - Invalidation groups: tag a cache key with one or more group strings. - * cache.clearGroup("library") clears ALL keys tagged with "library" in one call. - * This replaces the pattern of manually calling cache.clear() on every related key. - * - Subscriber notifications on set() — reactive components re-render when the - * cache is updated, not just when it's cleared. - * - cache.update(): atomically patch a cached value (read → transform → write). - */ - -interface Entry { - promise: Promise; - fetchedAt: number; -} - -const store = new Map>(); -const subs = new Map void>>(); -const groups = new Map>(); // groupTag → Set - -export const DEFAULT_TTL_MS = 5 * 60 * 1_000; - -function notify(key: string) { - subs.get(key)?.forEach((cb) => cb()); -} - -export const cache = { - /** - * Return a cached promise. Re-fetches once older than `ttl` ms. - * Pass `Infinity` to pin for the session. - */ - get( - key: string, - fetcher: () => Promise, - ttl: number = DEFAULT_TTL_MS, - group?: string | string[], - ): Promise { - const existing = store.get(key) as Entry | undefined; - if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise; - - const promise = fetcher().catch((err) => { - if (err?.name !== "AbortError") store.delete(key); - return Promise.reject(err); - }) as Promise; - - store.set(key, { promise, fetchedAt: Date.now() }); - - // Register in invalidation groups - if (group) { - const tags = Array.isArray(group) ? group : [group]; - for (const tag of tags) { - if (!groups.has(tag)) groups.set(tag, new Set()); - groups.get(tag)!.add(key); - } - } - - // Notify subscribers once the fetch resolves (reactive update on new data) - promise.then(() => notify(key)).catch(() => {}); - - return promise; - }, - - /** - * Directly write a value into the cache — for optimistic updates and - * post-mutation patching. Notifies subscribers immediately. - */ - set(key: string, value: T, group?: string | string[]) { - const promise = Promise.resolve(value); - store.set(key, { promise, fetchedAt: Date.now() }); - - if (group) { - const tags = Array.isArray(group) ? group : [group]; - for (const tag of tags) { - if (!groups.has(tag)) groups.set(tag, new Set()); - groups.get(tag)!.add(key); - } - } - - notify(key); - }, - - /** - * Atomically patch a cached value. - * If the key doesn't exist, does nothing. - */ - update(key: string, fn: (prev: T) => T) { - const existing = store.get(key) as Entry | undefined; - if (!existing) return; - const next = existing.promise.then(fn); - store.set(key, { promise: next, fetchedAt: Date.now() }); - next.then(() => notify(key)).catch(() => {}); - }, - - has(key: string): boolean { return store.has(key); }, - - ageOf(key: string): number | undefined { - const e = store.get(key); - return e ? Date.now() - e.fetchedAt : undefined; - }, - - clear(key: string) { - store.delete(key); - notify(key); - }, - - /** - * Clear all keys belonging to an invalidation group. - * e.g. cache.clearGroup("library") clears "library", "all_manga_unfiltered", etc. - */ - clearGroup(tag: string) { - const keys = groups.get(tag); - if (!keys) return; - for (const key of keys) { - store.delete(key); - notify(key); - } - groups.delete(tag); - }, - - clearAll() { - const allKeys = [...store.keys()]; - store.clear(); - groups.clear(); - allKeys.forEach(notify); - }, - - subscribe(key: string, cb: () => void): () => void { - if (!subs.has(key)) subs.set(key, new Set()); - subs.get(key)!.add(cb); - return () => subs.get(key)?.delete(cb); - }, -}; - -// ── Cache key constants ─────────────────────────────────────────────────────── - -/** - * Invalidation group tags. - * cache.clearGroup(CACHE_GROUPS.LIBRARY) clears all library-related keys at once. - */ -export const CACHE_GROUPS = { - LIBRARY: "g:library", // library + all_manga_unfiltered - SOURCES: "g:sources", // sources list + per-source page caches -} as const; - -export const CACHE_KEYS = { - LIBRARY: "library", - ALL_MANGA: "all_manga_unfiltered", - CATEGORIES: "categories", - SEARCH: "search_all_manga", // Search's unfiltered fetch — separate from library - SOURCES: "sources", - POPULAR: "popular", - GENRE: (genre: string) => `genre:${genre}`, - MANGA: (id: number) => `manga:${id}`, - CHAPTERS: (id: number) => `chapters:${id}`, - - sourceMangaPages( - sourceId: string, - type: "POPULAR" | "LATEST" | "SEARCH", - query?: string | string[], - ): string { - const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); - return `pages:${sourceId}:${type}:${q}`; - }, - - sourceMangaPage( - sourceId: string, - type: "POPULAR" | "LATEST" | "SEARCH", - page: number, - query?: string | string[], - ): string { - const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); - return `page:${sourceId}:${type}:${page}:${q}`; - }, -} as const; - -// ── In-flight request deduplication (for non-cached calls) ─────────────────── -// -// Some requests (chapter lists, manga detail) are NOT stored in the long-lived -// cache but still get fired multiple times when a user rapidly opens/closes a -// manga. This map deduplicates them so only one network round-trip is active at -// a time per key. - -const inflight = new Map>(); - -export function deduped(key: string, fetcher: () => Promise): Promise { - if (inflight.has(key)) return inflight.get(key) as Promise; - const p = fetcher().finally(() => inflight.delete(key)); - inflight.set(key, p); - return p; -} - -// ── PageSet: per-session page-number tracker ────────────────────────────────── -// -// Tracks which page numbers have been fetched for a (source, type, query) bucket. -// Lives in a separate map from the TTL store so it never gets TTL-evicted while -// a browse session is actively paginating. -// -// Usage: -// const ps = getPageSet(sourceId, "SEARCH", ["Action", "Romance"]); -// ps.add(1); // after fetching page 1 -// ps.next(); // → 2 -// ps.pages(); // → Set {1} -// ps.clear(); // call when query/tags change - -const _pageSets = new Map>(); - -export interface PageSet { - add(page: number): void; - pages(): Set; - /** Next page to fetch: max fetched + 1, or 1 if nothing fetched yet. */ - next(): number; - clear(): void; -} - -export function getPageSet( - sourceId: string, - type: "POPULAR" | "LATEST" | "SEARCH", - query?: string | string[], -): PageSet { - const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query); - return { - add(page) { - if (!_pageSets.has(key)) _pageSets.set(key, new Set()); - _pageSets.get(key)!.add(page); - }, - pages() { return new Set(_pageSets.get(key) ?? []); }, - next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; }, - clear() { _pageSets.delete(key); }, - }; -} - -// ── Source frecency helpers ─────────────────────────────────────────────────── - -const FRECENCY_KEY = "moku-source-frecency"; -const MAX_FRECENCY_SOURCES = 4; - -type FrecencyMap = Record; - -function loadFrecency(): FrecencyMap { - try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; } - catch { return {}; } -} - -function saveFrecency(map: FrecencyMap) { - try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {} -} - -export function recordSourceAccess(sourceId: string) { - if (!sourceId || sourceId === "0") return; - const map = loadFrecency(); - map[sourceId] = (map[sourceId] ?? 0) + 1; - saveFrecency(map); -} - -export function getTopSources(sources: T[]): T[] { - const map = loadFrecency(); - const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 })); - const hasFrecency = withScore.some((x) => x.score > 0); - if (hasFrecency) { - return withScore - .sort((a, b) => b.score - a.score) - .slice(0, MAX_FRECENCY_SOURCES) - .map((x) => x.s); - } - return sources.slice(0, MAX_FRECENCY_SOURCES); -} \ No newline at end of file diff --git a/src/lib/chapterList.ts b/src/lib/chapterList.ts deleted file mode 100644 index e856b69..0000000 --- a/src/lib/chapterList.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Chapter } from "./types"; - -export function buildReaderChapterList( - chapters: Chapter[], - mangaPrefs: { preferredScanlator?: string; scanlatorFilter?: string[] } | undefined, -): Chapter[] { - const preferred = mangaPrefs?.preferredScanlator ?? ""; - const filter = mangaPrefs?.scanlatorFilter ?? []; - - let base = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); - - if (preferred) { - const pref: Chapter[] = [], rest: Chapter[] = []; - for (const c of base) (c.scanlator === preferred ? pref : rest).push(c); - base = [...pref, ...rest]; - } - - if (filter.length > 0) { - const seen = new Map(); - for (const ch of base) { - const existing = seen.get(ch.chapterNumber); - if (!existing) { - seen.set(ch.chapterNumber, ch); - } else { - const np = filter.indexOf(ch.scanlator ?? ""); - const op = filter.indexOf(existing.scanlator ?? ""); - if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch); - } - } - base = [...seen.values()].sort((a, b) => a.sourceOrder - b.sourceOrder); - } - - return base; -} diff --git a/src/lib/discord.ts b/src/lib/discord.ts deleted file mode 100644 index 0a245f9..0000000 --- a/src/lib/discord.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { connect, disconnect, setActivity, clearActivity } from "tauri-plugin-discord-rpc-api"; -import { listen } from '@tauri-apps/api/event' -import type { Manga, Chapter } from './types' - -const APP_ID = '1487894643613106298' -const FALLBACK_IMAGE = 'moku_logo' - -let sessionStart: number | null = null -let unlisten: (() => void) | null = null - -function isPublicUrl(url: string | null | undefined): boolean { - return typeof url === 'string' && url.startsWith('https://') -} - -function resolveCoverImage(manga: Manga): string { - return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE -} - -function trunc(s: string, max = 128): string { - return s.length <= max ? s : `${s.slice(0, max - 1)}…` -} - -function formatChapter(chapter: Chapter): string { - const n = chapter.chapterNumber - return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}` -} - -const BUTTONS = [ - { label: 'GitHub', url: 'https://github.com/Youwes09/Moku' }, - { label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' }, -] - -export async function initRpc(): Promise { - sessionStart = Date.now() - - unlisten = await listen('discord-rpc://running', ({ payload }) => { - if (payload) setIdle().catch(() => {}) - }) - - await connect(APP_ID).catch(() => {}) -} - -export async function setReading(manga: Manga, chapter: Chapter): Promise { - await setActivity({ - details: trunc(manga.title), - state: `${formatChapter(chapter)} · Reading`, - timestamps: { start: sessionStart ?? Date.now() }, - assets: { - largeImage: resolveCoverImage(manga), - largeText: trunc(manga.title), - smallImage: FALLBACK_IMAGE, - smallText: 'Moku', - }, - buttons: BUTTONS, - }).catch(() => {}) -} - -export async function setIdle(): Promise { - await setActivity({ - details: 'Browsing', - timestamps: { start: sessionStart ?? Date.now() }, - assets: { - largeImage: FALLBACK_IMAGE, - largeText: 'Moku', - }, - buttons: BUTTONS, - }).catch(() => {}) -} - -export async function clearReading(): Promise { - await clearActivity().catch(() => {}) -} - -export async function destroyRpc(): Promise { - unlisten?.() - unlisten = null - sessionStart = null - await disconnect().catch(() => {}) -} diff --git a/src/lib/queries.ts b/src/lib/queries.ts deleted file mode 100644 index c7d9933..0000000 --- a/src/lib/queries.ts +++ /dev/null @@ -1,1001 +0,0 @@ -// ── Library ────────────────────────────────────────────────────────────────── - -export const GET_LIBRARY = ` - query GetLibrary { - mangas(condition: { inLibrary: true }) { - nodes { - id - title - thumbnailUrl - inLibrary - downloadCount - unreadCount - description - status - author - artist - genre - source { - id - name - displayName - } - chapters { - totalCount - } - } - } - } -`; - -export const GET_ALL_MANGA = ` - query GetAllManga { - mangas { - nodes { - id - title - thumbnailUrl - inLibrary - downloadCount - } - } - } -`; - -export const GET_MANGA = ` - query GetManga($id: Int!) { - manga(id: $id) { - id - title - description - thumbnailUrl - status - author - artist - genre - inLibrary - realUrl - source { - id - name - displayName - } - } - } -`; - -export const GET_CHAPTERS = ` - query GetChapters($mangaId: Int!) { - chapters(condition: { mangaId: $mangaId }) { - nodes { - id - name - chapterNumber - sourceOrder - isRead - isDownloaded - isBookmarked - pageCount - mangaId - uploadDate - realUrl - lastPageRead - scanlator - } - } - } -`; - -export const FETCH_CHAPTERS = ` - mutation FetchChapters($mangaId: Int!) { - fetchChapters(input: { mangaId: $mangaId }) { - chapters { - id - name - chapterNumber - sourceOrder - isRead - isDownloaded - isBookmarked - pageCount - mangaId - uploadDate - realUrl - lastPageRead - scanlator - } - } - } -`; - -export const FETCH_CHAPTER_PAGES = ` - mutation FetchChapterPages($chapterId: Int!) { - fetchChapterPages(input: { chapterId: $chapterId }) { - pages - } - } -`; - -export const UPDATE_MANGA = ` - mutation UpdateManga($id: Int!, $inLibrary: Boolean) { - updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) { - manga { - id - inLibrary - } - } - } -`; - -export const UPDATE_MANGAS = ` - mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) { - updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) { - mangas { - id - inLibrary - } - } - } -`; - -export const MARK_CHAPTER_READ = ` - mutation MarkChapterRead($id: Int!, $isRead: Boolean!) { - updateChapter(input: { id: $id, patch: { isRead: $isRead } }) { - chapter { - id - isRead - } - } - } -`; - -export const MARK_CHAPTERS_READ = ` - mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) { - updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) { - chapters { - id - isRead - } - } - } -`; - -export const UPDATE_CHAPTERS_PROGRESS = ` - mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) { - updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) { - chapters { - id - isRead - isBookmarked - lastPageRead - } - } - } -`; - -export const DELETE_DOWNLOADED_CHAPTERS = ` - mutation DeleteDownloadedChapters($ids: [Int!]!) { - deleteDownloadedChapters(input: { ids: $ids }) { - chapters { - id - isDownloaded - } - } - } -`; - -export const GET_DOWNLOADED_CHAPTERS_PAGES = ` - query GetDownloadedChaptersPages { - chapters(condition: { isDownloaded: true }) { - nodes { - pageCount - } - } - } -`; - -export const GET_DOWNLOADS_PATH = ` - query GetDownloadsPath { - settings { - downloadsPath - localSourcePath - } - } -`; - -export const SET_DOWNLOADS_PATH = ` - mutation SetDownloadsPath($path: String!) { - setSettings(input: { settings: { downloadsPath: $path } }) { - settings { downloadsPath } - } - } -`; - -export const SET_LOCAL_SOURCE_PATH = ` - mutation SetLocalSourcePath($path: String!) { - setSettings(input: { settings: { localSourcePath: $path } }) { - settings { localSourcePath } - } - } -`; - -// ── Categories ──────────────────────────────────────────────────────────────── - -export const GET_CATEGORIES = ` - query GetCategories { - categories { - nodes { - id - name - order - default - includeInUpdate - includeInDownload - mangas { - nodes { - id - title - thumbnailUrl - inLibrary - downloadCount - unreadCount - } - } - } - } - } -`; - -export const CREATE_CATEGORY = ` - mutation CreateCategory($name: String!) { - createCategory(input: { name: $name }) { - category { - id - name - order - default - includeInUpdate - includeInDownload - } - } - } -`; - -export const UPDATE_CATEGORY = ` - mutation UpdateCategory($id: Int!, $name: String) { - updateCategory(input: { id: $id, patch: { name: $name } }) { - category { - id - name - order - } - } - } -`; - -export const DELETE_CATEGORY = ` - mutation DeleteCategory($id: Int!) { - deleteCategory(input: { categoryId: $id }) { - category { - id - } - } - } -`; - -export const UPDATE_CATEGORY_ORDER = ` - mutation UpdateCategoryOrder($id: Int!, $position: Int!) { - updateCategoryOrder(input: { id: $id, position: $position }) { - categories { - id - name - order - default - includeInUpdate - includeInDownload - } - } - } -`; - -export const UPDATE_MANGA_CATEGORIES = ` - mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) { - updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) { - manga { - id - } - } - } -`; - -// ── Downloads ───────────────────────────────────────────────────────────────── - -export const GET_DOWNLOAD_STATUS = ` - query GetDownloadStatus { - downloadStatus { - state - queue { - progress - state - chapter { - id - name - pageCount - mangaId - manga { - id - title - thumbnailUrl - } - } - } - } - } -`; - -export const ENQUEUE_DOWNLOAD = ` - mutation EnqueueDownload($chapterId: Int!) { - enqueueChapterDownload(input: { id: $chapterId }) { - downloadStatus { - state - queue { - progress - state - chapter { - id - name - pageCount - mangaId - manga { id title thumbnailUrl } - } - } - } - } - } -`; - -export const ENQUEUE_CHAPTERS_DOWNLOAD = ` - mutation EnqueueChaptersDownload($chapterIds: [Int!]!) { - enqueueChapterDownloads(input: { ids: $chapterIds }) { - downloadStatus { - state - } - } - } -`; - -export const DEQUEUE_DOWNLOAD = ` - mutation DequeueDownload($chapterId: Int!) { - dequeueChapterDownload(input: { id: $chapterId }) { - downloadStatus { - state - } - } - } -`; - -export const START_DOWNLOADER = ` - mutation StartDownloader { - startDownloader(input: {}) { - downloadStatus { - state - queue { - progress - state - chapter { - id - name - pageCount - mangaId - manga { id title thumbnailUrl } - } - } - } - } - } -`; - -export const STOP_DOWNLOADER = ` - mutation StopDownloader { - stopDownloader(input: {}) { - downloadStatus { - state - queue { - progress - state - chapter { - id - name - pageCount - mangaId - manga { id title thumbnailUrl } - } - } - } - } - } -`; - -export const CLEAR_DOWNLOADER = ` - mutation ClearDownloader { - clearDownloader(input: {}) { - downloadStatus { - state - queue { - progress - state - chapter { - id name pageCount mangaId - manga { id title thumbnailUrl } - } - } - } - } - } -`; - -// ── Sources ─────────────────────────────────────────────────────────────────── - -export const GET_SOURCES = ` - query GetSources { - sources { - nodes { - id - name - lang - displayName - iconUrl - isNsfw - } - } - } -`; - -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 }) { - mangas { - id - title - thumbnailUrl - inLibrary - } - hasNextPage - } - } -`; - -export const FETCH_MANGA = ` - mutation FetchManga($id: Int!) { - fetchManga(input: { id: $id }) { - manga { - id - title - description - thumbnailUrl - status - author - artist - genre - inLibrary - realUrl - source { - id - name - displayName - } - } - } - } -`; - -// ── Extensions ──────────────────────────────────────────────────────────────── - -export const GET_EXTENSIONS = ` - query GetExtensions { - extensions { - nodes { - apkName - pkgName - name - lang - versionName - isInstalled - isObsolete - hasUpdate - iconUrl - } - } - } -`; - -export const FETCH_EXTENSIONS = ` - mutation FetchExtensions { - fetchExtensions(input: {}) { - extensions { - apkName - pkgName - name - lang - versionName - isInstalled - isObsolete - hasUpdate - iconUrl - } - } - } -`; - -export const UPDATE_EXTENSION = ` - mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) { - updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) { - extension { - apkName - pkgName - name - isInstalled - hasUpdate - } - } - } -`; - -export const INSTALL_EXTERNAL_EXTENSION = ` - mutation InstallExternalExtension($url: String!) { - installExternalExtension(input: { extensionUrl: $url }) { - extension { - apkName - pkgName - name - isInstalled - } - } - } -`; - -// ── Settings ────────────────────────────────────────────────────────────────── - -export const GET_SETTINGS = ` - query GetSettings { - settings { - extensionRepos - } - } -`; - -export const SET_EXTENSION_REPOS = ` - mutation SetExtensionRepos($repos: [String!]!) { - setSettings(input: { settings: { extensionRepos: $repos } }) { - settings { - extensionRepos - } - } - } -`; - -export const GET_SERVER_SECURITY = ` - query GetServerSecurity { - settings { - authMode - authUsername - socksProxyEnabled - socksProxyHost - socksProxyPort - socksProxyVersion - socksProxyUsername - flareSolverrEnabled - flareSolverrUrl - flareSolverrTimeout - flareSolverrSessionName - flareSolverrSessionTtl - flareSolverrAsResponseFallback - } - } -`; - -export const SET_SERVER_AUTH = ` - mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) { - setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) { - settings { - authMode - authUsername - } - } - } -`; - -export const SET_SOCKS_PROXY = ` - mutation SetSocksProxy( - $socksProxyEnabled: Boolean! - $socksProxyHost: String! - $socksProxyPort: String! - $socksProxyVersion: Int! - $socksProxyUsername: String! - $socksProxyPassword: String! - ) { - setSettings(input: { settings: { - socksProxyEnabled: $socksProxyEnabled - socksProxyHost: $socksProxyHost - socksProxyPort: $socksProxyPort - socksProxyVersion: $socksProxyVersion - socksProxyUsername: $socksProxyUsername - socksProxyPassword: $socksProxyPassword - }}) { - settings { - socksProxyEnabled - socksProxyHost - socksProxyPort - socksProxyVersion - socksProxyUsername - } - } - } -`; - -export const SET_FLARESOLVERR = ` - mutation SetFlareSolverr( - $flareSolverrEnabled: Boolean! - $flareSolverrUrl: String! - $flareSolverrTimeout: Int! - $flareSolverrSessionName: String! - $flareSolverrSessionTtl: Int! - $flareSolverrAsResponseFallback: Boolean! - ) { - setSettings(input: { settings: { - flareSolverrEnabled: $flareSolverrEnabled - flareSolverrUrl: $flareSolverrUrl - flareSolverrTimeout: $flareSolverrTimeout - flareSolverrSessionName: $flareSolverrSessionName - flareSolverrSessionTtl: $flareSolverrSessionTtl - flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback - }}) { - settings { - flareSolverrEnabled - flareSolverrUrl - flareSolverrTimeout - flareSolverrSessionName - flareSolverrSessionTtl - flareSolverrAsResponseFallback - } - } - } -`; - -// ── Trackers ────────────────────────────────────────────────────────────────── - -export const GET_TRACKERS = ` - query GetTrackers { - trackers { - nodes { - id - name - icon - isLoggedIn - authUrl - supportsPrivateTracking - scores - statuses { - value - name - } - } - } - } -`; - -export const GET_MANGA_TRACK_RECORDS = ` - query GetMangaTrackRecords($mangaId: Int!) { - manga(id: $mangaId) { - trackRecords { - nodes { - id - trackerId - remoteId - title - status - score - displayScore - lastChapterRead - totalChapters - remoteUrl - startDate - finishDate - private - } - } - } - } -`; - -export const SEARCH_TRACKER = ` - query SearchTracker($trackerId: Int!, $query: String!) { - searchTracker(input: { trackerId: $trackerId, query: $query }) { - trackSearches { - id - trackerId - remoteId - title - coverUrl - summary - publishingStatus - publishingType - startDate - totalChapters - trackingUrl - } - } - } -`; - -export const BIND_TRACK = ` - mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) { - bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) { - trackRecord { - id - trackerId - remoteId - title - status - score - displayScore - lastChapterRead - totalChapters - remoteUrl - startDate - finishDate - private - } - } - } -`; - -export const UPDATE_TRACK = ` - mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) { - updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) { - trackRecord { - id - trackerId - status - score - displayScore - lastChapterRead - totalChapters - startDate - finishDate - private - } - } - } -`; - -export const UNBIND_TRACK = ` - mutation UnbindTrack($recordId: Int!) { - unbindTrack(input: { recordId: $recordId }) { - trackRecord { - id - } - } - } -`; - -export const FETCH_TRACK = ` - mutation FetchTrack($recordId: Int!) { - fetchTrack(input: { recordId: $recordId }) { - trackRecord { - id - trackerId - status - score - displayScore - lastChapterRead - totalChapters - startDate - finishDate - } - } - } -`; - -export const GET_ALL_TRACKER_RECORDS = ` - query GetAllTrackerRecords { - trackers { - nodes { - id - name - icon - isLoggedIn - scores - statuses { value name } - trackRecords { - nodes { - id - trackerId - title - status - displayScore - lastChapterRead - totalChapters - remoteUrl - private - manga { - id - title - thumbnailUrl - inLibrary - } - } - } - } - } - } -`; - -export const GET_TRACKER_RECORDS = ` - query GetTrackerRecords($trackerId: Int!) { - trackers(condition: { id: $trackerId }) { - nodes { - id - name - statuses { value name } - trackRecords { - nodes { - id - title - status - displayScore - lastChapterRead - totalChapters - remoteUrl - manga { - id - title - thumbnailUrl - } - } - } - } - } - } -`; - -export const LOGIN_TRACKER_OAUTH = ` - mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) { - loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) { - isLoggedIn - tracker { - id - name - isLoggedIn - authUrl - } - } - } -`; - -export const LOGIN_TRACKER_CREDENTIALS = ` - mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) { - loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) { - isLoggedIn - tracker { - id - name - isLoggedIn - authUrl - } - } - } -`; - -export const LOGOUT_TRACKER = ` - mutation LogoutTracker($trackerId: Int!) { - logoutTracker(input: { trackerId: $trackerId }) { - tracker { - id - name - isLoggedIn - authUrl - } - } - } -`; - -export const LOGIN_USER = ` - mutation Login($username: String!, $password: String!) { - login(input: { username: $username, password: $password }) { - accessToken - refreshToken - } - } -`; - -export const REFRESH_TOKEN = ` - mutation RefreshToken { - refreshToken { - accessToken - } - } -`; - -export const UPDATE_LIBRARY = ` - mutation UpdateLibrary { - updateLibrary(input: {}) { - updateStatus { - jobsInfo { - isRunning - finishedJobs - totalJobs - } - } - } - } -`; - -// ── Backup ──────────────────────────────────────────────────────────────────── - -export const CREATE_BACKUP = ` - mutation CreateBackup { - createBackup(input: {}) { - url - } - } -`; - -export const RESTORE_BACKUP = ` - mutation RestoreBackup($backup: Upload!) { - restoreBackup(input: { backup: $backup }) { - id - status { - mangaProgress - state - totalManga - } - } - } -`; - -export const GET_RESTORE_STATUS = ` - query GetRestoreStatus($id: String!) { - restoreStatus(id: $id) { - mangaProgress - state - totalManga - } - } -`; - -export const VALIDATE_BACKUP = ` - query ValidateBackup($backup: Upload!) { - validateBackup(input: { backup: $backup }) { - missingSources { - id - name - } - missingTrackers { - name - } - } - } -`; - -export const LIBRARY_UPDATE_STATUS = ` - query LibraryUpdateStatus { - libraryUpdateStatus { - jobsInfo { - isRunning - finishedJobs - totalJobs - skippedMangasCount - } - mangaUpdates { - status - manga { - id - title - thumbnailUrl - unreadCount - } - } - } - } -`; diff --git a/src/lib/types.ts b/src/lib/types.ts deleted file mode 100644 index 8a57ff3..0000000 --- a/src/lib/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -export interface Category { - id: number; - name: string; - order: number; - default: boolean; - includeInUpdate: string; - includeInDownload: string; - mangas?: { - nodes: Manga[]; - }; -} - -export interface Manga { - id: number; - title: string; - thumbnailUrl: string; - inLibrary: boolean; - downloadCount?: number; - unreadCount?: number; - chapterCount?: number; - description?: string | null; - status?: string | null; - author?: string | null; - artist?: string | null; - genre?: string[]; - realUrl?: string | null; - source?: { - id: string; - name: string; - displayName: string; - } | null; -} - -export interface Chapter { - id: number; - name: string; - chapterNumber: number; - sourceOrder: number; - isRead: boolean; - isDownloaded: boolean; - isBookmarked: boolean; - pageCount: number; - mangaId: number; - uploadDate?: string | null; - realUrl?: string | null; - lastPageRead?: number; - scanlator?: string | null; -} - -export interface MangaDetail extends Manga { - description: string | null; - author: string | null; - artist: string | null; - status: string | null; - genre: string[]; -} - -export interface Source { - id: string; - name: string; - lang: string; - displayName: string; - iconUrl: string; - isNsfw: boolean; -} - -export interface Extension { - apkName: string; - pkgName: string; - name: string; - lang: string; - versionName: string; - isInstalled: boolean; - isObsolete: boolean; - hasUpdate: boolean; - iconUrl: string; -} - -export interface DownloadQueueItem { - progress: number; - state: "QUEUED" | "DOWNLOADING" | "FINISHED" | "ERROR"; - chapter: { - id: number; - name: string; - mangaId: number; - pageCount: number; - manga: { - id: number; - title: string; - thumbnailUrl: string; - } | null; - }; -} - -export interface DownloadStatus { - state: "STARTED" | "STOPPED"; - queue: DownloadQueueItem[]; -} - -export interface Connection { - nodes: T[]; -} - -export interface TrackerStatus { - value: number; - name: string; -} - -export interface Tracker { - id: number; - name: string; - icon: string; - isLoggedIn: boolean; - authUrl: string | null; - supportsPrivateTracking: boolean; - scores: string[]; - statuses: TrackerStatus[]; -} - -export interface TrackRecord { - id: number; - trackerId: number; - remoteId: string; - title: string; - status: number; - score: number; - displayScore: string; - lastChapterRead: number; - totalChapters: number; - remoteUrl: string | null; - startDate: string | null; - finishDate: string | null; - private: boolean; -} - -export interface TrackSearch { - id: number; - trackerId: number; - remoteId: string; - title: string; - coverUrl: string | null; - summary: string | null; - publishingStatus: string | null; - publishingType: string | null; - startDate: string | null; - totalChapters: number; - trackingUrl: string | null; -} diff --git a/src/main.ts b/src/main.ts index 5826b29..1dbe5b5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { mount } from "svelte"; import App from "./App.svelte"; -import "./styles/global.css"; +import "./design/tokens/index.css"; +import "./design/base/index.css"; -mount(App, { target: document.getElementById("app")! }); +mount(App, { target: document.getElementById("app")! }); \ No newline at end of file diff --git a/src/routes.ts b/src/routes.ts deleted file mode 100644 index b60372f..0000000 --- a/src/routes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Library from "./components/pages/Library.svelte"; -import Search from "./components/pages/Search.svelte"; -import History from "./components/pages/History.svelte"; -import Explore from "./components/pages/Explore.svelte"; -import Downloads from "./components/pages/Downloads.svelte"; -import Extensions from "./components/pages/Extensions.svelte"; - -export default { - "/": Library, - "/search": Search, - "/history": History, - "/explore": Explore, - "/downloads": Downloads, - "/extensions": Extensions, -}; diff --git a/src/shared/chrome/AuthGate.svelte b/src/shared/chrome/AuthGate.svelte new file mode 100644 index 0000000..2279239 --- /dev/null +++ b/src/shared/chrome/AuthGate.svelte @@ -0,0 +1,85 @@ + + +{#if boot.unsupportedMode} +
+
+ +

moku

+ { + store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : + store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth" + } +

{store.settings.serverUrl || "localhost:4567"}

+

+ { + store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : + store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode" + } is not supported. Switch your server to Basic Auth and update Settings → Security. +

+ +
+
+{:else if boot.loginRequired} +
+
+ +

moku

+ Basic Auth +

{store.settings.serverUrl || "localhost:4567"}

+ {#if boot.loginError} +

{boot.loginError}

+ {/if} +
+ e.key === "Enter" && handleLogin()} /> + e.key === "Enter" && handleLogin()} /> +
+ + +
+
+{/if} + + diff --git a/src/components/chrome/Layout.svelte b/src/shared/chrome/Layout.svelte similarity index 53% rename from src/components/chrome/Layout.svelte rename to src/shared/chrome/Layout.svelte index 5e55b5d..33bc5d2 100644 --- a/src/components/chrome/Layout.svelte +++ b/src/shared/chrome/Layout.svelte @@ -1,14 +1,15 @@
@@ -17,6 +18,8 @@
{#if store.activeManga} + {:else if store.genreFilter} + {:else if store.navPage === "home"} {:else if store.navPage === "library"} @@ -41,8 +44,7 @@ + \ No newline at end of file diff --git a/src/components/chrome/RecentActivity.svelte b/src/shared/chrome/RecentActivity.svelte similarity index 77% rename from src/components/chrome/RecentActivity.svelte rename to src/shared/chrome/RecentActivity.svelte index 98ab4b7..728ecd4 100644 --- a/src/components/chrome/RecentActivity.svelte +++ b/src/shared/chrome/RecentActivity.svelte @@ -1,38 +1,13 @@
-
History
@@ -223,16 +194,12 @@ {/each}
{/if} -
+ \ No newline at end of file diff --git a/src/components/chrome/TitleBar.svelte b/src/shared/chrome/TitleBar.svelte similarity index 61% rename from src/components/chrome/TitleBar.svelte rename to src/shared/chrome/TitleBar.svelte index ce481c9..0ae9910 100644 --- a/src/components/chrome/TitleBar.svelte +++ b/src/shared/chrome/TitleBar.svelte @@ -3,10 +3,10 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { platform } from "@tauri-apps/plugin-os"; - const win = getCurrentWindow(); - const os = platform(); - const isMac = os === "macos"; - const isWindows = os === "windows"; + const win = getCurrentWindow(); + const os = platform(); + const isMac = os === "macos"; + const isWindows = os === "windows"; let isFullscreen = $state(false); @@ -26,14 +26,10 @@ {#if !isMac}
+ + + +
+ + + {#if folderOpen} +
+ {#if catsLoading} +

Loading…

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

No folders yet

+ {/if} + {#each allCategories as cat} + {@const isIn = mangaCategories.some((c) => c.id === cat.id)} + + {/each} +
+ {#if creatingFolder} +
+ { + if (e.key === "Enter") handleFolderCreate(); + if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } + }} + use:focusAction + /> + +
+ {:else} + + {/if} +
+ {/if} +
+ + +
+
+ + +
+
+
+

{displayManga?.title}

+ {#if loadingDetail} + + {:else if displayManga?.author || displayManga?.artist} + + {/if} +
+ +
+ +
+ {#if fetchError} +
{fetchError}
+ {/if} + + + {#if loadingDetail} +
+
+
+
+ {:else} +
+ {#if statusLabel} + {statusLabel} + {/if} + {#if displayManga?.source} + {displayManga.source.displayName} + {/if} + {#if inLibrary} + In Library + {/if} + {#if !loadingChapters && unreadCount > 0} + {unreadCount} unread + {/if} + {#if !loadingChapters && bookmarkCount > 0} + {bookmarkCount} bookmarked + {/if} +
+ {/if} + + +
+ {#if loadingChapters} +
+ + Loading chapters… +
+ {:else if totalCount > 0} +
+ + {totalCount} {totalCount === 1 ? "chapter" : "chapters"} + {readCount > 0 ? ` · ${readCount} read` : ""} + {unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""} + {downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""} + + {#if unreadCount > 0} + + {/if} +
+ {#if readCount > 0} +
+
+
+ {/if} + {#if continueChapter} + + {/if} + {:else if !loadingDetail} + No chapters in local library + {/if} +
+ + + {#if loadingDetail} +
+
+
+
+
+ {:else if displayManga?.description} +
+

{displayManga.description}

+ {#if displayManga.description.length > 220} + + {/if} +
+ {/if} + + + {#if !loadingDetail && displayManga?.genre?.length} +
+ {#each displayManga.genre as g} + + {/each} +
+ {/if} + + + {#if !loadingDetail} +
+
+
+
+ Status + {statusLabel ?? "N/A"} +
+
+ Source + {displayManga?.source?.displayName ?? "N/A"} +
+
+ Link + {#if displayManga?.realUrl} + + Open + + {:else} + N/A + {/if} +
+
+
+
+ Author + {displayManga?.author ?? "N/A"} +
+
+ Artist + + {displayManga?.artist && displayManga.artist !== displayManga.author + ? displayManga.artist + : (displayManga?.author ?? "N/A")} + +
+
+ Scanlator + + {!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"} + +
+
+
+
+ {/if} +
+
+ +
+
+{/if} + + +{#if linkPickerOpen} + +{/if} + + + + \ No newline at end of file diff --git a/src/components/shared/SourceBrowse.svelte b/src/shared/manga/SourceBrowse.svelte similarity index 94% rename from src/components/shared/SourceBrowse.svelte rename to src/shared/manga/SourceBrowse.svelte index dcf8276..34f11ba 100644 --- a/src/components/shared/SourceBrowse.svelte +++ b/src/shared/manga/SourceBrowse.svelte @@ -1,11 +1,13 @@ -
{@render children()}
-
@@ -47,14 +39,8 @@ transition: filter ease-out 400ms; } - /* Zone divs sit above the card content */ - .hover-3d > :nth-child(n + 2) { - isolation: isolate; - z-index: 1; - scale: 1.2; - } + .hover-3d > :nth-child(n + 2) { isolation: isolate; z-index: 1; scale: 1.2; } - /* 3×3 grid positions for the 8 zones */ .hover-3d > :nth-child(2) { grid-area: 1/1/2/2; } .hover-3d > :nth-child(3) { grid-area: 1/2/2/3; } .hover-3d > :nth-child(4) { grid-area: 1/3/2/4; } @@ -64,50 +50,28 @@ .hover-3d > :nth-child(8) { grid-area: 3/2/4/3; } .hover-3d > :nth-child(9) { grid-area: 3/3/4/4; } - /* The card itself */ .hover-3d-content { - grid-area: 1/1/4/4; - overflow: hidden; - border-radius: inherit; - position: relative; + grid-area: 1/1/4/4; overflow: hidden; border-radius: inherit; position: relative; transform: rotate3d(var(--transform), 0, 10deg); transition: transform var(--ease-out) 500ms, scale var(--ease-out) 500ms, outline-color ease-out 500ms; - outline: 0.5px solid transparent; - outline-offset: -1px; + outline: 0.5px solid transparent; outline-offset: -1px; } - /* Shine overlay */ .hover-3d-content::before { - content: ""; - pointer-events: none; - position: absolute; - inset: 0; - z-index: 1; - opacity: 0; - filter: blur(0.75rem); + content: ""; pointer-events: none; position: absolute; inset: 0; z-index: 1; + opacity: 0; filter: blur(0.75rem); background-image: radial-gradient(circle at 50%, rgba(255,255,255,0.18) 10%, transparent 50%); translate: var(--shine); - transition: - translate ease-out 400ms, - opacity ease-out 400ms; + transition: translate ease-out 400ms, opacity ease-out 400ms; } - /* On hover: snappier ease, scale up, show shine + outline */ - .hover-3d:hover { - --ease-out: var(--ease-hover); - } - .hover-3d:hover > .hover-3d-content { - scale: 1.05; - outline-color: rgba(255,255,255,0.07); - } - .hover-3d:hover > .hover-3d-content::before { - opacity: 1; - } + .hover-3d:hover { --ease-out: var(--ease-hover); } + .hover-3d:hover > .hover-3d-content { scale: 1.05; outline-color: rgba(255,255,255,0.07); } + .hover-3d:hover > .hover-3d-content::before { opacity: 1; } - /* Zone → rotate3d + shine + shadow mappings */ .hover-3d:has(> :nth-child(2):hover) { --transform: -1, 1; --shine: 0% 0%; --shadow: -0.5rem -0.5rem; } .hover-3d:has(> :nth-child(3):hover) { --transform: -1, 0; --shine: 100% 0%; --shadow: 0rem -0.5rem; } .hover-3d:has(> :nth-child(4):hover) { --transform: -1, -1; --shine: 200% 0%; --shadow: 0.5rem -0.5rem; } diff --git a/src/components/shared/Thumbnail.svelte b/src/shared/manga/Thumbnail.svelte similarity index 58% rename from src/components/shared/Thumbnail.svelte rename to src/shared/manga/Thumbnail.svelte index 85baa34..38640c2 100644 --- a/src/components/shared/Thumbnail.svelte +++ b/src/shared/manga/Thumbnail.svelte @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/src/components/shared/ContextMenu.svelte b/src/shared/ui/ContextMenu.svelte similarity index 84% rename from src/components/shared/ContextMenu.svelte rename to src/shared/ui/ContextMenu.svelte index 9753c92..c1f9592 100644 --- a/src/components/shared/ContextMenu.svelte +++ b/src/shared/ui/ContextMenu.svelte @@ -76,7 +76,8 @@ }); -