diff --git a/_old/App.svelte b/_old/App.svelte deleted file mode 100644 index a8f8ec8..0000000 --- a/_old/App.svelte +++ /dev/null @@ -1,364 +0,0 @@ - - -{#if devSplash} - setTimeout(() => devSplash = false, 340)} /> - -{:else if !appReady && !boot.loginRequired} - { appReady = true; }} - onRetry={retryBoot} - onBypass={() => bypassBoot(() => { appReady = true; })} /> - -{:else if boot.loginRequired} - - { appReady = true; }} /> - -{:else} - {#if idle && !store.activeChapter} - { idle = false; }} /> - {/if} - - {#if boot.sessionExpired} - - { boot.sessionExpired = false; }} /> - {/if} - -
- {#if !store.activeChapter}{/if} -
- {#if store.activeChapter}{:else}{/if} -
- {#if store.settingsOpen}{/if} - {#if themeEditorOpen} - - {/if} - - -
-{/if} - -{#if closeDialogOpen} - -{/if} - - \ No newline at end of file diff --git a/_old/api/client.ts b/_old/api/client.ts deleted file mode 100644 index 1925765..0000000 --- a/_old/api/client.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { store } from "@store/state.svelte"; -import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth"; -import { boot } from "@store/boot.svelte"; -import { getBlobUrl } from "@core/cache/imageCache"; - -const DEFAULT_URL = "http://127.0.0.1:4567"; - -type ReauthResolver = () => void; -let _reauthQueue: ReauthResolver[] = []; - -export function notifyReauthSuccess() { - const queue = _reauthQueue; - _reauthQueue = []; - queue.forEach(resolve => resolve()); -} - -function waitForReauth(): Promise { - return new Promise(resolve => { _reauthQueue.push(resolve); }); -} - -export function getServerUrl(): string { - const url = store.settings.serverUrl; - return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; -} - -export function plainThumbUrl(path: string): string { - if (!path) return ""; - if (path.startsWith("http")) return path; - return `${getServerUrl()}${path}`; -} - -export async function resolveImageUrl(path: string): Promise { - if (!path) return ""; - const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`; - const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode === "NONE") return url; - return getBlobUrl(url); -} - -export const thumbUrl = plainThumbUrl; - -interface GQLResponse { - data: T; - errors?: { message: string }[]; -} - -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; } - const timer = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { - clearTimeout(timer); - reject(new DOMException("Aborted", "AbortError")); - }, { once: true }); - }); -} - -async function fetchWithRetry( - url: string, - init: RequestInit, - signal?: AbortSignal, - 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 { - const res = await fetchAuthenticated(url, init, signal, boot.skipped); - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - return res; - } catch (e: any) { - if (e?.authRequired) throw e; - if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError"); - if (e instanceof AuthRequiredError) throw e; - if (i === retries - 1) throw e; - await abortableSleep(delayMs * Math.pow(1.5, i), signal); - } - } - throw new Error("unreachable"); -} - -export async function fetchImage( - path: string, - signal?: AbortSignal, -): Promise<{ src: string; revoke: () => void }> { - if (!path) return { src: "", revoke: () => {} }; - - const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`; - const mode = store.settings.serverAuthMode ?? "NONE"; - - if (mode === "NONE") return { src: url, revoke: () => {} }; - - const res = await fetchWithRetry(url, { method: "GET" }, signal); - if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`); - - const blob = await res.blob(); - const src = URL.createObjectURL(blob); - return { src, revoke: () => URL.revokeObjectURL(src) }; -} - -export async function gql( - query: string, - variables?: Record, - signal?: AbortSignal, -): Promise { - const tryRefreshAndRetry = async (): Promise => { - const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode !== "UI_LOGIN" || boot.skipped) return null; - const refreshed = await refreshUiAccessToken(true); - if (!refreshed) return null; - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - return attempt(); - }; - - const attempt = async (): Promise => { - 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) { - if (res.status === 401 || res.status === 403) { - const retried = await tryRefreshAndRetry(); - if (retried) return retried; - } - 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) { - const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message)); - if (isAuthError && !boot.skipped) { - const retried = await tryRefreshAndRetry(); - if (retried) return retried; - - boot.sessionExpired = true; - boot.loginRequired = true; - boot.loginUser = store.settings.serverAuthUser ?? ""; - await waitForReauth(); - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - return attempt(); - } - throw new Error(json.errors[0].message); - } - return json.data; - }; - - return attempt(); -} \ No newline at end of file diff --git a/_old/api/index.ts b/_old/api/index.ts deleted file mode 100644 index 2552de0..0000000 --- a/_old/api/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/_old/api/mutations/chapters.ts b/_old/api/mutations/chapters.ts deleted file mode 100644 index 2418850..0000000 --- a/_old/api/mutations/chapters.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 lastReadAt 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 } - } - } -`; - -export const SET_CHAPTER_META = ` - mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) { - setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) { - meta { key value } - } - } -`; - -export const DELETE_CHAPTER_META = ` - mutation DeleteChapterMeta($chapterId: Int!, $key: String!) { - deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) { - meta { key value } - } - } -`; \ No newline at end of file diff --git a/_old/api/mutations/downloads.ts b/_old/api/mutations/downloads.ts deleted file mode 100644 index faeb49f..0000000 --- a/_old/api/mutations/downloads.ts +++ /dev/null @@ -1,99 +0,0 @@ -const QUEUE_FRAGMENT = ` - state - queue { - progress state tries - 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 DEQUEUE_CHAPTERS_DOWNLOAD = ` - mutation DequeueChaptersDownload($chapterIds: [Int!]!) { - dequeueChapterDownloads(input: { ids: $chapterIds }) { - downloadStatus { ${QUEUE_FRAGMENT} } - } - } -`; - -export const REORDER_DOWNLOAD = ` - mutation ReorderDownload($chapterId: Int!, $to: Int!) { - reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) { - downloadStatus { ${QUEUE_FRAGMENT} } - } - } -`; - -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 } - } - } -`; \ No newline at end of file diff --git a/_old/api/mutations/extensions.ts b/_old/api/mutations/extensions.ts deleted file mode 100644 index 8e21a01..0000000 --- a/_old/api/mutations/extensions.ts +++ /dev/null @@ -1,215 +0,0 @@ -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 UPDATE_EXTENSIONS = ` - mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) { - updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) { - extensions { 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 UPDATE_SOURCE_PREFERENCE = ` - mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) { - updateSourcePreference(input: { source: $source, change: $change }) { - source { id displayName } - } - } -`; - -export const SET_SOURCE_METAS = ` - mutation SetSourceMetas($input: SetSourceMetasInput!) { - setSourceMetas(input: $input) { - metas { sourceId key value } - } - } -`; - -export const DELETE_SOURCE_METAS = ` - mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) { - deleteSourceMetas(input: $input) { - metas { sourceId key value } - } - } -`; - -export const UPDATE_SOURCE_METADATA = ` - mutation UpdateSourceMetadata( - $preUpdateDeleteInput: DeleteSourceMetasInput! - $hasPreUpdateDeletions: Boolean! - $updateInput: SetSourceMetasInput! - $hasUpdates: Boolean! - $postUpdateDeleteInput: DeleteSourceMetasInput! - $hasPostUpdateDeletions: Boolean! - $migrateInput: SetSourceMetasInput! - $isMigration: Boolean! - ) { - preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) { - metas { sourceId key value } - } - updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) { - metas { sourceId key value } - } - postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) { - metas { sourceId key value } - } - migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) { - metas { sourceId key value } - } - } -`; - -export const SET_SOURCE_META = ` - mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) { - setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) { - meta { key value } - } - } -`; - -export const DELETE_SOURCE_META = ` - mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) { - deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) { - meta { key value } - } - } -`; - -export const SET_CATEGORY_META = ` - mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) { - setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) { - meta { key value } - } - } -`; - -export const DELETE_CATEGORY_META = ` - mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) { - deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) { - meta { key value } - } - } -`; - -export const SET_GLOBAL_META = ` - mutation SetGlobalMeta($key: String!, $value: String!) { - setGlobalMeta(input: { meta: { key: $key, value: $value } }) { - meta { key value } - } - } -`; - -export const DELETE_GLOBAL_META = ` - mutation DeleteGlobalMeta($key: String!) { - deleteGlobalMeta(input: { key: $key }) { - meta { key value } - } - } -`; - -export const CLEAR_CACHED_IMAGES = ` - mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) { - clearCachedImages(input: { - cachedPages: $cachedPages - cachedThumbnails: $cachedThumbnails - downloadedThumbnails: $downloadedThumbnails - }) { - cachedPages cachedThumbnails downloadedThumbnails - } - } -`; - -export const RESET_SETTINGS = ` - mutation ResetSettings { - resetSettings(input: {}) { - settings { extensionRepos } - } - } -`; - -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 - } - } - } -`; \ No newline at end of file diff --git a/_old/api/mutations/index.ts b/_old/api/mutations/index.ts deleted file mode 100644 index d075b68..0000000 --- a/_old/api/mutations/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./manga"; -export * from "./chapters"; -export * from "./downloads"; -export * from "./extensions"; -export * from "./tracking"; \ No newline at end of file diff --git a/_old/api/mutations/manga.ts b/_old/api/mutations/manga.ts deleted file mode 100644 index 75c234a..0000000 --- a/_old/api/mutations/manga.ts +++ /dev/null @@ -1,153 +0,0 @@ -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 UPDATE_MANGAS_CATEGORIES = ` - mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) { - updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) { - mangas { 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 UPDATE_CATEGORIES = ` - mutation UpdateCategories($ids: [Int!]!, $patch: UpdateCategoryPatchInput!) { - updateCategories(input: { ids: $ids, patch: $patch }) { - categories { id name order default includeInUpdate includeInDownload } - } - } -`; - -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_CATEGORY_MANGA = ` - mutation UpdateCategoryManga($categoryId: Int!) { - updateCategoryManga(input: { categoryId: $categoryId }) { - updateStatus { - jobsInfo { isRunning finishedJobs totalJobs } - } - } - } -`; - -export const UPDATE_LIBRARY = ` - mutation UpdateLibrary { - updateLibrary(input: {}) { - updateStatus { - jobsInfo { isRunning finishedJobs totalJobs } - } - } - } -`; - -export const UPDATE_LIBRARY_MANGA = ` - mutation UpdateLibraryManga($mangaId: Int!) { - updateLibraryManga(input: { mangaId: $mangaId }) { - updateStatus { - jobsInfo { isRunning finishedJobs totalJobs } - } - } - } -`; - -export const UPDATE_STOP = ` - mutation UpdateStop { - updateStop(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 } - } - } -`; - -export const SET_MANGA_META = ` - mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) { - setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) { - meta { key value } - } - } -`; - -export const DELETE_MANGA_META = ` - mutation DeleteMangaMeta($mangaId: Int!, $key: String!) { - deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) { - meta { key value } - } - } -`; \ No newline at end of file diff --git a/_old/api/mutations/mutations.md b/_old/api/mutations/mutations.md deleted file mode 100644 index 9bbbce5..0000000 --- a/_old/api/mutations/mutations.md +++ /dev/null @@ -1,130 +0,0 @@ -# Mutations - -## Manga (`mutations/manga.ts`) - -| Mutation | Variables | Description | -|----------|-----------|-------------| -| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source | -| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership | -| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga | -| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories | -| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories | -| `CREATE_CATEGORY` | `name: String!` | Create a new category | -| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name | -| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories | -| `DELETE_CATEGORY` | `id: Int!` | Delete a category | -| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position | -| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category | -| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh | -| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga | -| `UPDATE_STOP` | — | Stop the currently running library update job | -| `CREATE_BACKUP` | — | Create a backup and return its download URL | -| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status | -| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga | -| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga | - ---- - -## Chapters (`mutations/chapters.ts`) - -| Mutation | Variables | Description | -|----------|-----------|-------------| -| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source | -| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter | -| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread | -| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread | -| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read | -| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files | -| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter | -| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter | - ---- - -## Downloads (`mutations/downloads.ts`) - -| Mutation | Variables | Description | -|----------|-----------|-------------| -| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue | -| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue | -| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue | -| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue | -| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position | -| `START_DOWNLOADER` | — | Start the downloader | -| `STOP_DOWNLOADER` | — | Stop the downloader | -| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue | -| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination | -| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path | -| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path | - ---- - -## Extensions (`mutations/extensions.ts`) - -| Mutation | Variables | Description | -|----------|-----------|-------------| -| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos | -| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension | -| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions | -| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL | -| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value | -| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source | -| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source | -| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category | -| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category | -| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry | -| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry | -| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails | -| `RESET_SETTINGS` | — | Reset all server settings to defaults | -| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status | -| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle | -| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs | -| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials | -| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings | -| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration | - ---- - -## Tracking (`mutations/tracking.ts`) - -| Mutation | Variables | Description | -|----------|-----------|-------------| -| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry | -| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates | -| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record | -| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker | -| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga | -| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker | -| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password | -| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker | -| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account | -| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account | -| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter | -| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter | -| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens | -| `REFRESH_TOKEN` | — | Refresh the current access token | - ---- - -## New in Preview - -Mutations now available and not yet wired to any feature in Moku: - -| Mutation | Potential Feature | -|----------|-------------------| -| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once | -| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once | -| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga | -| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update | -| `UPDATE_STOP` | Cancel button for library update jobs | -| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page | -| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences | -| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. | -| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category | -| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes | -| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam | -| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) | -| `RESET_SETTINGS` | Settings page — factory reset button | -| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress | -| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel | -| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account | -| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close | \ No newline at end of file diff --git a/_old/api/mutations/tracking.ts b/_old/api/mutations/tracking.ts deleted file mode 100644 index 5d9effb..0000000 --- a/_old/api/mutations/tracking.ts +++ /dev/null @@ -1,127 +0,0 @@ -const TRACK_RECORD_FRAGMENT = ` - id trackerId remoteId title status score displayScore - lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId -`; - -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 libraryId - } - } - } -`; - -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 libraryId - } - } - } -`; - -export const TRACK_PROGRESS = ` - mutation TrackProgress($mangaId: Int!) { - trackProgress(input: { mangaId: $mangaId }) { - trackRecords { - id trackerId lastChapterRead status - } - } - } -`; - -export const LOGIN_TRACKER_OAUTH = ` - mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) { - loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) { - isLoggedIn - tracker { id name isLoggedIn isTokenExpired 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 isTokenExpired authUrl } - } - } -`; - -export const LOGOUT_TRACKER = ` - mutation LogoutTracker($trackerId: Int!) { - logoutTracker(input: { trackerId: $trackerId }) { - tracker { id name isLoggedIn isTokenExpired authUrl } - } - } -`; - -export const CONNECT_KOSYNC = ` - mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) { - connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) { - isConnected - } - } -`; - -export const LOGOUT_KOSYNC = ` - mutation LogoutKoSync { - logoutKoSyncAccount(input: {}) { - isConnected - } - } -`; - -export const PULL_KOSYNC_PROGRESS = ` - mutation PullKoSyncProgress($chapterId: Int!) { - pullKoSyncProgress(input: { chapterId: $chapterId }) { - chapter { id lastPageRead isRead } - } - } -`; - -export const PUSH_KOSYNC_PROGRESS = ` - mutation PushKoSyncProgress($chapterId: Int!) { - pushKoSyncProgress(input: { chapterId: $chapterId }) { - chapter { id lastPageRead isRead } - } - } -`; - -export const LOGIN_USER = ` - mutation Login($username: String!, $password: String!, $clientMutationId: String) { - login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) { - accessToken - refreshToken - clientMutationId - } - } -`; - -export const REFRESH_TOKEN = ` - mutation RefreshToken($refreshToken: String!, $clientMutationId: String) { - refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) { - accessToken - clientMutationId - } - } -`; \ No newline at end of file diff --git a/_old/api/queries/chapters.ts b/_old/api/queries/chapters.ts deleted file mode 100644 index 91a4920..0000000 --- a/_old/api/queries/chapters.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const GET_RECENTLY_UPDATED = ` - query GetRecentlyUpdated { - chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) { - nodes { - id - name - chapterNumber - sourceOrder - isRead - lastPageRead - mangaId - fetchedAt - manga { id title thumbnailUrl inLibrary } - } - } - } -`; - -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 lastReadAt scanlator - } - } - } -`; diff --git a/_old/api/queries/downloads.ts b/_old/api/queries/downloads.ts deleted file mode 100644 index 96ed7b7..0000000 --- a/_old/api/queries/downloads.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const GET_DOWNLOAD_STATUS = ` - query GetDownloadStatus { - downloadStatus { - state - queue { - progress state tries - chapter { - id name pageCount mangaId - manga { id title thumbnailUrl } - } - } - } - } -`; \ No newline at end of file diff --git a/_old/api/queries/extensions.ts b/_old/api/queries/extensions.ts deleted file mode 100644 index 6faa53d..0000000 --- a/_old/api/queries/extensions.ts +++ /dev/null @@ -1,117 +0,0 @@ -export const GET_LOCAL_MANGA = ` - query GetLocalManga { - mangas(condition: { sourceId: "0" }) { - nodes { id title thumbnailUrl inLibrary } - } - } -`; - -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 - isConfigurable supportsLatest - extension { pkgName } - } - } - } -`; - -export const GET_SOURCE_SETTINGS = ` - query GetSourceSettings($id: LongString!) { - source(id: $id) { - id - displayName - preferences { - ... on CheckBoxPreference { - type: __typename - CheckBoxTitle: title - CheckBoxSummary: summary - CheckBoxDefault: default - CheckBoxCurrentValue: currentValue - key - } - ... on SwitchPreference { - type: __typename - SwitchPreferenceTitle: title - SwitchPreferenceSummary: summary - SwitchPreferenceDefault: default - SwitchPreferenceCurrentValue: currentValue - key - } - ... on ListPreference { - type: __typename - ListPreferenceTitle: title - ListPreferenceSummary: summary - ListPreferenceDefault: default - ListPreferenceCurrentValue: currentValue - entries - entryValues - key - } - ... on EditTextPreference { - type: __typename - EditTextPreferenceTitle: title - EditTextPreferenceSummary: summary - EditTextPreferenceDefault: default - EditTextPreferenceCurrentValue: currentValue - dialogTitle - dialogMessage - key - } - ... on MultiSelectListPreference { - type: __typename - MultiSelectListPreferenceTitle: title - MultiSelectListPreferenceSummary: summary - MultiSelectListPreferenceDefault: default - MultiSelectListPreferenceCurrentValue: currentValue - entries - entryValues - key - } - } - } - } -`; - -export const GET_MIGRATABLE_SOURCES = ` - query GetMigratableSources { - mangas(condition: { inLibrary: true }) { - nodes { - sourceId - source { - id name lang displayName iconUrl isNsfw isConfigurable supportsLatest - } - } - } - } -`; - -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 - } - } -`; \ No newline at end of file diff --git a/_old/api/queries/index.ts b/_old/api/queries/index.ts deleted file mode 100644 index de739e0..0000000 --- a/_old/api/queries/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./manga"; -export * from "./chapters"; -export * from "./downloads"; -export * from "./extensions"; -export * from "./tracking"; -export * from "./updater"; -export * from "./meta"; \ No newline at end of file diff --git a/_old/api/queries/manga.ts b/_old/api/queries/manga.ts deleted file mode 100644 index ac89539..0000000 --- a/_old/api/queries/manga.ts +++ /dev/null @@ -1,110 +0,0 @@ -export const GET_LIBRARY = ` - query GetLibrary { - mangas(condition: { inLibrary: true }) { - nodes { - id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount - description status author artist genre - inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched - source { id name displayName } - chapters { totalCount } - latestFetchedChapter { id uploadDate } - latestUploadedChapter { id uploadDate } - lastReadChapter { id chapterNumber } - firstUnreadChapter { id chapterNumber } - } - } - } -`; - -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 - inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy - source { id name displayName } - lastReadChapter { id chapterNumber lastPageRead } - firstUnreadChapter { id chapterNumber } - highestNumberedChapter { id chapterNumber } - } - } -`; - -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 skippedCategoriesCount - } - mangaUpdates { - status - manga { id title thumbnailUrl unreadCount } - } - } - lastUpdateTimestamp { - timestamp - } - } -`; - -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 - } - } -`; \ No newline at end of file diff --git a/_old/api/queries/meta.ts b/_old/api/queries/meta.ts deleted file mode 100644 index 3593a6f..0000000 --- a/_old/api/queries/meta.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const GET_META = ` - query GetMeta($key: String!) { - meta(key: $key) { - key value - } - } -`; - -export const GET_METAS = ` - query GetMetas { - metas { - nodes { key value } - } - } -`; \ No newline at end of file diff --git a/_old/api/queries/queries.md b/_old/api/queries/queries.md deleted file mode 100644 index 7c441a8..0000000 --- a/_old/api/queries/queries.md +++ /dev/null @@ -1,117 +0,0 @@ -# Queries - -## Manga (`queries/manga.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) | -| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count | -| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` | -| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) | -| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats | -| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings | -| `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing | -| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` | -| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers | -| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` | - ---- - -## Chapters (`queries/chapters.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info | -| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator | - ---- - -## Downloads (`queries/downloads.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info | - ---- - -## Extensions (`queries/extensions.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) | -| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata | -| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` | -| `GET_SETTINGS` | — | `extensionRepos` from settings | -| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings | - ---- - -## Tracking (`queries/tracking.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses | -| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag | -| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info | -| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` | -| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga | - ---- - -## Updater (`queries/updater.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links | -| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp | -| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL | -| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag | -| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block | - ---- - -## Meta (`queries/meta.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_META` | `key: String!` | Single server-side key/value meta entry | -| `GET_METAS` | — | All global meta entries as a node list | - ---- - -## KoSync (`queries/kosync.ts`) - -| Query | Variables | Description | -|-------|-----------|-------------| -| `GET_KOSYNC_STATUS` | — | KOReader sync connection status | - ---- - -## New in Preview - -Queries and fields now available but not yet wired to any feature in Moku: - -| Query / Field | Potential Feature | -|---------------|-------------------| -| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord | -| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel | -| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge | -| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge | -| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings | -| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage | -| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state | -| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker | -| `category` (single by id) | Direct category detail without fetching all categories | -| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list | -| `source` (single by id) | Source detail page — preferences, filters, browse | -| `tracker` (single by id) | Individual tracker detail — statuses, records | -| `trackRecord` (single by id) | Direct track record lookup for deep linking | -| `lastUpdateTimestamp` | Stale data detection — poll before refetching library | -| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers | -| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days | -| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched | -| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable | -| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab | -| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it | -| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them | -| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails | \ No newline at end of file diff --git a/_old/api/queries/tracking.ts b/_old/api/queries/tracking.ts deleted file mode 100644 index 3374d25..0000000 --- a/_old/api/queries/tracking.ts +++ /dev/null @@ -1,71 +0,0 @@ -export const GET_TRACKERS = ` - query GetTrackers { - trackers { - nodes { - id name icon isLoggedIn isTokenExpired authUrl - supportsPrivateTracking supportsReadingDates supportsTrackDeletion - 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 libraryId - } - } - } - } -`; - -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 isTokenExpired scores - statuses { value name } - trackRecords { - nodes { - id trackerId title status displayScore lastChapterRead - totalChapters remoteUrl private libraryId - 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 } - } - } - } - } - } -`; \ No newline at end of file diff --git a/_old/api/queries/updater.ts b/_old/api/queries/updater.ts deleted file mode 100644 index 6b02105..0000000 --- a/_old/api/queries/updater.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const GET_ABOUT_SERVER = ` - query GetAboutServer { - aboutServer { - name version buildType buildTime github discord - } - } -`; - -export const GET_ABOUT_WEBUI = ` - query GetAboutWebUI { - aboutWebUI { - channel tag updateTimestamp - } - } -`; - -export const CHECK_FOR_SERVER_UPDATES = ` - query CheckForServerUpdates { - checkForServerUpdates { - channel tag url - } - } -`; \ No newline at end of file diff --git a/_old/core/actions/index.ts b/_old/core/actions/index.ts deleted file mode 100644 index 5d37ceb..0000000 --- a/_old/core/actions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./selectPortal"; \ No newline at end of file diff --git a/_old/core/actions/selectPortal.ts b/_old/core/actions/selectPortal.ts deleted file mode 100644 index d6086df..0000000 --- a/_old/core/actions/selectPortal.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Attachment } from "svelte/attachments"; - -export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment { - return (menuEl: HTMLElement) => { - function position() { - const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1; - const r = triggerEl.getBoundingClientRect(); - - const top = r.bottom / zoom + 4; - const right = r.right / zoom; - const width = menuEl.offsetWidth; - const left = Math.max(8, right - width); - - menuEl.style.position = "fixed"; - menuEl.style.top = `${top}px`; - menuEl.style.left = `${left}px`; - } - - menuEl.style.visibility = "hidden"; - document.body.appendChild(menuEl); - triggerEl.__selectMenuEl = menuEl; - - requestAnimationFrame(() => { - position(); - menuEl.style.visibility = ""; - }); - - window.addEventListener("scroll", position, true); - window.addEventListener("resize", position); - - return () => { - window.removeEventListener("scroll", position, true); - window.removeEventListener("resize", position); - triggerEl.__selectMenuEl = null; - menuEl.remove(); - }; - }; -} \ No newline at end of file diff --git a/_old/core/algorithms/filter.ts b/_old/core/algorithms/filter.ts deleted file mode 100644 index f5ecd05..0000000 --- a/_old/core/algorithms/filter.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util"; - -export function buildFilter(...predicates: ((item: T) => boolean)[]): (item: T) => boolean { - return (item) => predicates.every((p) => p(item)); -} \ No newline at end of file diff --git a/_old/core/algorithms/index.ts b/_old/core/algorithms/index.ts deleted file mode 100644 index 95f4d46..0000000 --- a/_old/core/algorithms/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './sort'; -export * from './filter'; -export * from './paginate'; -export * from './search'; -export * from './queue'; diff --git a/_old/core/algorithms/paginate.ts b/_old/core/algorithms/paginate.ts deleted file mode 100644 index 979dab8..0000000 --- a/_old/core/algorithms/paginate.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/_old/core/algorithms/queue.ts b/_old/core/algorithms/queue.ts deleted file mode 100644 index 81a6a8c..0000000 --- a/_old/core/algorithms/queue.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/_old/core/algorithms/search.ts b/_old/core/algorithms/search.ts deleted file mode 100644 index 0c92805..0000000 --- a/_old/core/algorithms/search.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/_old/core/algorithms/sort.ts b/_old/core/algorithms/sort.ts deleted file mode 100644 index d148045..0000000 --- a/_old/core/algorithms/sort.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/_old/core/async/batchRequests.ts b/_old/core/async/batchRequests.ts deleted file mode 100644 index ff49dbc..0000000 --- a/_old/core/async/batchRequests.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 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/_old/core/async/createPaginatedQuery.ts b/_old/core/async/createPaginatedQuery.ts deleted file mode 100644 index 912b282..0000000 --- a/_old/core/async/createPaginatedQuery.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/_old/core/async/fetchWithRetry.ts b/_old/core/async/fetchWithRetry.ts deleted file mode 100644 index f9ad4cc..0000000 --- a/_old/core/async/fetchWithRetry.ts +++ /dev/null @@ -1,31 +0,0 @@ -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/_old/core/async/index.ts b/_old/core/async/index.ts deleted file mode 100644 index a3304b1..0000000 --- a/_old/core/async/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './fetchWithRetry'; -export * from './batchRequests'; -export * from './createPaginatedQuery'; diff --git a/_old/core/auth.ts b/_old/core/auth.ts deleted file mode 100644 index d0b2bae..0000000 --- a/_old/core/auth.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { store, updateSettings } from "@store/state.svelte"; - -export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN"; - -export class AuthRequiredError extends Error { - constructor(msg = "Authentication required") { - super(msg); - this.name = "AuthRequiredError"; - } -} - -const TOKEN_KEY = "moku_access_token"; -const UI_SESSION_KEY = "moku_ui_auth_session"; -const TOKEN_REFRESH_SKEW_MS = 30_000; -const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV); - -interface StoredAccessToken { - base: string; - token: string; -} - -interface StoredUiAuthSession { - base: string; - accessToken: string; - refreshToken?: string; - clientMutationId?: string; - accessExpiresAt?: number | null; - refreshExpiresAt?: number | null; -} - -interface JwtSettings { - jwtAudience?: string | null; - jwtRefreshExpiry?: string | null; - jwtTokenExpiry?: string | null; -} - -export interface UiAuthDebugStatus { - mode: AuthMode; - serverBase: string; - hasSession: boolean; - hasRefreshToken: boolean; - accessExpiresAt: number | null; - refreshExpiresAt: number | null; - accessExpiresInMs: number | null; - refreshExpiresInMs: number | null; - shouldRefreshSoon: boolean; - refreshInFlight: boolean; - skewMs: number; -} - -let _accessToken: string | null = null; -let _accessTokenBase: string | null = null; -let _uiSession: StoredUiAuthSession | null = null; -let _refreshPromise: Promise | null = null; -let _jwtSettingsBase: string | null = null; -let _jwtSettings: JwtSettings | null = null; -let _jwtSettingsFetchedAt = 0; - -function authDebug(event: string, fields?: Record) { - if (!AUTH_DEBUG) return; - if (fields) { - console.debug(`[auth] ${event}`, fields); - return; - } - console.debug(`[auth] ${event}`); -} - -function parseIsoDuration(duration: string): number | null { - try { - const match = duration.match( - /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/ - ); - if (!match) return null; - const [, years, months, days, hours, minutes, seconds] = match; - let ms = 0; - if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000; - if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000; - if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000; - if (hours) ms += parseInt(hours) * 60 * 60 * 1000; - if (minutes) ms += parseInt(minutes) * 60 * 1000; - if (seconds) ms += parseFloat(seconds) * 1000; - return ms; - } catch { - return null; - } -} - -function decodeJwtExpiryMs(token: string): number | null { - try { - const payload = token.split(".")[1]; - if (!payload) return null; - const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="); - const decoded = atob(padded); - const json = JSON.parse(decoded) as { exp?: number }; - return typeof json.exp === "number" ? json.exp * 1000 : null; - } catch { - return null; - } -} - -function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean { - if (!expiresAt || !Number.isFinite(expiresAt)) return false; - return Date.now() >= expiresAt - skewMs; -} - -function withExpiryFromSettings( - accessToken: string, - jwt: JwtSettings | null, -): Pick { - const now = Date.now(); - const accessExpiresAt = - decodeJwtExpiryMs(accessToken) - ?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null); - const refreshExpiresAt = - typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null; - return { accessExpiresAt, refreshExpiresAt }; -} - -async function fetchJwtSettings(base: string): Promise { - const res = await fetchAuthenticated( - `${base}/api/graphql`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: gqlBody( - `query GetJWTSettings { - settings { - jwtAudience - jwtRefreshExpiry - jwtTokenExpiry - } - }`, - ), - }, - timeoutSignal(5000), - ); - - if (!res.ok) { - authDebug("JWT settings fetch failed", { status: res.status }); - return null; - } - - const json = await res.json(); - if (json?.errors?.length) { - authDebug("JWT settings query error", { errors: json.errors }); - return null; - } - - const settings = json?.data?.settings; - if (!settings || typeof settings !== "object") { - authDebug("JWT settings missing or invalid", { settings }); - return null; - } - - authDebug("JWT settings fetched", { - hasAudience: !!settings.jwtAudience, - tokenExpiry: settings.jwtTokenExpiry, - refreshExpiry: settings.jwtRefreshExpiry, - }); - - return { - jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null, - jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null, - jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null, - }; -} - -async function getJwtSettings(force = false): Promise { - const base = getServerBase(); - const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000; - if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings; - - const jwt = await fetchJwtSettings(base); - _jwtSettingsBase = base; - _jwtSettings = jwt; - _jwtSettingsFetchedAt = Date.now(); - return jwt; -} - -export const uiAuth = { - getSession: () => { - const base = getServerBase(); - if (_uiSession && _uiSession.base === base) return _uiSession; - - const stored = readStoredSession(); - if (!stored) return null; - if (stored.base !== base) { - sessionStorage.removeItem(UI_SESSION_KEY); - sessionStorage.removeItem(TOKEN_KEY); - _uiSession = null; - _accessToken = null; - _accessTokenBase = null; - return null; - } - - _uiSession = stored; - _accessToken = stored.accessToken; - _accessTokenBase = stored.base; - return _uiSession; - }, - setSession: (session: Omit) => { - const base = getServerBase(); - _uiSession = { ...session, base }; - _accessToken = session.accessToken; - _accessTokenBase = base; - sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession)); - sessionStorage.removeItem(TOKEN_KEY); - }, - getToken: () => { - const session = uiAuth.getSession(); - if (!session) return null; - - if (isExpired(session.accessExpiresAt, 0)) return null; - - const base = getServerBase(); - if (_accessToken && _accessTokenBase === base) return _accessToken; - const stored = readStoredToken(); - if (!stored) return null; - if (stored.base !== base) { - sessionStorage.removeItem(TOKEN_KEY); - sessionStorage.removeItem(UI_SESSION_KEY); - _accessToken = null; - _accessTokenBase = null; - _uiSession = null; - return null; - } - _accessToken = stored.token; - _accessTokenBase = stored.base; - return _accessToken; - }, - setToken: (t: string) => { - const existing = uiAuth.getSession(); - if (existing?.refreshToken) { - uiAuth.setSession({ - ...existing, - accessToken: t, - ...withExpiryFromSettings(t, _jwtSettings), - }); - return; - } - const base = getServerBase(); - _accessToken = t; - _accessTokenBase = base; - sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t })); - }, - setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => { - uiAuth.setSession({ - accessToken: payload.accessToken, - refreshToken: payload.refreshToken, - clientMutationId: payload.clientMutationId, - ...withExpiryFromSettings(payload.accessToken, jwt), - }); - }, - updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => { - const existing = uiAuth.getSession(); - if (!existing?.refreshToken) { - uiAuth.setToken(payload.accessToken); - return; - } - uiAuth.setSession({ - ...existing, - accessToken: payload.accessToken, - clientMutationId: payload.clientMutationId ?? existing.clientMutationId, - ...withExpiryFromSettings(payload.accessToken, jwt), - refreshToken: existing.refreshToken, - }); - }, - clearToken: () => { - _accessToken = null; - _accessTokenBase = null; - _uiSession = null; - sessionStorage.removeItem(TOKEN_KEY); - sessionStorage.removeItem(UI_SESSION_KEY); - }, -}; - -export const authSession = { - clearTokens() { - _refreshPromise = null; - _jwtSettings = null; - _jwtSettingsBase = null; - _jwtSettingsFetchedAt = 0; - uiAuth.clearToken(); - }, - hasSession(): boolean { - const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode === "UI_LOGIN") return uiAuth.getSession() !== null; - return true; - }, -}; - -function getServerBase(): string { - const url = store.settings.serverUrl; - return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567"; -} - -function readStoredToken(): StoredAccessToken | null { - const session = readStoredSession(); - if (session) return { base: session.base, token: session.accessToken }; - - const raw = sessionStorage.getItem(TOKEN_KEY); - if (raw?.trim()) { - try { - const parsed = JSON.parse(raw); - if (typeof parsed?.base === "string" && typeof parsed?.token === "string") - return { base: parsed.base, token: parsed.token }; - } catch {} - - const migrated = { base: getServerBase(), token: raw.trim() }; - sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated)); - return migrated; - } - - return null; -} - -function readStoredSession(): StoredUiAuthSession | null { - const raw = sessionStorage.getItem(UI_SESSION_KEY); - if (raw?.trim()) { - try { - const parsed = JSON.parse(raw); - if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") { - return { - base: parsed.base, - accessToken: parsed.accessToken, - refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined, - clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined, - accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null, - refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null, - }; - } - } catch {} - } - - const legacy = sessionStorage.getItem(TOKEN_KEY); - if (!legacy?.trim()) return null; - - try { - const parsed = JSON.parse(legacy); - if (typeof parsed?.base === "string" && typeof parsed?.token === "string") { - const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token }; - sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated)); - return migrated; - } - } catch {} - - const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() }; - sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated)); - return migrated; -} - -function timeoutSignal(ms: number): AbortSignal { - const controller = new AbortController(); - setTimeout(() => controller.abort(), ms); - return controller.signal; -} - -function basicHeader(user: string, pass: string): Record { - return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; -} - -function bearerHeader(token: string): Record { - return { Authorization: `Bearer ${token}` }; -} - -function gqlBody(query: string, variables?: Record): string { - return JSON.stringify({ query, ...(variables ? { variables } : {}) }); -} - -export async function fetchAuthenticated( - url: string, - init: RequestInit, - signal?: AbortSignal, - skipped = false, -): Promise { - const mode = store.settings.serverAuthMode ?? "NONE"; - const baseHeaders = (init.headers ?? {}) as Record; - - if (mode === "BASIC_AUTH") { - const user = store.settings.serverAuthUser?.trim() ?? ""; - const pass = store.settings.serverAuthPass?.trim() ?? ""; - return fetch(url, { - ...init, signal, credentials: "omit", - headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) }, - }); - } - - if (mode === "UI_LOGIN") { - const token = await getUIAccessToken(); - if (!token) { - if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders }); - throw new AuthRequiredError(); - } - - let res = await fetch(url, { - ...init, signal, credentials: "omit", - headers: { ...baseHeaders, ...bearerHeader(token) }, - }); - - if (res.status !== 401 || skipped) return res; - - const refreshed = await refreshUiAccessToken(true); - if (!refreshed) return res; - - res = await fetch(url, { - ...init, signal, credentials: "omit", - headers: { ...baseHeaders, ...bearerHeader(refreshed) }, - }); - return res; - } - - return fetch(url, { ...init, signal, credentials: "omit" }); -} - -export async function getUIAccessToken(forceRefresh = false): Promise { - const session = uiAuth.getSession(); - if (!session) return null; - if (forceRefresh || isExpired(session.accessExpiresAt)) { - return refreshUiAccessToken(true); - } - return session.accessToken; -} - -export async function refreshUiAccessToken(force = false): Promise { - const session = uiAuth.getSession(); - if (!session) return null; - if (!session.refreshToken) { - if (force && isExpired(session.accessExpiresAt, 0)) return null; - return session.accessToken; - } - - if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken; - if (isExpired(session.refreshExpiresAt)) { - authDebug("refresh skipped: refresh token expired", { - force, - refreshExpiresAt: session.refreshExpiresAt ?? null, - }); - uiAuth.clearToken(); - return null; - } - - if (_refreshPromise) { - authDebug("refresh joined existing request"); - return _refreshPromise; - } - - authDebug("refresh start", { - force, - accessExpiresAt: session.accessExpiresAt ?? null, - refreshExpiresAt: session.refreshExpiresAt ?? null, - }); - - _refreshPromise = (async () => { - const base = getServerBase(); - const jwt = await getJwtSettings().catch(() => null); - - const res = await fetch(`${base}/api/graphql`, { - method: "POST", - credentials: "omit", - headers: { "Content-Type": "application/json" }, - body: gqlBody( - `mutation RefreshToken($refreshToken: String!, $clientMutationId: String) { - refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) { - accessToken - clientMutationId - } - }`, - { refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined }, - ), - signal: timeoutSignal(5000), - }); - - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - authDebug("refresh rejected by server", { status: res.status }); - uiAuth.clearToken(); - return null; - } - authDebug("refresh failed with HTTP error", { status: res.status }); - throw new Error(`Token refresh failed (${res.status})`); - } - - const json = await res.json(); - const refreshed = json?.data?.refreshToken; - const nextAccessToken: string | undefined = refreshed?.accessToken; - if (!nextAccessToken) { - const msg = json?.errors?.[0]?.message; - if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) { - authDebug("refresh rejected by GraphQL error", { message: msg }); - uiAuth.clearToken(); - return null; - } - authDebug("refresh returned no access token", { message: msg ?? null }); - throw new Error(msg ?? "Token refresh failed"); - } - - uiAuth.updateAccessToken( - { - accessToken: nextAccessToken, - clientMutationId: typeof refreshed?.clientMutationId === "string" - ? refreshed.clientMutationId - : session.clientMutationId, - }, - jwt, - ); - authDebug("refresh success", { - nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null, - }); - return nextAccessToken; - })() - .catch((e: unknown) => { - authDebug("refresh threw error", { - message: e instanceof Error ? e.message : String(e), - }); - throw e; - }) - .finally(() => { - _refreshPromise = null; - }); - - return _refreshPromise; -} - -export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus { - const session = uiAuth.getSession(); - const accessExpiresAt = session?.accessExpiresAt ?? null; - const refreshExpiresAt = session?.refreshExpiresAt ?? null; - - return { - mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode, - serverBase: getServerBase(), - hasSession: !!session, - hasRefreshToken: !!session?.refreshToken, - accessExpiresAt, - refreshExpiresAt, - accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null, - refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null, - shouldRefreshSoon: isExpired(accessExpiresAt), - refreshInFlight: _refreshPromise !== null, - skewMs: TOKEN_REFRESH_SKEW_MS, - }; -} - -export async function loginUI(user: string, pass: string): Promise { - const res = await fetch(`${getServerBase()}/api/graphql`, { - method: "POST", credentials: "omit", - headers: { "Content-Type": "application/json" }, - body: gqlBody( - `mutation Login($username: String!, $password: String!) { - login(input: { username: $username, password: $password }) { - accessToken - refreshToken - clientMutationId - } - }`, - { username: user, password: pass }, - ), - signal: timeoutSignal(8000), - }); - if (!res.ok) throw new Error(`Login request failed (${res.status})`); - const json = await res.json(); - const payload = json?.data?.login; - const accessToken: string | undefined = payload?.accessToken; - const refreshToken: string | undefined = payload?.refreshToken; - if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed"); - - authDebug("login success", { user }); - - const preliminarySession = { - accessToken, - refreshToken, - clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined, - }; - - uiAuth.setLoginSession(preliminarySession, null); - updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" }); - - const jwt = await getJwtSettings(true).catch(() => null); - uiAuth.setLoginSession(preliminarySession, jwt); -} - -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: gqlBody("{ __typename }"), - signal: timeoutSignal(5000), - }); - if (!res.ok) throw new Error(`Authentication failed (${res.status})`); - updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass }); -} - -export async function logout(): Promise { - uiAuth.clearToken(); - updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" }); -} - -export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> { - const base = getServerBase(); - const mode = store.settings.serverAuthMode ?? "NONE"; - const s = store.settings; - const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null; - - if (mode === "UI_LOGIN" && !token) return "auth_required"; - - 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)); - } else if (mode === "UI_LOGIN" && token) { - Object.assign(headers, bearerHeader(token)); - } - - const res = await fetch(`${base}/api/graphql`, { - method: "POST", credentials: "omit", headers, - body: gqlBody("{ __typename }"), - signal: timeoutSignal(5000), - }); - - if (res.ok) return "ok"; - if (res.status === 401) return "auth_required"; - return "unreachable"; - } catch { - return "unreachable"; - } -} \ No newline at end of file diff --git a/_old/core/backup.ts b/_old/core/backup.ts deleted file mode 100644 index 81a6233..0000000 --- a/_old/core/backup.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { - persistSettings, - persistLibrary, - persistUpdates, -} from "@core/persistence/persist"; - -const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const; - -export async function exportAppData(): Promise { - const entries: [string, string][] = await invoke("read_store_files", { - names: [...STORE_FILES], - }); - - const zip = buildZip( - entries.map(([name, content]) => ({ - name, - bytes: new TextEncoder().encode(content), - })) - ); - - await invoke("export_app_data", { bytes: Array.from(zip) }); -} - -export async function importAppData(): Promise { - const raw: number[] = await invoke("import_app_data"); - const files = parseZip(new Uint8Array(raw)); - - const decode = (name: string) => { - const bytes = files.get(name); - if (!bytes) throw new Error(`Backup is missing ${name}`); - return JSON.parse(new TextDecoder().decode(bytes)); - }; - - const s = decode("settings.json"); - const l = decode("library.json"); - const u = decode("updates.json"); - - await Promise.all([ - persistSettings({ - settings: s.settings ?? null, - storeVersion: s.storeVersion ?? 1, - }), - persistLibrary({ - history: l.history ?? [], - bookmarks: l.bookmarks ?? [], - markers: l.markers ?? [], - readLog: l.readLog ?? [], - readingStats: l.readingStats ?? null, - dailyReadCounts: l.dailyReadCounts ?? {}, - }), - persistUpdates({ - libraryUpdates: u.libraryUpdates ?? [], - lastLibraryRefresh: u.lastLibraryRefresh ?? 0, - acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [], - }), - ]); - - await showExitModal(); - invoke("exit_app"); -} - -function showExitModal(): Promise { - return new Promise(resolve => { - const backdrop = document.createElement("div"); - backdrop.className = "s-backdrop"; - backdrop.style.cssText = "z-index:99999"; - - const modal = document.createElement("div"); - modal.style.cssText = [ - "background:var(--bg-surface)", - "border:1px solid var(--border-base)", - "border-radius:var(--radius-2xl)", - "box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)", - "width:min(400px,calc(100vw - 40px))", - "display:flex", - "flex-direction:column", - "overflow:hidden", - "animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both", - ].join(";"); - - const header = document.createElement("div"); - header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)"; - - const title = document.createElement("p"); - title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em"; - title.textContent = "Import complete"; - header.appendChild(title); - - const body = document.createElement("div"); - body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)"; - - const sub = document.createElement("p"); - sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)"; - sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data."; - - const counter = document.createElement("p"); - counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)"; - counter.textContent = "Closing in 3…"; - - body.append(sub, counter); - - const footer = document.createElement("div"); - footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end"; - - const btn = document.createElement("button"); - btn.className = "s-btn s-btn-danger"; - btn.textContent = "Close now"; - - footer.appendChild(btn); - modal.append(header, body, footer); - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - - let secs = 3; - const tick = setInterval(() => { - secs--; - counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…"; - if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); } - }, 1000); - - btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); }); - }); -} - -export async function autoBackupAppData(): Promise { - try { - const entries: [string, string][] = await invoke("read_store_files", { - names: [...STORE_FILES], - }); - const zip = buildZip( - entries.map(([name, content]) => ({ - name, - bytes: new TextEncoder().encode(content), - })) - ); - await invoke("auto_backup_app_data", { bytes: Array.from(zip) }); - } catch (e) { - console.warn("[moku] auto-backup failed:", e); - } -} - -function crc32(data: Uint8Array): number { - let crc = 0xffffffff; - for (const byte of data) { - crc ^= byte; - for (let j = 0; j < 8; j++) { - crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; - } - } - return (crc ^ 0xffffffff) >>> 0; -} - -function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array { - const buf = new ArrayBuffer(30 + name.byteLength); - const v = new DataView(buf); - v.setUint32(0, 0x04034b50, true); - v.setUint16(4, 20, true); - v.setUint16(6, 0, true); - v.setUint16(8, 0, true); - v.setUint16(10, 0, true); - v.setUint16(12, 0, true); - v.setUint32(14, crc32(data), true); - v.setUint32(18, data.byteLength, true); - v.setUint32(22, data.byteLength, true); - v.setUint16(26, name.byteLength, true); - v.setUint16(28, 0, true); - new Uint8Array(buf).set(name, 30); - return new Uint8Array(buf); -} - -function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array { - const buf = new ArrayBuffer(46 + name.byteLength); - const v = new DataView(buf); - v.setUint32(0, 0x02014b50, true); - v.setUint16(4, 20, true); - v.setUint16(6, 20, true); - v.setUint16(8, 0, true); - v.setUint16(10, 0, true); - v.setUint16(12, 0, true); - v.setUint16(14, 0, true); - v.setUint32(16, crc32(data), true); - v.setUint32(20, data.byteLength, true); - v.setUint32(24, data.byteLength, true); - v.setUint16(28, name.byteLength, true); - v.setUint16(30, 0, true); - v.setUint16(32, 0, true); - v.setUint16(34, 0, true); - v.setUint16(36, 0, true); - v.setUint32(38, 0, true); - v.setUint32(42, offset, true); - new Uint8Array(buf).set(name, 46); - return new Uint8Array(buf); -} - -function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array { - const buf = new ArrayBuffer(22); - const v = new DataView(buf); - v.setUint32(0, 0x06054b50, true); - v.setUint16(4, 0, true); - v.setUint16(6, 0, true); - v.setUint16(8, count, true); - v.setUint16(10, count, true); - v.setUint32(12, cdSize, true); - v.setUint32(16, cdOffset, true); - v.setUint16(20, 0, true); - return new Uint8Array(buf); -} - -function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array { - const enc = new TextEncoder(); - const parts: Uint8Array[] = []; - const offsets: number[] = []; - let pos = 0; - - for (const { name, bytes } of files) { - const nameBytes = enc.encode(name); - const lh = localHeader(nameBytes, bytes); - offsets.push(pos); - parts.push(lh, bytes); - pos += lh.byteLength + bytes.byteLength; - } - - const cdParts = files.map(({ name, bytes }, i) => - centralHeader(enc.encode(name), bytes, offsets[i]) - ); - const cd = concat(cdParts); - - return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]); -} - -function parseZip(data: Uint8Array): Map { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); - const files = new Map(); - let pos = 0; - - while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) { - const fnLen = view.getUint16(pos + 26, true); - const exLen = view.getUint16(pos + 28, true); - const cSize = view.getUint32(pos + 18, true); - const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen)); - const start = pos + 30 + fnLen + exLen; - files.set(name, data.subarray(start, start + cSize)); - pos = start + cSize; - } - - return files; -} - -function concat(arrays: Uint8Array[]): Uint8Array { - const total = arrays.reduce((n, a) => n + a.byteLength, 0); - const out = new Uint8Array(total); - let pos = 0; - for (const a of arrays) { out.set(a, pos); pos += a.byteLength; } - return out; -} \ No newline at end of file diff --git a/_old/core/cache/imageCache.ts b/_old/core/cache/imageCache.ts deleted file mode 100644 index b162185..0000000 --- a/_old/core/cache/imageCache.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; -import { store } from "@store/state.svelte"; -import { getUIAccessToken } from "@core/auth"; - -const cache = new Map(); -const inflight = new Map>(); -const MAX_CONCURRENT = 6; -let active = 0; -let drainScheduled = false; -let clearing = false; - -interface QueueEntry { - url: string; - priority: number; - resolve: (v: string) => void; - reject: (e: unknown) => void; -} - -const queue: QueueEntry[] = []; - -async function getAuthHeaders(): Promise> { - const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode === "UI_LOGIN") { - const token = await getUIAccessToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; - } - if (mode === "BASIC_AUTH") { - const user = store.settings.serverAuthUser?.trim() ?? ""; - const pass = store.settings.serverAuthPass?.trim() ?? ""; - return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {}; - } - return {}; -} - -async function doFetch(url: string): Promise { - const headers = await getAuthHeaders(); - const res = await tauriFetch(url, { method: "GET", headers }); - if (!res.ok) throw new Error(`${res.status}`); - const blob = await res.blob(); - if (clearing) throw new DOMException("Cancelled", "AbortError"); - const blobUrl = URL.createObjectURL(blob); - cache.set(url, blobUrl); - return blobUrl; -} - -function insertSorted(entry: QueueEntry) { - let lo = 0, hi = queue.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (queue[mid].priority > entry.priority) lo = mid + 1; - else hi = mid; - } - queue.splice(lo, 0, entry); -} - -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(() => { 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 }); - }).catch(err => { - inflight.delete(url); - return Promise.reject(err); - }); - inflight.set(url, promise); - 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); - if (idx !== -1 && priority > queue[idx].priority) { - const [entry] = queue.splice(idx, 1); - entry.priority = priority; - insertSorted(entry); - } - return existing; - } - return enqueue(url, priority); -} - -export function preloadBlobUrls(urls: string[], basePriority = 0): void { - urls.forEach((url, i) => { - if (!url || cache.has(url) || inflight.has(url)) return; - enqueue(url, basePriority - i); - }); -} - -export function revokeBlobUrl(url: string): void { - const blob = cache.get(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 cancelQueuedFetches(): void { - const dropped = queue.splice(0); - for (const entry of dropped) { - inflight.delete(entry.url); - entry.reject(new DOMException("Cancelled", "AbortError")); - } -} - -export function clearBlobCache(): void { - clearing = true; - cancelQueuedFetches(); - cache.forEach(blob => URL.revokeObjectURL(blob)); - cache.clear(); - inflight.clear(); - clearing = false; -} \ No newline at end of file diff --git a/_old/core/cache/index.ts b/_old/core/cache/index.ts deleted file mode 100644 index 410c37d..0000000 --- a/_old/core/cache/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './memoryCache'; -export * from './pageCache'; -export * from './imageCache'; -export * from './queryCache'; \ No newline at end of file diff --git a/_old/core/cache/memoryCache.ts b/_old/core/cache/memoryCache.ts deleted file mode 100644 index 1cc6431..0000000 --- a/_old/core/cache/memoryCache.ts +++ /dev/null @@ -1,44 +0,0 @@ -interface MemEntry { - value: T; - expiresAt: number; - key: string; -} - -export class MemoryCache { - readonly #cap: number; - readonly #ttl: number; - readonly #map = new Map>(); - - constructor(capacity: number, ttlMs: number) { - this.#cap = capacity; - this.#ttl = ttlMs; - } - - get(key: string): T | undefined { - const entry = this.#map.get(key); - if (!entry) return undefined; - if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; } - this.#map.delete(key); - this.#map.set(key, entry); - return entry.value; - } - - set(key: string, value: T): void { - if (this.#map.has(key)) this.#map.delete(key); - else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!); - this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key }); - } - - has(key: string): boolean { - const entry = this.#map.get(key); - if (!entry) return false; - if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; } - return true; - } - - delete(key: string): void { this.#map.delete(key); } - - clear(): void { this.#map.clear(); } - - get size(): number { return this.#map.size; } -} \ No newline at end of file diff --git a/_old/core/cache/pageCache.ts b/_old/core/cache/pageCache.ts deleted file mode 100644 index 46f61f3..0000000 --- a/_old/core/cache/pageCache.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { gql, getServerUrl } from "@api/client"; -import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache"; -import { dedupeRequest } from "@core/async/batchRequests"; -import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; - -const pageCache = new Map(); -const inflight = new Map>(); -const resolvedUrlCache = new Map>(); -const aspectCache = new Map(); - -export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise { - if (!useBlob) return Promise.resolve(url); - const cached = resolvedUrlCache.get(url); - if (cached) return cached; - const p = getBlobUrl(url, priority).catch(err => { - resolvedUrlCache.delete(url); - return Promise.reject(err); - }); - resolvedUrlCache.set(url, p); - return p; -} - -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 => p.startsWith("http") ? p : `${getServerUrl()}${p}`); - if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999); - 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 (useBlob) { preloadBlobUrls([url], 0); return; } - resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); -} - -export function clearResolvedUrlCache(): void { - resolvedUrlCache.clear(); - aspectCache.clear(); -} - -export function clearPageCache(chapterId?: number): void { - if (chapterId !== undefined) { - pageCache.delete(chapterId); - inflight.delete(chapterId); - } else { - pageCache.clear(); - inflight.clear(); - resolvedUrlCache.clear(); - aspectCache.clear(); - } -} \ No newline at end of file diff --git a/_old/core/cache/queryCache.ts b/_old/core/cache/queryCache.ts deleted file mode 100644 index 1baae89..0000000 --- a/_old/core/cache/queryCache.ts +++ /dev/null @@ -1,241 +0,0 @@ -interface Entry { - promise: Promise; - fetchedAt: number; - fetcher?: () => Promise; - ttl?: number; -} - -const store = new Map>(); -const subs = new Map void>>(); -const keyToGroups = new Map>(); -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); - if (!keyToGroups.has(key)) keyToGroups.set(key, new Set()); - keyToGroups.get(key)!.add(tag); - } -} - -function unregisterKey(key: string) { - const tags = keyToGroups.get(key); - if (tags) { - for (const tag of tags) groups.get(tag)?.delete(key); - keyToGroups.delete(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(), fetcher: fetcher as () => Promise, ttl }); - registerGroups(key, group); - promise.then(() => notify(key)).catch(() => {}); - return promise; - }, - - set(key: string, value: T, group?: string | string[]) { - const existing = store.get(key) as Entry | undefined; - store.set(key, { - promise: Promise.resolve(value), - fetchedAt: Date.now(), - fetcher: existing?.fetcher, - ttl: existing?.ttl, - }); - 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, { ...existing, promise: next, fetchedAt: Date.now() }); - next.then(() => notify(key)).catch(() => {}); - }, - - refresh(key: string): Promise | undefined { - const existing = store.get(key) as Entry | undefined; - if (!existing?.fetcher) return undefined; - const promise = (existing.fetcher as () => Promise)().catch(err => { - if (err?.name !== "AbortError") store.delete(key); - return Promise.reject(err); - }); - store.set(key, { ...existing, promise: promise as Promise, fetchedAt: Date.now() }); - promise.then(() => notify(key)).catch(() => {}); - return promise; - }, - - refreshGroup(tag: string): void { - const keys = groups.get(tag); - if (!keys) return; - for (const key of [...keys]) { - const existing = store.get(key); - if (existing?.fetcher) { - const promise = existing.fetcher().catch(err => { - if (err?.name !== "AbortError") store.delete(key); - return Promise.reject(err); - }); - store.set(key, { ...existing, promise, fetchedAt: Date.now() }); - promise.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; - }, - - isStale(key: string): boolean { - const e = store.get(key); - if (!e) return true; - return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS); - }, - - clear(key: string) { - unregisterKey(key); - store.delete(key); - notify(key); - }, - - clearGroup(tag: string) { - const keys = groups.get(tag); - if (!keys) return; - for (const key of [...keys]) { - keyToGroups.get(key)?.delete(tag); - if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key); - store.delete(key); - notify(key); - } - groups.delete(tag); - }, - - clearAll() { - const allKeys = [...store.keys()]; - store.clear(); - groups.clear(); - keyToGroups.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", - RECENT_UPDATES: "recent_updates", - 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); -} - -export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise { - const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId)); - if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId)); - - cache.clear(CACHE_KEYS.CHAPTERS(mangaId)); - cache.clear(CACHE_KEYS.LIBRARY); - cache.clear(CACHE_KEYS.ALL_MANGA); - - if (thumbnailUrl) { - const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache"); - revokeBlobUrl(thumbnailUrl); - getBlobUrl(thumbnailUrl, 999).catch(() => {}); - } -} \ No newline at end of file diff --git a/_old/core/cover/autoLink.ts b/_old/core/cover/autoLink.ts deleted file mode 100644 index a256058..0000000 --- a/_old/core/cover/autoLink.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { store, linkManga } from "@store/state.svelte"; -import type { Manga } from "@types"; - -export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise { - return new Promise((resolve) => { - const worker = new Worker( - new URL("./autoLinkWorker.ts", import.meta.url), - { type: "module" }, - ); - - worker.onmessage = (e: MessageEvent) => { - const matches = e.data; - for (const id of matches) linkManga(focal.id, id); - worker.terminate(); - resolve(matches.length); - }; - - worker.onerror = () => { worker.terminate(); resolve(0); }; - - worker.postMessage({ - focalTitle: focal.title, - focalId: focal.id, - allManga: allManga.map(m => ({ id: m.id, title: m.title })), - linkedIds: store.settings.mangaLinks?.[focal.id] ?? [], - }); - }); -} \ No newline at end of file diff --git a/_old/core/cover/autoLinkWorker.ts b/_old/core/cover/autoLinkWorker.ts deleted file mode 100644 index b5bae69..0000000 --- a/_old/core/cover/autoLinkWorker.ts +++ /dev/null @@ -1,29 +0,0 @@ -interface WorkerMsg { - focalTitle: string; - focalId: number; - allManga: { id: number; title: string }[]; - linkedIds: number[]; -} - -function titleSimilarity(a: string, b: string): number { - const norm = (s: string) => - s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); - const wa = new Set(norm(a)); - const wb = new Set(norm(b)); - if (!wa.size || !wb.size) return 0; - const intersection = [...wa].filter(w => wb.has(w)).length; - return intersection / new Set([...wa, ...wb]).size; -} - -self.onmessage = (e: MessageEvent) => { - const { focalTitle, focalId, allManga, linkedIds } = e.data; - const matches: number[] = []; - - for (const m of allManga) { - if (m.id === focalId) continue; - if (linkedIds.includes(m.id)) continue; - if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id); - } - - self.postMessage(matches); -}; \ No newline at end of file diff --git a/_old/core/cover/coverHash.ts b/_old/core/cover/coverHash.ts deleted file mode 100644 index 5dbf7c2..0000000 --- a/_old/core/cover/coverHash.ts +++ /dev/null @@ -1,54 +0,0 @@ -const THUMB_SIZE = 16; -const DUPE_THRESH = 0.12; - -const hashCache = new Map(); - -function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray { - const gray = new Uint8ClampedArray(pixels); - for (let i = 0; i < pixels; i++) { - const o = i * 4; - gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000; - } - return gray; -} - -function loadThumb(url: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = () => { - const canvas = document.createElement("canvas"); - canvas.width = canvas.height = THUMB_SIZE; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE); - resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE)); - }; - img.onerror = reject; - img.src = url; - }); -} - -function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number { - let diff = 0; - for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]); - return diff / (a.length * 255); -} - -export async function getHash(url: string): Promise { - if (hashCache.has(url)) return hashCache.get(url)!; - try { - const thumb = await loadThumb(url); - hashCache.set(url, thumb); - return thumb; - } catch { - return null; - } -} - -export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean { - return similarity(a, b) <= DUPE_THRESH; -} - -export function clearHashCache(): void { - hashCache.clear(); -} \ No newline at end of file diff --git a/_old/core/cover/coverResolver.ts b/_old/core/cover/coverResolver.ts deleted file mode 100644 index 9dd3912..0000000 --- a/_old/core/cover/coverResolver.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { store } from "@store/state.svelte"; -import { searchWithScore } from "@core/algorithms/search"; -import { getHash, areDuplicates } from "@core/cover/coverHash"; - -type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null }; - -export type CoverCandidate = { - mangaId: number; - url: string; - label: string; - isActive: boolean; -}; - -const FUZZY_SCORE_THRESHOLD = 0.65; - -function normalizeUrl(url: string): string { - try { - const u = new URL(url); - u.search = ""; - return u.href.toLowerCase(); - } catch { - return url.toLowerCase(); - } -} - -export function resolvedCover(mangaId: number, ownUrl: string): string { - return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl; -} - -function fuzzyMatchIds( - mangaId: number, - title: string, - mangaById: Map, -): number[] { - const results = searchWithScore( - [...mangaById.values()].filter(m => m.id !== mangaId), - title, - m => m.title, - ); - return results - .filter(r => r.score >= FUZZY_SCORE_THRESHOLD) - .map(r => r.item.id); -} - -export function coverCandidatesSync( - mangaId: number, - title: string, - ownUrl: string, - mangaById: Map, -): CoverCandidate[] { - const linkedIds = store.getLinkedMangaIds(mangaId); - const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById); - const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl; - - const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds])); - - const raw: { mangaId: number; url: string; label: string }[] = [ - { mangaId, url: ownUrl, label: "This source" }, - ...allIds.flatMap(id => { - const m = mangaById.get(id); - return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : []; - }), - ]; - - const seen = new Set(); - return raw - .filter(c => { - const key = normalizeUrl(c.url); - if (seen.has(key)) return false; - seen.add(key); - return true; - }) - .map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) })); -} - -export async function dedupeByImage(candidates: CoverCandidate[]): Promise { - const hashes = await Promise.all(candidates.map(c => getHash(c.url))); - - const groups: number[][] = []; - - for (let i = 0; i < candidates.length; i++) { - const hi = hashes[i]; - const existing = hi - ? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; }) - : undefined; - if (existing) existing.push(i); - else groups.push([i]); - } - - return groups.map(group => { - const active = group.find(i => candidates[i].isActive) ?? group[0]; - const labels = [...new Set(group.map(i => candidates[i].label))]; - return { ...candidates[active], label: labels.join(" · ") }; - }); -} \ No newline at end of file diff --git a/_old/core/cover/index.ts b/_old/core/cover/index.ts deleted file mode 100644 index 130da9f..0000000 --- a/_old/core/cover/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { getHash, areDuplicates, clearHashCache } from "./coverHash"; -export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver"; -export type { CoverCandidate } from "./coverResolver"; -export { autoLinkLibrary } from "./autoLink"; diff --git a/_old/core/keybinds/defaultBinds.ts b/_old/core/keybinds/defaultBinds.ts deleted file mode 100644 index 83cac86..0000000 --- a/_old/core/keybinds/defaultBinds.ts +++ /dev/null @@ -1,50 +0,0 @@ -export interface Keybinds { - turnPageRight: string; - turnPageLeft: string; - firstPage: string; - lastPage: string; - turnChapterRight: string; - turnChapterLeft: string; - exitReader: string; - toggleReadingDirection: string; - togglePageStyle: string; - toggleFullscreen: string; - openSettings: string; - toggleBookmark: string; - toggleMarker: string; - toggleAutoScroll: string; -} - -export const DEFAULT_KEYBINDS: Keybinds = { - turnPageRight: "ArrowRight", - turnPageLeft: "ArrowLeft", - firstPage: "ctrl+ArrowLeft", - lastPage: "ctrl+ArrowRight", - turnChapterRight: "]", - turnChapterLeft: "[", - exitReader: "Backspace", - toggleReadingDirection: "d", - togglePageStyle: "q", - toggleFullscreen: "f", - openSettings: "o", - toggleBookmark: "m", - toggleMarker: "n", - toggleAutoScroll: "s", -}; - -export const KEYBIND_LABELS: Record = { - turnPageRight: "Turn page right (→)", - turnPageLeft: "Turn page left (←)", - firstPage: "Jump to first page", - lastPage: "Jump to last page", - turnChapterRight: "Turn chapter right (→)", - turnChapterLeft: "Turn chapter left (←)", - exitReader: "Exit reader", - toggleReadingDirection: "Toggle reading direction", - togglePageStyle: "Toggle page style", - toggleFullscreen: "Toggle fullscreen", - openSettings: "Open settings", - toggleBookmark: "Toggle bookmark", - toggleMarker: "Toggle marker", - toggleAutoScroll: "Toggle auto scroll", -}; \ No newline at end of file diff --git a/_old/core/keybinds/index.ts b/_old/core/keybinds/index.ts deleted file mode 100644 index 5bb9877..0000000 --- a/_old/core/keybinds/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine"; -export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds"; -export type { Keybinds } from "./defaultBinds"; diff --git a/_old/core/keybinds/keybindEngine.ts b/_old/core/keybinds/keybindEngine.ts deleted file mode 100644 index f905c37..0000000 --- a/_old/core/keybinds/keybindEngine.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/_old/core/persistence/credentialVault.ts b/_old/core/persistence/credentialVault.ts deleted file mode 100644 index 3289193..0000000 --- a/_old/core/persistence/credentialVault.ts +++ /dev/null @@ -1,88 +0,0 @@ -const VAULT_KEY = "moku-credential-vault"; -const SALT_ITERATIONS = 200_000; -const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"]; - -export interface VaultPayload { - refreshToken?: string; - basicUser?: string; - basicPass?: string; - authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE"; -} - -interface StoredVault { - salt: string; - iv: string; - data: string; -} - -function toB64(buf: ArrayBuffer): string { - return btoa(String.fromCharCode(...new Uint8Array(buf))); -} - -function fromB64(s: string): Uint8Array { - return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); -} - -async function deriveKey(pin: string, salt: Uint8Array): Promise { - const enc = new TextEncoder(); - const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); - return crypto.subtle.deriveKey( - { name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" }, - keyMat, - { name: "AES-GCM", length: 256 }, - false, - KEY_USAGE, - ); -} - -export function vaultExists(): boolean { - return !!localStorage.getItem(VAULT_KEY); -} - -export async function lockVault(pin: string, payload: VaultPayload): Promise { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const key = await deriveKey(pin, salt); - - const enc = new TextEncoder(); - const cipher = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - enc.encode(JSON.stringify(payload)), - ); - - localStorage.setItem(VAULT_KEY, JSON.stringify({ - salt: toB64(salt), - iv: toB64(iv), - data: toB64(cipher), - } satisfies StoredVault)); -} - -export async function unlockVault(pin: string): Promise { - const raw = localStorage.getItem(VAULT_KEY); - if (!raw) return null; - - try { - const stored = JSON.parse(raw) as StoredVault; - const key = await deriveKey(pin, fromB64(stored.salt)); - const plain = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: fromB64(stored.iv) }, - key, - fromB64(stored.data), - ); - return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload; - } catch { - return null; - } -} - -export function clearVault(): void { - localStorage.removeItem(VAULT_KEY); -} - -export async function rekeyVault(oldPin: string, newPin: string): Promise { - const payload = await unlockVault(oldPin); - if (!payload) return false; - await lockVault(newPin, payload); - return true; -} \ No newline at end of file diff --git a/_old/core/persistence/index.ts b/_old/core/persistence/index.ts deleted file mode 100644 index bb72661..0000000 --- a/_old/core/persistence/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { - loadSettings, saveSettings, - loadLibrary, saveLibrary, - loadUpdates, saveUpdates, - loadBackups, saveBackups, -} from "./persist"; -export type { - PersistedSettings, - PersistedLibrary, - PersistedUpdates, - PersistedBackups, -} from "./persist"; - -export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault"; -export type { VaultPayload } from "./credentialVault"; \ No newline at end of file diff --git a/_old/core/persistence/persist.ts b/_old/core/persistence/persist.ts deleted file mode 100644 index 6591a4e..0000000 --- a/_old/core/persistence/persist.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { LazyStore } from "@tauri-apps/plugin-store"; - -const settingsStore = new LazyStore("settings.json", { autoSave: false }); -const libraryStore = new LazyStore("library.json", { autoSave: false }); -const updatesStore = new LazyStore("updates.json", { autoSave: false }); -const backupsStore = new LazyStore("backups.json", { autoSave: false }); - -export interface PersistedData { - settings: any; - storeVersion: number | null; - history: any[]; - bookmarks: any[]; - markers: any[]; - readLog: any[]; - readingStats: any | null; - dailyReadCounts: Record; - libraryUpdates: any[]; - lastLibraryRefresh: number; - acknowledgedUpdateIds: number[]; -} - -export async function loadAllStores(): Promise { - const migrated = await migrateFromLocalStorage(); - if (migrated) return migrated; - - const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([ - settingsStore.get("storeVersion"), - settingsStore.get("settings"), - libraryStore.get("history"), - libraryStore.get("bookmarks"), - libraryStore.get("markers"), - libraryStore.get("readLog"), - libraryStore.get("readingStats"), - libraryStore.get>("dailyReadCounts"), - updatesStore.get("libraryUpdates"), - updatesStore.get("lastLibraryRefresh"), - updatesStore.get("acknowledgedUpdateIds"), - ]); - - return { - storeVersion: sv ?? null, - settings: s ?? null, - history: hist ?? [], - bookmarks: bk ?? [], - markers: mk ?? [], - readLog: rl ?? [], - readingStats: rs ?? null, - dailyReadCounts: dc ?? {}, - libraryUpdates: lu ?? [], - lastLibraryRefresh: llr ?? 0, - acknowledgedUpdateIds: au ?? [], - }; -} - -async function migrateFromLocalStorage(): Promise { - try { - const raw = localStorage.getItem("moku-store"); - if (!raw) return null; - const data = JSON.parse(raw); - - await Promise.all([ - persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }), - persistLibrary({ - history: data.history ?? [], - bookmarks: data.bookmarks ?? [], - markers: data.markers ?? [], - readLog: data.readLog ?? [], - readingStats: data.readingStats ?? null, - dailyReadCounts: data.dailyReadCounts ?? {}, - }), - persistUpdates({ - libraryUpdates: data.libraryUpdates ?? [], - lastLibraryRefresh: data.lastLibraryRefresh ?? 0, - acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [], - }), - ]); - - localStorage.removeItem("moku-store"); - - return { - storeVersion: data.storeVersion ?? null, - settings: data.settings ?? null, - history: data.history ?? [], - bookmarks: data.bookmarks ?? [], - markers: data.markers ?? [], - readLog: data.readLog ?? [], - readingStats: data.readingStats ?? null, - dailyReadCounts: data.dailyReadCounts ?? {}, - libraryUpdates: data.libraryUpdates ?? [], - lastLibraryRefresh: data.lastLibraryRefresh ?? 0, - acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [], - }; - } catch { - return null; - } -} - -export async function persistSettings(data: { settings: any; storeVersion: number }) { - await Promise.all([ - settingsStore.set("settings", data.settings), - settingsStore.set("storeVersion", data.storeVersion), - ]); - await settingsStore.save(); -} - -export async function persistLibrary(data: { - history: any[]; - bookmarks: any[]; - markers: any[]; - readLog: any[]; - readingStats: any; - dailyReadCounts: Record; -}) { - await Promise.all([ - libraryStore.set("history", data.history), - libraryStore.set("bookmarks", data.bookmarks), - libraryStore.set("markers", data.markers), - libraryStore.set("readLog", data.readLog), - libraryStore.set("readingStats", data.readingStats), - libraryStore.set("dailyReadCounts", data.dailyReadCounts), - ]); - await libraryStore.save(); -} - -export async function persistUpdates(data: { - libraryUpdates: any[]; - lastLibraryRefresh: number; - acknowledgedUpdateIds: number[]; -}) { - await Promise.all([ - updatesStore.set("libraryUpdates", data.libraryUpdates), - updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh), - updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds), - ]); - await updatesStore.save(); -} - -export interface BackupEntry { url: string; name: string; } - -export async function loadBackups(): Promise { - const fromStore = await backupsStore.get("backupList"); - if (fromStore) return fromStore; - try { - const raw = localStorage.getItem("moku_backups"); - if (!raw) return []; - const migrated: BackupEntry[] = JSON.parse(raw); - await persistBackups(migrated); - localStorage.removeItem("moku_backups"); - return migrated; - } catch { return []; } -} - -export async function persistBackups(list: BackupEntry[]): Promise { - await backupsStore.set("backupList", list); - await backupsStore.save(); -} - -export async function resetAuthSettings(): Promise { - const current = await settingsStore.get("settings") ?? {}; - current.serverAuthMode = "NONE"; - current.serverAuthUser = ""; - current.serverAuthPass = ""; - await settingsStore.set("settings", current); - await settingsStore.save(); - localStorage.removeItem("moku-credential-vault"); -} \ No newline at end of file diff --git a/_old/core/theme.ts b/_old/core/theme.ts deleted file mode 100644 index 638a13c..0000000 --- a/_old/core/theme.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { store, updateSettings } from "@store/state.svelte"; - -let themeStyleEl: HTMLStyleElement | null = null; -let mediaQuery: MediaQueryList | null = null; -let mediaHandler: (() => void) | 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"); -} - -function applySystemTheme(dark: boolean) { - const themeId = dark - ? (store.settings.systemThemeDark ?? "dark") - : (store.settings.systemThemeLight ?? "light"); - updateSettings({ theme: themeId }); -} - -export function mountSystemThemeSync() { - if (mediaQuery && mediaHandler) { - mediaQuery.removeEventListener("change", mediaHandler); - mediaHandler = null; - } - - if (!store.settings.systemThemeSync) return; - - mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - mediaHandler = () => applySystemTheme(mediaQuery!.matches); - mediaQuery.addEventListener("change", mediaHandler); - applySystemTheme(mediaQuery.matches); -} - -export function unmountSystemThemeSync() { - if (mediaQuery && mediaHandler) { - mediaQuery.removeEventListener("change", mediaHandler); - mediaHandler = null; - mediaQuery = null; - } -} \ No newline at end of file diff --git a/_old/core/ui/idle.ts b/_old/core/ui/idle.ts deleted file mode 100644 index 97d5d12..0000000 --- a/_old/core/ui/idle.ts +++ /dev/null @@ -1,23 +0,0 @@ -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/_old/core/ui/index.ts b/_old/core/ui/index.ts deleted file mode 100644 index 6991c2e..0000000 --- a/_old/core/ui/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './idle'; -export * from './zoom'; -export * from './touchscreen'; \ No newline at end of file diff --git a/_old/core/ui/touchscreen.ts b/_old/core/ui/touchscreen.ts deleted file mode 100644 index 72731fd..0000000 --- a/_old/core/ui/touchscreen.ts +++ /dev/null @@ -1,234 +0,0 @@ -export interface LongPressOptions { - onLongPress: (e: PointerEvent) => void; - duration?: number; - moveThreshold?: number; -} - -export function longPress(node: HTMLElement, opts: LongPressOptions) { - const { onLongPress, duration = 500, moveThreshold = 8 } = opts; - let timer: ReturnType | null = null; - let startX = 0, startY = 0; - let fired = false; - - function start(e: PointerEvent) { - if (e.button !== 0 && e.pointerType === "mouse") return; - startX = e.clientX; startY = e.clientY; fired = false; - timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration); - } - function move(e: PointerEvent) { - if (!timer) return; - const dx = e.clientX - startX, dy = e.clientY - startY; - if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel(); - } - function cancel() { if (timer) { clearTimeout(timer); timer = null; } } - - node.addEventListener("pointerdown", start); - node.addEventListener("pointermove", move); - node.addEventListener("pointerup", cancel); - node.addEventListener("pointerleave", cancel); - node.addEventListener("pointercancel",cancel); - - return { - get fired() { return fired; }, - destroy() { - cancel(); - node.removeEventListener("pointerdown", start); - node.removeEventListener("pointermove", move); - node.removeEventListener("pointerup", cancel); - node.removeEventListener("pointerleave", cancel); - node.removeEventListener("pointercancel",cancel); - }, - }; -} - -export interface TapOptions { - onTap: (e: PointerEvent) => void; - onDoubleTap?: (e: PointerEvent) => void; - doubleTapGap?: number; -} - -export function tap(node: HTMLElement, opts: TapOptions) { - const { onTap, onDoubleTap, doubleTapGap = 300 } = opts; - let lastTap = 0; - let pending: ReturnType | null = null; - let startX = 0, startY = 0; - const SLOP = 8; - - function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; } - function up(e: PointerEvent) { - const dx = e.clientX - startX, dy = e.clientY - startY; - if (Math.sqrt(dx * dx + dy * dy) > SLOP) return; - const now = Date.now(); - if (onDoubleTap && now - lastTap < doubleTapGap) { - if (pending) { clearTimeout(pending); pending = null; } - onDoubleTap(e); - lastTap = 0; - } else { - lastTap = now; - if (onDoubleTap) { - pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap); - } else { - onTap(e); - } - } - } - - node.addEventListener("pointerdown", down); - node.addEventListener("pointerup", up); - return { destroy() { - node.removeEventListener("pointerdown", down); - node.removeEventListener("pointerup", up); - }}; -} - -export interface SwipeOptions { - onSwipeLeft?: (e: PointerEvent) => void; - onSwipeRight?: (e: PointerEvent) => void; - onSwipeUp?: (e: PointerEvent) => void; - onSwipeDown?: (e: PointerEvent) => void; - threshold?: number; - lockAxis?: boolean; -} - -export function swipe(node: HTMLElement, opts: SwipeOptions) { - const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts; - let startX = 0, startY = 0, active = false; - - function down(e: PointerEvent) { - if (e.pointerType === "mouse") return; - startX = e.clientX; startY = e.clientY; active = true; - node.setPointerCapture(e.pointerId); - } - function up(e: PointerEvent) { - if (!active) return; active = false; - const dx = e.clientX - startX, dy = e.clientY - startY; - const ax = Math.abs(dx), ay = Math.abs(dy); - if (Math.max(ax, ay) < threshold) return; - if (lockAxis && ax > ay) { - if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); - } else if (lockAxis && ay >= ax) { - if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); - } else { - if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); } - else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); } - } - } - function cancel() { active = false; } - - node.addEventListener("pointerdown", down); - node.addEventListener("pointerup", up); - node.addEventListener("pointercancel", cancel); - return { destroy() { - node.removeEventListener("pointerdown", down); - node.removeEventListener("pointerup", up); - node.removeEventListener("pointercancel", cancel); - }}; -} - -export interface PinchOptions { - onPinch: (scale: number, origin: { x: number; y: number }) => void; - onPinchEnd?: (scale: number) => void; -} - -export interface PinchGestureOptions { - onPinch: (scale: number, origin: { x: number; y: number }) => void; - onPinchEnd?: (scale: number) => void; -} - -export interface PinchGesture { - onPointerDown: (e: PointerEvent) => void; - onPointerMove: (e: PointerEvent) => void; - onPointerUp: (e: PointerEvent) => void; - isPinching: () => boolean; -} - -export function createPinchGesture(opts: PinchGestureOptions): PinchGesture { - const { onPinch, onPinchEnd } = opts; - const pointers = new Map(); - let initDist = 0; - - function pdist(a: PointerEvent, b: PointerEvent) { - const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; - return Math.sqrt(dx * dx + dy * dy); - } - function pmid(a: PointerEvent, b: PointerEvent) { - return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; - } - - function onPointerDown(e: PointerEvent) { - pointers.set(e.pointerId, e); - if (pointers.size === 2) { - const [a, b] = [...pointers.values()]; - initDist = pdist(a, b); - } - } - function onPointerMove(e: PointerEvent) { - if (!pointers.has(e.pointerId)) return; - pointers.set(e.pointerId, e); - if (pointers.size !== 2 || initDist === 0) return; - const [a, b] = [...pointers.values()]; - onPinch(pdist(a, b) / initDist, pmid(a, b)); - } - function onPointerUp(e: PointerEvent) { - if (pointers.size === 2 && onPinchEnd) { - const [a, b] = [...pointers.values()]; - onPinchEnd(pdist(a, b) / initDist); - } - pointers.delete(e.pointerId); - initDist = 0; - } - - return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 }; -} - -export function pinch(node: HTMLElement, opts: PinchOptions) { - const gesture = createPinchGesture(opts); - function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); } - node.addEventListener("pointerdown", down); - node.addEventListener("pointermove", gesture.onPointerMove); - node.addEventListener("pointerup", gesture.onPointerUp); - node.addEventListener("pointercancel", gesture.onPointerUp); - return { destroy() { - node.removeEventListener("pointerdown", down); - node.removeEventListener("pointermove", gesture.onPointerMove); - node.removeEventListener("pointerup", gesture.onPointerUp); - node.removeEventListener("pointercancel", gesture.onPointerUp); - }}; -} - -export interface DragScrollOptions { - direction?: "x" | "y" | "both"; - onDragStart?: () => void; - onDragEnd?: () => void; -} - -export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) { - const { direction = "both", onDragStart, onDragEnd } = opts; - let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0; - - function down(e: PointerEvent) { - if (e.pointerType === "mouse") return; - active = true; - startX = e.clientX; startY = e.clientY; - scrollX = node.scrollLeft; scrollY = node.scrollTop; - node.setPointerCapture(e.pointerId); - onDragStart?.(); - } - function move(e: PointerEvent) { - if (!active) return; - if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY); - if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX); - } - function up() { if (active) { active = false; onDragEnd?.(); } } - - node.addEventListener("pointerdown", down); - node.addEventListener("pointermove", move); - node.addEventListener("pointerup", up); - node.addEventListener("pointercancel", up); - return { destroy() { - node.removeEventListener("pointerdown", down); - node.removeEventListener("pointermove", move); - node.removeEventListener("pointerup", up); - node.removeEventListener("pointercancel", up); - }}; -} \ No newline at end of file diff --git a/_old/core/ui/zoom.ts b/_old/core/ui/zoom.ts deleted file mode 100644 index 891784a..0000000 --- a/_old/core/ui/zoom.ts +++ /dev/null @@ -1,61 +0,0 @@ -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); -} - -export function clampZoom(z: number, min: number, max: number): number { - return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000; -} - -export function captureZoomAnchor( - containerEl: HTMLElement | null, - style: string, - out: { el: HTMLElement | null; offset: number }, -) { - if (!containerEl || style !== "longstrip") return; - const containerTop = containerEl.getBoundingClientRect().top; - for (const img of containerEl.querySelectorAll("img[data-local-page]")) { - 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; - containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset; - }); -} \ No newline at end of file diff --git a/_old/core/updater.ts b/_old/core/updater.ts deleted file mode 100644 index 005f012..0000000 --- a/_old/core/updater.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/_old/core/util.ts b/_old/core/util.ts deleted file mode 100644 index f3d808a..0000000 --- a/_old/core/util.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { Manga, Source } from "@types"; -import type { Settings } from "@types"; - -export { clsx as cn } from "clsx"; - -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 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`; -} - -const STRICT_TAGS: string[] = [ - "adult", "mature", "hentai", "ecchi", "erotic", "pornograph", - "18+", "smut", "explicit", "sexual violence", - "gore", "guro", "graphic violence", "torture", "body horror", -]; - -const MODERATE_TAGS: string[] = [ - "adult", "mature", "hentai", "ecchi", "erotic", "pornograph", - "18+", "smut", "explicit", "sexual violence", -]; - -type ContentFilterSettings = Pick< - Settings, - "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds" ->; - -function blockedTagsForSettings(settings: ContentFilterSettings): string[] { - if (settings.contentLevel === "strict") return STRICT_TAGS; - if (settings.contentLevel === "moderate") return MODERATE_TAGS; - return []; -} - -function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean { - if (!blockedTags.length) return false; - return genre.some(g => { - const norm = g.toLowerCase().trim(); - return blockedTags.some(tag => { - const idx = norm.indexOf(tag); - if (idx === -1) return false; - const before = idx === 0 || /\W/.test(norm[idx - 1]); - const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]); - return before && after; - }); - }); -} - -export function shouldHideNsfw( - manga: Pick, - settings: ContentFilterSettings, -): boolean { - if (settings.contentLevel === "unrestricted") return false; - - const srcId = manga.source?.id; - const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : []; - const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : []; - - if (srcId && blocked.includes(srcId)) return true; - - const sourceAllowed = !!(srcId && allowed.includes(srcId)); - - if (!sourceAllowed && manga.source?.isNsfw) return true; - - return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings)); -} - -export function shouldHideSource( - source: Pick, - settings: ContentFilterSettings, -): boolean { - if (settings.contentLevel === "unrestricted") return false; - - if (settings.sourceOverridesEnabled) { - if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true; - if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false; - } - - return source.isNsfw; -} - -export function dedupeSourcesByLang( - sources: Source[], - preferredLang: string, - settings: ContentFilterSettings, - 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()); -} - -export function dedupeSources(sources: Source[], preferredLang: string): Source[] { - const byName = new Map(); - for (const src of sources) { - if (src.id === "0") continue; - if (!byName.has(src.name)) byName.set(src.name, []); - byName.get(src.name)!.push(src); - } - const picked: Source[] = []; - for (const group of byName.values()) { - const preferred = group.find(s => s.lang === preferredLang); - picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]); - } - return picked; -} - -export function normalizeTitle(title: string): string { - return title - .toLowerCase() - .replace(/\(official\)|\(web comic\)|\(webtoon\)|\(manhwa\)|\(manhua\)/gi, "") - .replace(/[^a-z0-9\s]/g, " ") - .replace(/^(the|a|an)\s+/, "") - .replace(/\s+/g, " ") - .trim(); -} - -function norm(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim(); -} - -function descFingerprint(desc: string | null | undefined): string | null { - if (!desc) return null; - const n = norm(desc); - return n.length >= 60 ? n.slice(0, 200) : null; -} - -function authorFingerprint(author?: string | null, artist?: string | null): string | null { - const parts = [author, artist].filter(Boolean).map(s => norm(s!)); - return parts.length ? parts.sort().join("|") : null; -} - -export function dedupeMangaByTitle(items: T[], links: Record = {}): T[] { - const byTitle = new Map(); - const byDesc = new Map(); - const byAuthorDesc = new Map(); - const byId = new Map(); - 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; - - const linkedIds = links[m.id] ?? []; - const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); - const existingIdx = - linkedIdx ?? - byTitle.get(tk) ?? - (dk ? byDesc.get(dk) : undefined) ?? - (ak ? byAuthorDesc.get(ak) : undefined); - - if (existingIdx !== undefined) { - const existing = out[existingIdx]; - const mBetter = - (m.inLibrary && !existing.inLibrary) || - (!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0)); - - if (mBetter) { - out[existingIdx] = m; - byTitle.set(tk, existingIdx); - byId.set(m.id, existingIdx); - if (dk) byDesc.set(dk, existingIdx); - if (ak) byAuthorDesc.set(ak, existingIdx); - } - continue; - } - - const idx = out.length; - out.push(m); - byTitle.set(tk, idx); - byId.set(m.id, idx); - if (dk) byDesc.set(dk, idx); - if (ak) byAuthorDesc.set(ak, idx); - } - - return out; -} - -export function dedupeMangaById(items: T[]): T[] { - const seen = new Set(); - const out: T[] = []; - for (const m of items) { - if (!seen.has(m.id)) { seen.add(m.id); out.push(m); } - } - return out; -} \ No newline at end of file diff --git a/_old/design/base/animations.css b/_old/design/base/animations.css deleted file mode 100644 index 1234923..0000000 --- a/_old/design/base/animations.css +++ /dev/null @@ -1,48 +0,0 @@ -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes fadeUp { - from { opacity: 0; transform: translateY(5px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes fadeDown { - from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes scaleIn { - from { opacity: 0; transform: scale(0.97); } - to { opacity: 1; transform: scale(1); } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.35; } -} - -@keyframes shimmer { - from { background-position: -200% 0; } - to { background-position: 200% 0; } -} - -.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; } - -.skeleton { - 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); -} diff --git a/_old/design/base/index.css b/_old/design/base/index.css deleted file mode 100644 index ffd5908..0000000 --- a/_old/design/base/index.css +++ /dev/null @@ -1,4 +0,0 @@ -@import "./reset.css"; -@import "./animations.css"; -@import "./scrollbars.css"; -@import "./typography.css"; \ No newline at end of file diff --git a/_old/design/base/reset.css b/_old/design/base/reset.css deleted file mode 100644 index 2b05743..0000000 --- a/_old/design/base/reset.css +++ /dev/null @@ -1,41 +0,0 @@ -*, *::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/_old/design/base/scrollbars.css b/_old/design/base/scrollbars.css deleted file mode 100644 index c512765..0000000 --- a/_old/design/base/scrollbars.css +++ /dev/null @@ -1,9 +0,0 @@ -* { - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -*::-webkit-scrollbar { width: 4px; height: 4px; } -*::-webkit-scrollbar-track { background: transparent; } -*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; } -*::-webkit-scrollbar-thumb:hover { background: transparent; } \ No newline at end of file diff --git a/_old/design/base/typography.css b/_old/design/base/typography.css deleted file mode 100644 index 0a52eb1..0000000 --- a/_old/design/base/typography.css +++ /dev/null @@ -1,9 +0,0 @@ -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/_old/design/themes/dark.css b/_old/design/themes/dark.css deleted file mode 100644 index 1a2bc7f..0000000 --- a/_old/design/themes/dark.css +++ /dev/null @@ -1,25 +0,0 @@ -[data-theme="dark"] { - --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; -} \ No newline at end of file diff --git a/_old/design/themes/index.css b/_old/design/themes/index.css deleted file mode 100644 index 0262d47..0000000 --- a/_old/design/themes/index.css +++ /dev/null @@ -1,5 +0,0 @@ -@import "./original.css"; -@import "./dark.css"; -@import "./light.css"; -@import "./midnight.css"; -@import "./warm.css"; diff --git a/_old/design/themes/light.css b/_old/design/themes/light.css deleted file mode 100644 index 02864b1..0000000 --- a/_old/design/themes/light.css +++ /dev/null @@ -1,29 +0,0 @@ -[data-theme="light"] { - --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/_old/design/themes/midnight.css b/_old/design/themes/midnight.css deleted file mode 100644 index 2455908..0000000 --- a/_old/design/themes/midnight.css +++ /dev/null @@ -1,25 +0,0 @@ -[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/_old/design/themes/original.css b/_old/design/themes/original.css deleted file mode 100644 index ea630c7..0000000 --- a/_old/design/themes/original.css +++ /dev/null @@ -1,31 +0,0 @@ -[data-theme="original"] { - --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; -} \ No newline at end of file diff --git a/_old/design/themes/warm.css b/_old/design/themes/warm.css deleted file mode 100644 index 63422cb..0000000 --- a/_old/design/themes/warm.css +++ /dev/null @@ -1,25 +0,0 @@ -[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/_old/design/tokens/colors.css b/_old/design/tokens/colors.css deleted file mode 100644 index 80fc4b6..0000000 --- a/_old/design/tokens/colors.css +++ /dev/null @@ -1,35 +0,0 @@ -: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); -} \ No newline at end of file diff --git a/_old/design/tokens/index.css b/_old/design/tokens/index.css deleted file mode 100644 index a94bed5..0000000 --- a/_old/design/tokens/index.css +++ /dev/null @@ -1,8 +0,0 @@ -@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/_old/design/tokens/motion.css b/_old/design/tokens/motion.css deleted file mode 100644 index 6064a42..0000000 --- a/_old/design/tokens/motion.css +++ /dev/null @@ -1,5 +0,0 @@ -:root { - --t-fast: 0.08s ease; - --t-base: 0.14s ease; - --t-slow: 0.22s ease; -} diff --git a/_old/design/tokens/radius.css b/_old/design/tokens/radius.css deleted file mode 100644 index d19a858..0000000 --- a/_old/design/tokens/radius.css +++ /dev/null @@ -1,8 +0,0 @@ -:root { - --radius-sm: 3px; - --radius-md: 5px; - --radius-lg: 7px; - --radius-xl: 10px; - --radius-2xl: 14px; - --radius-full: 9999px; -} diff --git a/_old/design/tokens/shadows.css b/_old/design/tokens/shadows.css deleted file mode 100644 index 5a4260f..0000000 --- a/_old/design/tokens/shadows.css +++ /dev/null @@ -1,2 +0,0 @@ -:root { -} diff --git a/_old/design/tokens/spacing.css b/_old/design/tokens/spacing.css deleted file mode 100644 index 4f8acc8..0000000 --- a/_old/design/tokens/spacing.css +++ /dev/null @@ -1,13 +0,0 @@ -: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; - --titlebar-height: 36px; -} \ No newline at end of file diff --git a/_old/design/tokens/typography.css b/_old/design/tokens/typography.css deleted file mode 100644 index e7e57b7..0000000 --- a/_old/design/tokens/typography.css +++ /dev/null @@ -1,28 +0,0 @@ -: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/_old/design/tokens/zindex.css b/_old/design/tokens/zindex.css deleted file mode 100644 index aebac7d..0000000 --- a/_old/design/tokens/zindex.css +++ /dev/null @@ -1,5 +0,0 @@ -:root { - --z-reader: 50; - --z-modal: 100; - --z-settings: 150; -} diff --git a/_old/design/utilities/layout.css b/_old/design/utilities/layout.css deleted file mode 100644 index e69de29..0000000 diff --git a/_old/design/utilities/text.css b/_old/design/utilities/text.css deleted file mode 100644 index e69de29..0000000 diff --git a/_old/design/utilities/visibility.css b/_old/design/utilities/visibility.css deleted file mode 100644 index e69de29..0000000 diff --git a/_old/features/discover/components/GenreDrillPage.svelte b/_old/features/discover/components/GenreDrillPage.svelte deleted file mode 100644 index 08356d1..0000000 --- a/_old/features/discover/components/GenreDrillPage.svelte +++ /dev/null @@ -1,275 +0,0 @@ - - -
-
- - {label} - {#if !loadingInitial || filtered.length > 0} - {visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length} - {/if} - {#if !loadingInitial && hasMoreNetwork} - More loading… - {/if} -
- - {#if loadingInitial && filtered.length === 0} -
- {#each Array(50) as _} -
-
-
-
- {/each} -
- {:else if filtered.length === 0} -
No manga found for "{label}".
- {:else} -
- {#each visibleItems as m, i (m.id)} - - {/each} - {#if hasMore} -
- -
- {/if} -
- {/if} -
- -{#if ctx} - ctx = null} /> -{/if} - - \ No newline at end of file diff --git a/_old/features/discover/components/KeywordTab.svelte b/_old/features/discover/components/KeywordTab.svelte deleted file mode 100644 index 3a63851..0000000 --- a/_old/features/discover/components/KeywordTab.svelte +++ /dev/null @@ -1,337 +0,0 @@ - - -
- - - {#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/_old/features/discover/components/Search.svelte b/_old/features/discover/components/Search.svelte deleted file mode 100644 index c31dfae..0000000 --- a/_old/features/discover/components/Search.svelte +++ /dev/null @@ -1,327 +0,0 @@ - - -
-
- Search - -
- {#if anims && tabIndicator.width > 0} - - {/if} - - - -
-
- - {#if tab === "keyword"} - (pendingPrefill = "")} - onPreview={setPreviewManga} - /> - {:else if tab === "tag"} - - {:else} - - {/if} -
- - \ No newline at end of file diff --git a/_old/features/discover/components/SourceTab.svelte b/_old/features/discover/components/SourceTab.svelte deleted file mode 100644 index 0aa3b6c..0000000 --- a/_old/features/discover/components/SourceTab.svelte +++ /dev/null @@ -1,369 +0,0 @@ - - -
-
-
- Language - -
- - {#if loadingSources} -
- -
- {:else} -
- {#if localSource} - -
- {/if} - - {#if pinnedSources.length > 0} - - {#each pinnedSources as src (src.id)} - - {/each} -
- - {/if} - - {#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} -
-
- -{#if ctx_source} - {@const isPinned = pinnedIds.includes(ctx_source.id)} - { store.togglePinnedSource(ctx_source!.id); }, - }, - { separator: true }, - { - label: "Browse source", - icon: ArrowRight, - onClick: () => { srcSelectSource(ctx_source!); }, - }, - ]} - /> -{/if} - - \ No newline at end of file diff --git a/_old/features/discover/components/TagTab.svelte b/_old/features/discover/components/TagTab.svelte deleted file mode 100644 index 42c153d..0000000 --- a/_old/features/discover/components/TagTab.svelte +++ /dev/null @@ -1,474 +0,0 @@ - - -
- -
-
- - - {#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/_old/features/discover/index.ts b/_old/features/discover/index.ts deleted file mode 100644 index d086c34..0000000 --- a/_old/features/discover/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Search } from "./components/Search.svelte"; -export * from "./lib/searchFilter"; diff --git a/_old/features/discover/lib/searchFilter.ts b/_old/features/discover/lib/searchFilter.ts deleted file mode 100644 index 50164c2..0000000 --- a/_old/features/discover/lib/searchFilter.ts +++ /dev/null @@ -1,134 +0,0 @@ -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/_old/features/downloads/components/DownloadItem.svelte b/_old/features/downloads/components/DownloadItem.svelte deleted file mode 100644 index 8e5437f..0000000 --- a/_old/features/downloads/components/DownloadItem.svelte +++ /dev/null @@ -1,140 +0,0 @@ - - -
{ e.stopPropagation(); onSelect(item.chapter.id, e); }} - onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }} -> - {#if manga?.thumbnailUrl} -
- -
- {/if} - -
- {#if manga?.title}{manga.title}{/if} - {item.chapter.name} - {#if pages > 0} -
-
-
-
- - {#if isActive} - {prog.done}/{prog.total} - {:else if isError} - failed · {item.tries} {item.tries === 1 ? "try" : "tries"} - {:else} - {prog.total}p - {/if} - -
- {/if} -
- -
- {item.state} -
- {#if isError} - - {/if} - {#if !isActive} - - {/if} -
-
-
- - \ No newline at end of file diff --git a/_old/features/downloads/components/DownloadQueue.svelte b/_old/features/downloads/components/DownloadQueue.svelte deleted file mode 100644 index a543f33..0000000 --- a/_old/features/downloads/components/DownloadQueue.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - -{#if loading} -
- {#each Array(5) as _, i (i)} -
-
- -
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- {/each} -
-{:else if queue.length === 0} -
Queue is empty.
-{:else} -
- {#each queue as item, i (item.chapter.id)} - - {/each} -
-{/if} - - \ No newline at end of file diff --git a/_old/features/downloads/components/Downloads.svelte b/_old/features/downloads/components/Downloads.svelte deleted file mode 100644 index a642ee6..0000000 --- a/_old/features/downloads/components/Downloads.svelte +++ /dev/null @@ -1,230 +0,0 @@ - - -
-
-

Downloads

-
- - {#if downloadStore.hasErrored} - - {/if} - - - -
-
- -
-
-
- - {downloadStore.togglingPlay - ? (downloadStore.isRunning ? "Pausing…" : "Starting…") - : downloadStore.isRunning ? "Downloading" : "Paused"} - - {#if downloadStore.selected.size > 0} -
- -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none"> - - e.stopPropagation()} - onkeydown={(e) => e.stopPropagation()} - /> - -
- - {#if selectedErrorCount > 0} - - {/if} - -
-
- {downloadStore.selected.size} selected - {:else} -
- {#if downloadStore.isRunning && downloadStore.eta !== null} - {formatEta(downloadStore.eta)} left - {/if} - {downloadStore.queue.length} queued -
- {/if} -
-
- -
e.key === 'Escape' && handleClickOff()}> - downloadStore.dequeue(id)} - onRetry={(id) => downloadStore.retryOne(id)} - onReorder={(id, dir) => downloadStore.reorder(id, dir)} - onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)} - onSelect={handleSelect} - /> -
-
- - \ No newline at end of file diff --git a/_old/features/downloads/index.ts b/_old/features/downloads/index.ts deleted file mode 100644 index 7c55a51..0000000 --- a/_old/features/downloads/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { downloadStore } from "./store/downloadState.svelte"; -export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue"; diff --git a/_old/features/downloads/lib/autoRetry.ts b/_old/features/downloads/lib/autoRetry.ts deleted file mode 100644 index 90d0937..0000000 --- a/_old/features/downloads/lib/autoRetry.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DownloadQueueItem } from "@types/index"; - -const RETRY_DELAY_MS = 20_000; - -export interface AutoRetryHandle { - stop: () => void; -} - -export function startAutoRetry( - getQueue: () => DownloadQueueItem[], - isRunning: () => boolean, - retryErrored: () => Promise, -): AutoRetryHandle { - let stopped = false; - let timer: ReturnType | null = null; - - async function tick() { - if (stopped) return; - - const queue = getQueue(); - const errored = queue.filter(i => i.state === "ERROR"); - const active = queue.filter(i => i.state !== "ERROR"); - - if (errored.length > 0 && active.length === 0 && !isRunning()) { - await retryErrored().catch(() => {}); - } - - if (!stopped) timer = setTimeout(tick, RETRY_DELAY_MS); - } - - timer = setTimeout(tick, RETRY_DELAY_MS); - - return { - stop() { - stopped = true; - if (timer !== null) { clearTimeout(timer); timer = null; } - }, - }; -} \ No newline at end of file diff --git a/_old/features/downloads/lib/downloadQueue.ts b/_old/features/downloads/lib/downloadQueue.ts deleted file mode 100644 index 7b28b70..0000000 --- a/_old/features/downloads/lib/downloadQueue.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 optimisticRemoveMany(queue: DownloadQueueItem[], chapterIds: Set): DownloadQueueItem[] { - return queue.filter((i) => !chapterIds.has(i.chapter.id)); -} - -export function isRunning(state: string | undefined): boolean { - return state === "STARTED"; -} - -export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] { - return queue.filter((i) => i.state === "ERROR"); -} - -export function pageProgress(progress: number, pageCount: number): { done: number; total: number } { - return { done: Math.round(progress * pageCount), total: pageCount }; -} - -export interface SpeedSample { - ts: number; - progress: number; - pages: number; -} - -export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null { - if (!prev) return null; - const dt = (current.ts - prev.ts) / 1000; - if (dt <= 0) return null; - const prevDone = Math.round(prev.progress * prev.pages); - const curDone = Math.round(current.progress * current.pages); - const delta = curDone - prevDone; - if (delta <= 0) return null; - return delta / dt; -} - -export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null { - if (pagesPerSec <= 0 || queue.length === 0) return null; - let remaining = 0; - for (const item of queue) { - const pages = item.chapter.pageCount ?? 0; - remaining += pages - Math.round(item.progress * pages); - } - return remaining / pagesPerSec; -} - -export function reorderSelectedToEdge( - queue: DownloadQueueItem[], - selected: Set, - edge: "top" | "bottom", -): DownloadQueueItem[] { - const pinned = queue.filter((i) => selected.has(i.chapter.id)); - const rest = queue.filter((i) => !selected.has(i.chapter.id)); - return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned]; -} - -const AVG_BYTES_PER_PAGE = 1_500_000; - -export function estimateQueueBytes(queue: DownloadQueueItem[]): number { - let total = 0; - for (const item of queue) { - const pages = item.chapter.pageCount ?? 0; - const remaining = pages - Math.round(item.progress * pages); - total += remaining * AVG_BYTES_PER_PAGE; - } - return total; -} - -export function formatEta(seconds: number): string { - if (seconds < 60) return `~${Math.ceil(seconds)}s`; - if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`; - return `~${(seconds / 3600).toFixed(1)}h`; -} \ No newline at end of file diff --git a/_old/features/downloads/store/downloadState.svelte.ts b/_old/features/downloads/store/downloadState.svelte.ts deleted file mode 100644 index 5dd7d87..0000000 --- a/_old/features/downloads/store/downloadState.svelte.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { gql } from "@api/client"; -import { GET_DOWNLOAD_STATUS } from "@api/queries"; -import { - START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, - DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD, - ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD, -} from "@api/mutations"; -import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte"; -import { boot } from "@store/boot.svelte"; -import type { DownloadStatus, DownloadQueueItem } from "@types/index"; -import { - toActiveDownloads, optimisticRemove, optimisticRemoveMany, - isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes, - type SpeedSample, -} from "../lib/downloadQueue"; -import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry"; -import { invoke } from "@tauri-apps/api/core"; - -class DownloadStore { - status: DownloadStatus | null = $state(null); - loading = $state(true); - togglingPlay = $state(false); - clearing = $state(false); - dequeueing = $state(new Set()); - selected = $state(new Set()); - batchWorking = $state(false); - pagesPerSec: number | null = $state(null); - eta: number | null = $state(null); - storageWarning: boolean = $state(false); - - private freeBytes: number | null = null; - - get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; } - get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; } - - private lastSample: SpeedSample | null = null; - private prevQueue: DownloadQueueItem[] = []; - private autoRetryHnd: AutoRetryHandle | null = null; - - get queue() { return this.status?.queue ?? []; } - get isRunning() { return isRunning(this.status?.state); } - get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); } - get hasErrored() { return this.erroredIds.size > 0; } - - toggleToasts() { - const next = !this.toastsEnabled; - updateSettings({ downloadToastsEnabled: next }); - addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 }); - } - - toggleAutoRetry() { - if (this.autoRetryEnabled) { - this.autoRetryHnd?.stop(); - this.autoRetryHnd = null; - updateSettings({ downloadAutoRetry: false }); - addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 }); - } else { - updateSettings({ downloadAutoRetry: true }); - this.autoRetryHnd = startAutoRetry( - () => this.queue, - () => this.isRunning, - () => this.retryAllErrored(), - ); - addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 }); - } - } - - detectTransitions(next: DownloadQueueItem[]) { - if (!this.toastsEnabled) return; - const nextMap = new Map(next.map(i => [i.chapter.id, i])); - for (const item of this.prevQueue) { - if (item.state !== "DOWNLOADING") continue; - const nextItem = nextMap.get(item.chapter.id); - const manga = item.chapter.manga; - const label = manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name; - if (!nextItem) { - addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 }); - } else if (nextItem.state === "ERROR") { - addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 }); - } - } - this.prevQueue = next.slice(); - } - - applyStatus(ds: DownloadStatus) { - this.status = ds; - setActiveDownloads(toActiveDownloads(ds.queue)); - this.updateSpeed(ds); - this.fetchFreeBytes(ds); - } - - private async fetchFreeBytes(ds: DownloadStatus) { - const path = store.settings.serverDownloadsPath ?? ""; - if (!path) return; - try { - const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path }); - this.freeBytes = info.free_bytes; - this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95; - } catch { } - } - - private confirmStorageOverrun(): Promise { - return new Promise(resolve => { - const backdrop = document.createElement("div"); - backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both"; - const panel = document.createElement("div"); - panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both"; - panel.innerHTML = ` -
-

Low disk space

-
-
-

- The download queue is estimated to exceed 95% of your available storage. Download anyway? -

-
-
- - -
- `; - backdrop.appendChild(panel); - document.body.appendChild(backdrop); - function finish(result: boolean) { backdrop.remove(); resolve(result); } - panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false)); - panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true)); - backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); }); - }); - } - - private async guardStorage(queueAfter: DownloadQueueItem[]): Promise { - if (this.freeBytes === null) return true; - if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true; - return this.confirmStorageOverrun(); - } - - private updateSpeed(ds: DownloadStatus) { - const active = ds.queue[0]; - if (!active || active.state !== "DOWNLOADING") { - this.lastSample = null; - this.pagesPerSec = null; - this.eta = null; - return; - } - const sample: SpeedSample = { - ts: Date.now(), - progress: active.progress, - pages: active.chapter.pageCount ?? 0, - }; - const speed = calcSpeed(this.lastSample, sample); - this.lastSample = sample; - if (speed !== null) { - this.pagesPerSec = speed; - this.eta = estimateEta(speed, ds.queue); - } - } - - async poll() { - if (boot.sessionExpired) return; - 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; - addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 }); - } - } - - async clear() { - if (this.clearing) return; - this.clearing = true; - this.selected = new Set(); - if (this.status) this.status = { ...this.status, queue: [] }; - setActiveDownloads([]); - try { - const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER); - this.applyStatus(d.clearDownloader.downloadStatus); - addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 }); - } 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) }; - this.selected.delete(chapterId); - this.selected = new Set(this.selected); - 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); } - } - - async dequeueSelected() { - if (this.batchWorking || this.selected.size === 0) return; - this.batchWorking = true; - const ids = [...this.selected]; - if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) }; - this.selected = new Set(); - try { - await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); - this.poll(); - addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 }); - } catch (e) { console.error(e); this.poll(); } - finally { this.batchWorking = false; } - } - - async enqueue(chapterId: number): Promise { - const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any]; - if (!(await this.guardStorage(projected))) return false; - try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); } - catch (e) { console.error(e); } - return true; - } - - async retryOne(chapterId: number) { - if (this.dequeueing.has(chapterId)) return; - this.dequeueing = new Set(this.dequeueing).add(chapterId); - try { - await gql(DEQUEUE_DOWNLOAD, { chapterId }); - const projected = this.queue.filter(i => i.chapter.id !== chapterId); - if (!(await this.guardStorage(projected))) { this.poll(); return; } - await gql(ENQUEUE_DOWNLOAD, { chapterId }); - this.poll(); - } catch (e) { console.error(e); this.poll(); } - finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); } - } - - async retryAllErrored() { - if (this.batchWorking || !this.hasErrored) return; - this.batchWorking = true; - const ids = [...this.erroredIds]; - try { - await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); - const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id)); - if (!(await this.guardStorage(projected))) { this.poll(); return; } - for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); - this.poll(); - addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 }); - } catch (e) { console.error(e); this.poll(); } - finally { this.batchWorking = false; } - } - - async retrySelected() { - if (this.batchWorking || this.selected.size === 0) return; - this.batchWorking = true; - const ids = [...this.selected].filter((id) => this.erroredIds.has(id)); - this.selected = new Set(); - try { - if (ids.length > 0) { - await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); - const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id)); - if (!(await this.guardStorage(projected))) { this.poll(); return; } - for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); - addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 }); - } - this.poll(); - } catch (e) { console.error(e); this.poll(); } - finally { this.batchWorking = false; } - } - - async reorder(chapterId: number, direction: "up" | "down") { - const idx = this.queue.findIndex((i) => i.chapter.id === chapterId); - if (idx === -1) return; - const to = direction === "up" ? idx - 1 : idx + 1; - if (to < 0 || to >= this.queue.length) return; - const newQueue = [...this.queue]; - [newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]]; - if (this.status) this.status = { ...this.status, queue: newQueue }; - try { - const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( - REORDER_DOWNLOAD, { chapterId, to }, - ); - this.applyStatus(d.reorderChapterDownload.downloadStatus); - } catch (e) { console.error(e); this.poll(); } - } - - async reorderSelected(direction: "up" | "down") { - if (this.batchWorking || this.selected.size === 0) return; - this.batchWorking = true; - - const queue = [...this.queue]; - const selectedIndices = queue - .map((item, i) => ({ id: item.chapter.id, i })) - .filter(({ id }) => this.selected.has(id)) - .map(({ i }) => i) - .sort((a, b) => direction === "up" ? a - b : b - a); - - if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; } - if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; } - - const newQueue = [...queue]; - for (const idx of selectedIndices) { - const to = direction === "up" ? idx - 1 : idx + 1; - if (to < 0 || to >= newQueue.length) break; - [newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]]; - } - if (this.status) this.status = { ...this.status, queue: newQueue }; - - try { - for (const idx of selectedIndices) { - const to = direction === "up" ? idx - 1 : idx + 1; - if (to < 0 || to >= queue.length) break; - const chapterId = queue[idx].chapter.id; - await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( - REORDER_DOWNLOAD, { chapterId, to }, - ); - } - this.poll(); - } catch (e) { console.error(e); this.poll(); } - finally { this.batchWorking = false; } - } - - async reorderToEdge(chapterId: number, edge: "top" | "bottom") { - const idx = this.queue.findIndex((i) => i.chapter.id === chapterId); - if (idx === -1) return; - const first = this.isRunning ? 1 : 0; - const last = this.queue.length - 1; - const to = edge === "top" ? first : last; - if (idx === to) return; - const newQueue = [...this.queue]; - newQueue.splice(idx, 1); - newQueue.splice(to, 0, this.queue[idx]); - if (this.status) this.status = { ...this.status, queue: newQueue }; - try { - const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( - REORDER_DOWNLOAD, { chapterId, to }, - ); - this.applyStatus(d.reorderChapterDownload.downloadStatus); - } catch (e) { console.error(e); this.poll(); } - } - - async reorderSelectedToEdge(edge: "top" | "bottom") { - if (this.batchWorking || this.selected.size === 0) return; - this.batchWorking = true; - - const first = this.isRunning ? 1 : 0; - const active = this.queue.slice(0, first); - const moveable = this.queue.slice(first); - const pinned = moveable.filter((i) => this.selected.has(i.chapter.id)); - const rest = moveable.filter((i) => !this.selected.has(i.chapter.id)); - const newQueue = edge === "top" - ? [...active, ...pinned, ...rest] - : [...active, ...rest, ...pinned]; - if (this.status) this.status = { ...this.status, queue: newQueue }; - - const last = this.queue.length - 1; - - try { - if (edge === "top") { - for (let i = 0; i < pinned.length; i++) { - await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( - REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i }, - ); - } - } else { - for (let i = 0; i < pinned.length; i++) { - await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( - REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) }, - ); - } - } - this.poll(); - } catch (e) { console.error(e); this.poll(); } - finally { this.batchWorking = false; } - } - - selectOnly(chapterId: number) { this.selected = new Set([chapterId]); } - toggleSelect(chapterId: number) { - const next = new Set(this.selected); - if (next.has(chapterId)) next.delete(chapterId); - else next.add(chapterId); - this.selected = next; - } - - selectRange(fromId: number, toId: number) { - const ids = this.queue.map((i) => i.chapter.id); - const a = ids.indexOf(fromId), b = ids.indexOf(toId); - if (a === -1 || b === -1) return; - const [lo, hi] = a < b ? [a, b] : [b, a]; - const next = new Set(this.selected); - for (let i = lo; i <= hi; i++) next.add(ids[i]); - this.selected = next; - } - - selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); } - clearSelection() { this.selected = new Set(); } -} - -export const downloadStore = new DownloadStore(); \ No newline at end of file diff --git a/_old/features/extensions/components/ExtensionCard.svelte b/_old/features/extensions/components/ExtensionCard.svelte deleted file mode 100644 index 513d620..0000000 --- a/_old/features/extensions/components/ExtensionCard.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - -
- onLibrary(primary.pkgName, base, primary.iconUrl) : undefined} - > - ((e.target as HTMLImageElement).style.display = "none")} - /> -
- {base} - - {primary.lang.toUpperCase()} - {#if primary.isInstalled} - - 0 ? "fill" : "regular"} /> - {libraryCount > 0 ? libraryCount : 0} - - - {/if} - 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} -
- - \ No newline at end of file diff --git a/_old/features/extensions/components/ExtensionFilters.svelte b/_old/features/extensions/components/ExtensionFilters.svelte deleted file mode 100644 index e0901fc..0000000 --- a/_old/features/extensions/components/ExtensionFilters.svelte +++ /dev/null @@ -1,115 +0,0 @@ - - -
-

Extensions

- -
- {#if anims && tabIndicator.width > 0} - - {/if} - {#each FILTERS as f} - - {/each} -
- -
-
- - onSearch((e.target as HTMLInputElement).value)} /> -
- - - - {#if updateCount > 0} - - {/if} -
-
- -{#if availableLangs.length > 1} -
- - {#each availableLangs as lang} - - {/each} -
-{/if} - - \ No newline at end of file diff --git a/_old/features/extensions/components/ExtensionLibrary.svelte b/_old/features/extensions/components/ExtensionLibrary.svelte deleted file mode 100644 index 3d6daa9..0000000 --- a/_old/features/extensions/components/ExtensionLibrary.svelte +++ /dev/null @@ -1,333 +0,0 @@ - - -
-
- - {#if iconUrl} - ((e.target as HTMLImageElement).style.display = "none")} /> - {/if} -
- In Library - {extensionName} -
- {#if !loading} - {filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""} - {/if} -
-
- - -
- -
- - {#if filterOpen} - - {/if} -
- - {#if sources.length > 0} - - {/if} -
-
- -
- {#if loading} -
- {#each Array(12) as _} -
-
-
-
- {/each} -
- {:else if filtered.length === 0} -
- {allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."} -
- {:else} - {#if groups.length > 1} -
- {#each groups as group} -
- {group.displayName} - {group.manga.length} - -
- {/each} -
- {:else if groups.length === 1} -
- {groups[0].displayName} - -
- {/if} - -
- {#each filtered as m (m.id)} - {@const isCompleted = !m.unreadCount && m.downloadCount > 0} - - {/each} -
- {/if} -
-
- -{#if migrateTarget} - migrateTarget = null} - onDone={() => { migrateTarget = null; load(); }} - /> -{/if} - - \ No newline at end of file diff --git a/_old/features/extensions/components/Extensions.svelte b/_old/features/extensions/components/Extensions.svelte deleted file mode 100644 index 2495b0d..0000000 --- a/_old/features/extensions/components/Extensions.svelte +++ /dev/null @@ -1,416 +0,0 @@ - - -{#if libraryTarget} - libraryTarget = null} - onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }} - /> -{:else} -
- search = q} - onLang={(l) => langFilter = l} - onPanel={openPanel} - onRefresh={fetchFromRepo} - onUpdateAll={updateAll} - /> - - {#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 showLocal} -
-
-
- Local Source - Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"} -
- Built-in -
- {/if} - {#each groups as { base, primary, variants }} - libraryTarget = { pkgName, extensionName, iconUrl }} - /> - {/each} - {#if !showLocal && groups.length === 0} -
No extensions found.
- {/if} -
- {/if} -
-{/if} - -{#if settingsTarget} - settingsTarget = null} - /> -{/if} - - \ No newline at end of file diff --git a/_old/features/extensions/index.ts b/_old/features/extensions/index.ts deleted file mode 100644 index 173f6cd..0000000 --- a/_old/features/extensions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Extensions } from "./components/Extensions.svelte"; -export * from "./lib/extensionHelpers"; diff --git a/_old/features/extensions/lib/extensionHelpers.ts b/_old/features/extensions/lib/extensionHelpers.ts deleted file mode 100644 index 9394178..0000000 --- a/_old/features/extensions/lib/extensionHelpers.ts +++ /dev/null @@ -1,55 +0,0 @@ -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/_old/features/extensions/lib/extensionLibrary.ts b/_old/features/extensions/lib/extensionLibrary.ts deleted file mode 100644 index ba6df21..0000000 --- a/_old/features/extensions/lib/extensionLibrary.ts +++ /dev/null @@ -1,56 +0,0 @@ -export interface LibraryManga { - id: number; - title: string; - thumbnailUrl: string; - unreadCount: number; - downloadCount: number; - source: { id: string; displayName: string }; -} - -export interface SourceLibrary { - sourceId: string; - displayName: string; - manga: LibraryManga[]; -} - -export type SourceNode = { - id: string; - displayName: string; - isConfigurable: boolean; - extension: { pkgName: string }; -}; - -export function libraryByExtension( - libraryManga: LibraryManga[], - sources: SourceNode[], - pkgName: string, -): SourceLibrary[] { - const pkgSources = sources.filter(s => s.extension?.pkgName === pkgName); - const sourceIds = new Set(pkgSources.map(s => s.id)); - - const bySource = new Map(); - for (const src of pkgSources) bySource.set(src.id, []); - for (const m of libraryManga) { - if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m); - } - - return pkgSources - .map(src => ({ sourceId: src.id, displayName: src.displayName, manga: bySource.get(src.id)! })) - .filter(g => g.manga.length > 0); -} - -export function libraryCountByPkg( - libraryManga: LibraryManga[], - sources: SourceNode[], -): Record { - const sourceIdToPkg = new Map(); - for (const s of sources) { - if (s.extension?.pkgName) sourceIdToPkg.set(s.id, s.extension.pkgName); - } - const counts: Record = {}; - for (const m of libraryManga) { - const pkg = sourceIdToPkg.get(m.source.id); - if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1; - } - return counts; -} \ No newline at end of file diff --git a/_old/features/extensions/panels/ExtensionSettingsPanel.svelte b/_old/features/extensions/panels/ExtensionSettingsPanel.svelte deleted file mode 100644 index 3f34dfc..0000000 --- a/_old/features/extensions/panels/ExtensionSettingsPanel.svelte +++ /dev/null @@ -1,526 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/_old/features/extensions/panels/SourceMigrateModal.svelte b/_old/features/extensions/panels/SourceMigrateModal.svelte deleted file mode 100644 index 74d1126..0000000 --- a/_old/features/extensions/panels/SourceMigrateModal.svelte +++ /dev/null @@ -1,448 +0,0 @@ - - - -
{ if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}> - -
- - \ No newline at end of file diff --git a/_old/features/home/components/ActivityFeed.svelte b/_old/features/home/components/ActivityFeed.svelte deleted file mode 100644 index 918245c..0000000 --- a/_old/features/home/components/ActivityFeed.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - -
-
- Recent Activity - {#if entries.length > 0} - - {/if} -
- -
- {#if entries.length > 0} - {#each entries as entry (entry.id)} - - {/each} - {:else} -
- {#each Array(5) as _, i} -
-
-
-
-
-
-
-
- {/each} -
- -
-
- {/if} -
-
- - \ No newline at end of file diff --git a/_old/features/home/components/ActivityHeatmap.svelte b/_old/features/home/components/ActivityHeatmap.svelte deleted file mode 100644 index 4779c1f..0000000 --- a/_old/features/home/components/ActivityHeatmap.svelte +++ /dev/null @@ -1,285 +0,0 @@ - - -
- -
-
-
- {#each visibleWeeks as _week, ci} - {@const lbl = monthLabels.find(l => l.colIndex === ci)} -
{lbl?.label ?? ""}
- {/each} -
-
- -
-
- {#each DAY_LABELS as d} - {d} - {/each} -
-
- {#each visibleWeeks as week} -
- {#each week as cell} - - - {/each} -
- {/each} -
-
- -
- Less - {#each [0, 1, 2, 3, 4] as lvl} -
- {/each} - More -
- -
- -{#if tip} -
{tip.text}
-{/if} - - \ No newline at end of file diff --git a/_old/features/home/components/HeroSlotPicker.svelte b/_old/features/home/components/HeroSlotPicker.svelte deleted file mode 100644 index f30ac77..0000000 --- a/_old/features/home/components/HeroSlotPicker.svelte +++ /dev/null @@ -1,194 +0,0 @@ - - - - - diff --git a/_old/features/home/components/HeroStage.svelte b/_old/features/home/components/HeroStage.svelte deleted file mode 100644 index b1da029..0000000 --- a/_old/features/home/components/HeroStage.svelte +++ /dev/null @@ -1,454 +0,0 @@ - - -
- {#key heroThumb} - {#if heroThumb} -
- {:else} -
- {/if} - {/key} -
- - - -
- {#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} - {#if heroNewChapter && !heroNewChapter.isRead} - New ch.{Math.floor(heroNewChapter.chapterNumber)} - {/if} - {#each (heroManga?.genre ?? []).slice(0, 3) as g} - - {/each} -
- -

{heroTitle}

- {#if heroManga?.author}

{heroManga.author}

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

- - {heroEntry.endChapterName} - {#if heroEntry.endPage > 1} · p.{heroEntry.endPage}{/if} - {timeAgo(heroEntry.endedAt)} -

- {/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?.endChapterId === ch.id} - - {/each} - {#if heroManga} - - {/if} - {/if} -
-
- - \ No newline at end of file diff --git a/_old/features/home/components/Home.svelte b/_old/features/home/components/Home.svelte deleted file mode 100644 index 30bf3fb..0000000 --- a/_old/features/home/components/Home.svelte +++ /dev/null @@ -1,376 +0,0 @@ - - -
-
- -
- { if (heroManga) store.activeManga = heroManga; }} - /> -
- -
-
-
- setNavPage("history")} - onopenlibrary={() => setNavPage("library")} - /> -
-
-
- { store.previewManga = m; }} - /> -
-
- -
-
- Activity - -
-
-
- -
-
-
- -
-
- -{#if pickerOpen && pickerSlotIndex !== null} - -{/if} - - \ No newline at end of file diff --git a/_old/features/home/components/RecsRow.svelte b/_old/features/home/components/RecsRow.svelte deleted file mode 100644 index 2cce4a3..0000000 --- a/_old/features/home/components/RecsRow.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -
-
- Recommended -
-

Recommendations coming soon

-
- - \ No newline at end of file diff --git a/_old/features/home/components/StatsGrid.svelte b/_old/features/home/components/StatsGrid.svelte deleted file mode 100644 index d9b44c6..0000000 --- a/_old/features/home/components/StatsGrid.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - -
-
- 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 -
-
-
-
- - \ No newline at end of file diff --git a/_old/features/home/index.ts b/_old/features/home/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/_old/features/home/lib/homeHelpers.ts b/_old/features/home/lib/homeHelpers.ts deleted file mode 100644 index bbcc961..0000000 --- a/_old/features/home/lib/homeHelpers.ts +++ /dev/null @@ -1,35 +0,0 @@ -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/_old/features/home/lib/recommendations.ts b/_old/features/home/lib/recommendations.ts deleted file mode 100644 index 05a0c87..0000000 --- a/_old/features/home/lib/recommendations.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { gql } from "@api/client"; -import { MANGAS_BY_GENRE } from "@api/queries/manga"; -import { buildTagFilter } from "@features/discover/lib/searchFilter"; -import type { Manga } from "@types"; -import type { HistoryEntry } from "@store/state.svelte"; - -export interface RecommendedManga { - manga: Manga; - matchedGenres: string[]; -} - -const TOP_GENRES = 6; -const PAGE_SIZE = 100; -const MAX_PAGES = 10; -const TARGET_PER_GENRE = 20; -const EXCLUDED_STATUSES = ["CANCELLED", "ABANDONED"]; - -export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] { - const byId = new Map(libraryManga.map(m => [m.id, m])); - const tally = new Map(); - - for (const entry of history) { - const manga = byId.get(entry.mangaId); - if (!manga?.genre?.length) continue; - for (const g of manga.genre) { - const key = g.toLowerCase(); - const existing = tally.get(key); - if (existing) { existing.count++; } - else { tally.set(key, { count: 1, original: g }); } - } - } - - return [...tally.values()] - .sort((a, b) => b.count - a.count) - .slice(0, TOP_GENRES) - .map(e => e.original); -} - -type Result = { mangas: { nodes: Manga[] } }; - -async function fetchGenrePages( - genre: string, - globalSeen: Set, - signal?: AbortSignal, -): Promise { - const filter = { - and: [ - buildTagFilter([genre], "OR", []), - { inLibrary: { equalTo: false } }, - ], - }; - - const localSeen = new Set(); - const nodes: Manga[] = []; - - for (let page = 0; page < MAX_PAGES; page++) { - if (signal?.aborted) break; - - let batch: Manga[]; - try { - const d = await gql(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: page * PAGE_SIZE }, signal); - batch = d.mangas.nodes; - } catch { - break; - } - - if (!batch.length) break; - - for (const m of batch) { - if (localSeen.has(m.id) || globalSeen.has(m.id)) continue; - if (EXCLUDED_STATUSES.includes(m.status ?? "")) continue; - localSeen.add(m.id); - nodes.push(m); - } - - if (nodes.length >= TARGET_PER_GENRE) break; - if (batch.length < PAGE_SIZE) break; - } - - return nodes; -} - -export async function fetchRecommendations( - history: HistoryEntry[], - libraryManga: Manga[], - signal?: AbortSignal, -): Promise { - if (!history.length || !libraryManga.length) return []; - - const genres = topGenres(history, libraryManga); - if (!genres.length) return []; - - const globalSeen = new Set(); - const merged: Manga[] = []; - - for (const genre of genres) { - const results = await fetchGenrePages(genre, globalSeen, signal); - for (const m of results) { - globalSeen.add(m.id); - merged.push(m); - } - } - - return merged.map(m => ({ - manga: m, - matchedGenres: (m.genre ?? []).filter(g => - genres.some(tg => tg.toLowerCase() === g.toLowerCase()) - ), - })); -} \ No newline at end of file diff --git a/_old/features/library/components/Library.svelte b/_old/features/library/components/Library.svelte deleted file mode 100644 index 89fbdfe..0000000 --- a/_old/features/library/components/Library.svelte +++ /dev/null @@ -1,753 +0,0 @@ - - - - -{#if ctx} - ctx = null} /> -{/if} -{#if emptyCtx} - emptyCtx = null} /> -{/if} -{#if bulkAutomateOpen} - { bulkAutomateOpen = false; exitSelectMode(); }} - /> -{/if} - - \ No newline at end of file diff --git a/_old/features/library/components/LibraryFilters.svelte b/_old/features/library/components/LibraryFilters.svelte deleted file mode 100644 index f75d854..0000000 --- a/_old/features/library/components/LibraryFilters.svelte +++ /dev/null @@ -1,113 +0,0 @@ - - -
- - - {#if filterPanelOpen} - - {/if} -
- - diff --git a/_old/features/library/components/LibraryGrid.svelte b/_old/features/library/components/LibraryGrid.svelte deleted file mode 100644 index 0a77d0f..0000000 --- a/_old/features/library/components/LibraryGrid.svelte +++ /dev/null @@ -1,219 +0,0 @@ - - -{#if selectMode} -
- {selectedIds.size} selected - - -
- {#if visibleCategories.length} -
- - {#if bulkMoveOpen} -
- {#each visibleCategories as cat} - - {/each} -
- {/if} -
- {/if} - - -
-
-{/if} - - - - \ No newline at end of file diff --git a/_old/features/library/components/LibraryToolbar.svelte b/_old/features/library/components/LibraryToolbar.svelte deleted file mode 100644 index ef32428..0000000 --- a/_old/features/library/components/LibraryToolbar.svelte +++ /dev/null @@ -1,257 +0,0 @@ - - -
- Library - -
- {#each visibleTabIds as id, idx} - {@const cat = visibleCategories.find(c => String(c.id) === id)} - {#if id === "library" || id === "downloaded" || cat} - {@const isBuiltin = id === "library" || id === "downloaded"} - {@const isCompleted = cat && id === String(completedCatId)} - {@const isDraggable = true} - {#if activeDragKind === "tab" && dragInsertIdx === idx} - - {/if} - - {#if activeDragKind === "tab" && dragInsertIdx === idx + 1} - - {/if} - {/if} - {/each} -
- -
-
- - onSearchChange((e.target as HTMLInputElement).value)} /> -
- - {#if refreshing} - - {:else} - - {/if} - - - -
- - {#if sortPanelOpen} - - {/if} -
- - -
-
- - \ No newline at end of file diff --git a/_old/features/library/index.ts b/_old/features/library/index.ts deleted file mode 100644 index 4b21145..0000000 --- a/_old/features/library/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as Library } from "./components/Library.svelte"; -export { sortLibrary, librarySorter } from "./lib/librarySort"; -export * from "./store/libraryState.svelte"; diff --git a/_old/features/library/lib/librarySort.ts b/_old/features/library/lib/librarySort.ts deleted file mode 100644 index 7a9150c..0000000 --- a/_old/features/library/lib/librarySort.ts +++ /dev/null @@ -1,52 +0,0 @@ -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.chapters?.totalCount ?? 0) - (b.chapters?.totalCount ?? 0), - }, - { - key: "recentlyAdded", - comparator: (a, b) => Number(a.inLibraryAt ?? 0) - Number(b.inLibraryAt ?? 0), - }, - { - 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) => Number(a.latestFetchedChapter?.uploadDate ?? 0) - Number(b.latestFetchedChapter?.uploadDate ?? 0), - }, - { - key: "latestUploaded", - comparator: (a, b) => Number(a.latestUploadedChapter?.uploadDate ?? 0) - Number(b.latestUploadedChapter?.uploadDate ?? 0), - }, - ], -}); - -export function sortLibrary( - items: Manga[], - mode: LibrarySortMode, - dir: LibrarySortDir, - recentlyReadMap?: Map, -): Manga[] { - return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined); -} \ No newline at end of file diff --git a/_old/features/library/lib/libraryUpdater.ts b/_old/features/library/lib/libraryUpdater.ts deleted file mode 100644 index 1c7d4a5..0000000 --- a/_old/features/library/lib/libraryUpdater.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { gql } from "@api/client"; -import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga"; -import { UPDATE_LIBRARY, FETCH_MANGA } from "@api/mutations/manga"; -import { GET_LIBRARY } from "@api/queries/manga"; -import type { LibraryUpdateEntry } from "@store/state.svelte"; - -const POLL_INTERVAL_MS = 2000; -const POLL_INITIAL_MS = 500; - -export interface UpdateProgress { - finished: number; - total: number; - skippedManga: number; - skippedCategories: number; -} - -export interface UpdateResult { - entries: LibraryUpdateEntry[]; - totalUpdated: number; - newChapters: number; -} - -export interface LibraryUpdaterCallbacks { - onProgress: (p: UpdateProgress) => void; - onDone: (r: UpdateResult) => void; - onError: (e?: unknown) => void; -} - -export async function refreshLibraryMetadata( - onProgress?: (done: number, total: number) => void, -): Promise { - const data = await gql<{ mangas: { nodes: { id: number }[] } }>(GET_LIBRARY, {}); - const ids = data.mangas.nodes.map(m => m.id); - let done = 0; - for (const id of ids) { - try { - await gql(FETCH_MANGA, { id }); - } catch {} - onProgress?.(++done, ids.length); - } -} - -export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void { - let timer: ReturnType | null = null; - let cancelled = false; - - function cancel() { - cancelled = true; - if (timer) { clearTimeout(timer); timer = null; } - } - - function buildEntries( - mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[] - ): LibraryUpdateEntry[] { - const byManga = new Map(); - for (const u of mangaUpdates) { - if (u.status !== "UPDATED") continue; - const existing = byManga.get(u.manga.id); - if (existing) { - existing.newChapters++; - } else { - byManga.set(u.manga.id, { - mangaId: u.manga.id, - mangaTitle: u.manga.title, - thumbnailUrl: u.manga.thumbnailUrl, - newChapters: 1, - checkedAt: Date.now(), - }); - } - } - return [...byManga.values()]; - } - - async function run() { - let jobsStarted = false; - - try { - const res = await gql<{ - updateLibrary: { - updateStatus: { - jobsInfo: { - isRunning: boolean; - totalJobs: number; - finishedJobs: number; - skippedMangasCount: number; - skippedCategoriesCount: number; - } - } - } - }>(UPDATE_LIBRARY, {}); - if (cancelled) return; - - const { jobsInfo } = res.updateLibrary.updateStatus; - jobsStarted = jobsInfo.totalJobs > 0; - - callbacks.onProgress({ - finished: jobsInfo.finishedJobs, - total: jobsInfo.totalJobs, - skippedManga: jobsInfo.skippedMangasCount, - skippedCategories: jobsInfo.skippedCategoriesCount, - }); - - if (!jobsStarted) { - callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 }); - return; - } - - if (jobsStarted && !jobsInfo.isRunning) { - callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 }); - return; - } - } catch (e) { - console.error("[libraryUpdater] failed to start update", e); - if (!cancelled) callbacks.onError(e); - return; - } - - function poll() { - gql<{ - libraryUpdateStatus: { - jobsInfo: { - isRunning: boolean; - finishedJobs: number; - totalJobs: number; - skippedMangasCount: number; - skippedCategoriesCount: number; - }; - mangaUpdates: { - status: string; - manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number }; - }[]; - } - }>(LIBRARY_UPDATE_STATUS, {}) - .then(async d => { - if (cancelled) return; - const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus; - - if (jobsInfo.totalJobs > 0) jobsStarted = true; - callbacks.onProgress({ - finished: jobsInfo.finishedJobs, - total: jobsInfo.totalJobs, - skippedManga: jobsInfo.skippedMangasCount, - skippedCategories: jobsInfo.skippedCategoriesCount, - }); - - if (!jobsInfo.isRunning && jobsStarted) { - const entries = buildEntries(mangaUpdates); - const newChapters = entries.reduce((s, e) => s + e.newChapters, 0); - callbacks.onDone({ entries, totalUpdated: entries.length, newChapters }); - return; - } - - timer = setTimeout(poll, POLL_INTERVAL_MS); - }) - .catch((e) => { - console.error("[libraryUpdater] poll error", e); - if (!cancelled) callbacks.onError(e); - }); - } - - timer = setTimeout(poll, POLL_INITIAL_MS); - } - - run(); - return cancel; -} \ No newline at end of file diff --git a/_old/features/library/panels/BulkAutomationPanel.svelte b/_old/features/library/panels/BulkAutomationPanel.svelte deleted file mode 100644 index cf45ba6..0000000 --- a/_old/features/library/panels/BulkAutomationPanel.svelte +++ /dev/null @@ -1,318 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_old/features/library/store/libraryState.svelte.ts b/_old/features/library/store/libraryState.svelte.ts deleted file mode 100644 index 412e0e0..0000000 --- a/_old/features/library/store/libraryState.svelte.ts +++ /dev/null @@ -1,45 +0,0 @@ -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/_old/features/reader/components/PageView.svelte b/_old/features/reader/components/PageView.svelte deleted file mode 100644 index 310263d..0000000 --- a/_old/features/reader/components/PageView.svelte +++ /dev/null @@ -1,710 +0,0 @@ - - -
1} - class:midscroll-active={midScrollActive} - style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""} - role="presentation" - tabindex="-1" - onclick={handleTap} - onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }} - ondblclick={() => { if (tapToToggleBar) onToggleUi(); }} - onmousedown={onInspectMouseDown} - onpointerdown={pinchZoomEnabled ? onPointerDown : undefined} - onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }} - style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined} - onkeydown={(e) => { - if (e.key === " " && style === "longstrip") { e.preventDefault(); store.settings.autoScroll = !store.settings.autoScroll; return; } - if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault(); - }} -> - {#if midScrollActive} -
-
- {#each [5,4,3,2,1] as n} -
= n}>
- {/each} -
- {#each [1,2,3,4,5] as n} -
0 && midScrollDisplayLevel >= n}>
- {/each} -
- -
- {/if} - - {#if loading} -
- -
- {/if} - {#if error} -

{error}

- {/if} - - {#key chapterEpoch} - {#if style === "longstrip"} - {#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)} - {@const src = resolvedSrc[gi]} - {@const isLoaded = loadedSet.has(gi)} -
- {#if isLoaded && src} - {page.chapterName} – Page {page.localIndex + 1} { - const img = e.currentTarget as HTMLImageElement; - const slot = img.closest(".strip-slot"); - if (slot && img.naturalWidth > 0) { - slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight)); - } - }} - /> - {:else} - - {/if} -
- {/each} -
- - {:else if style === "fade" && pageReady} -
- {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} - - {:then src} - Page {store.pageNumber} - {/await} -
- - {:else if style === "double" && pageReady} -
- {#if pageGroups.length} -
- {#each currentGroup as pg, i (pg)} - {#await resolveUrl(store.pageUrls[pg - 1], 999)} - - {:then src} - Page {pg} - {/await} - {/each} -
- {:else} -
- -
- {/if} -
- - {:else if pageReady} -
- {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} - - {:then src} - Page {store.pageNumber} - {/await} -
- {/if} - {/key} - -
- - \ No newline at end of file diff --git a/_old/features/reader/components/Reader.svelte b/_old/features/reader/components/Reader.svelte deleted file mode 100644 index 5890e94..0000000 --- a/_old/features/reader/components/Reader.svelte +++ /dev/null @@ -1,646 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_old/features/reader/components/ReaderControls.svelte b/_old/features/reader/components/ReaderControls.svelte deleted file mode 100644 index 3d799d2..0000000 --- a/_old/features/reader/components/ReaderControls.svelte +++ /dev/null @@ -1,625 +0,0 @@ - - -
-
- - - - - - - - - {#if !isVertical} - - {/if} -
- - {#if isVertical && progressBar} -
- {@render progressBar()} -
- {/if} - - {#if !isVertical} -
- {/if} - -
-
-
- -
- -
- -
- - {#if readerState.zoomOpen} -
-
- { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} /> -
- -
- {/if} -
- -
- - - {#if readerState.markerOpen} - - {/if} -
- - - - - - - - - -
- - {#if readerState.winOpen} - - {/if} -
-
-
- - \ No newline at end of file diff --git a/_old/features/reader/components/ReaderOverlay.svelte b/_old/features/reader/components/ReaderOverlay.svelte deleted file mode 100644 index 7ad828a..0000000 --- a/_old/features/reader/components/ReaderOverlay.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -{#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/_old/features/reader/components/ReaderPresetPanel.svelte b/_old/features/reader/components/ReaderPresetPanel.svelte deleted file mode 100644 index ff46e60..0000000 --- a/_old/features/reader/components/ReaderPresetPanel.svelte +++ /dev/null @@ -1,813 +0,0 @@ - - -
e.key === 'Escape' && close()} transition:fade={{ duration: 150 }}>
- - - - \ No newline at end of file diff --git a/_old/features/reader/components/ReaderProgressBar.svelte b/_old/features/reader/components/ReaderProgressBar.svelte deleted file mode 100644 index 334e46a..0000000 --- a/_old/features/reader/components/ReaderProgressBar.svelte +++ /dev/null @@ -1,258 +0,0 @@ - - -{#if !isVertical} -
- - - {#if sliderMax > 1} -
readerState.sliderHover = true} - onmouseleave={() => readerState.sliderHover = false} - > - readerState.sliderDragging = true} - onmouseup={() => readerState.sliderDragging = false} - /> - - - - {#if readerState.sliderHover || readerState.sliderDragging} -
- {sliderPage} / {sliderMax} -
- {/if} -
- {/if} - - -
-{:else} -
- {#if sliderMax > 1} -
readerState.sliderHover = true} - onmouseleave={() => readerState.sliderHover = false} - > - readerState.sliderDragging = true} - onmouseup={() => readerState.sliderDragging = false} - /> - - - - {#if readerState.sliderHover || readerState.sliderDragging} -
- {sliderPage} / {sliderMax} -
- {/if} -
- {/if} -
-{/if} - - \ No newline at end of file diff --git a/_old/features/reader/index.ts b/_old/features/reader/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/_old/features/reader/lib/chapterActions.ts b/_old/features/reader/lib/chapterActions.ts deleted file mode 100644 index 4467736..0000000 --- a/_old/features/reader/lib/chapterActions.ts +++ /dev/null @@ -1,79 +0,0 @@ -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"; -import { trackingState } from "@features/tracking/store/trackingState.svelte"; - -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 ch = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter; - const prefs = getMangaPrefs(); - if (ch) trackingState.updateFromRead(mangaId, ch, store.activeChapterList, prefs); - 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 }); - } -} \ No newline at end of file diff --git a/_old/features/reader/lib/chapterLoader.ts b/_old/features/reader/lib/chapterLoader.ts deleted file mode 100644 index 5793803..0000000 --- a/_old/features/reader/lib/chapterLoader.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { store } from "@store/state.svelte"; -import { readerState } from "../store/readerState.svelte"; -import { fetchPages } from "./pageLoader"; -import { trackingState } from "@features/tracking/store/trackingState.svelte"; -import { cancelQueuedFetches } from "@core/cache/imageCache"; -import { clearResolvedUrlCache } from "@core/cache/pageCache"; - -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; - - cancelQueuedFetches(); - if (useBlob) clearResolvedUrlCache(); - - startAtLastPage.current = false; - markedRead.clear(); - readerState.resetForChapter(); - store.pageUrls = []; - - const mangaId = store.activeManga?.id; - if (mangaId) trackingState.loadForManga(mangaId); - - 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, ctrl.signal).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/_old/features/reader/lib/index.ts b/_old/features/reader/lib/index.ts deleted file mode 100644 index 24aac27..0000000 --- a/_old/features/reader/lib/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -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"; \ No newline at end of file diff --git a/_old/features/reader/lib/navigation.ts b/_old/features/reader/lib/navigation.ts deleted file mode 100644 index 2448e80..0000000 --- a/_old/features/reader/lib/navigation.ts +++ /dev/null @@ -1,84 +0,0 @@ -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/_old/features/reader/lib/pageLoader.ts b/_old/features/reader/lib/pageLoader.ts deleted file mode 100644 index a4e2b9d..0000000 --- a/_old/features/reader/lib/pageLoader.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache } from "@core/cache/pageCache"; - -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; -} \ No newline at end of file diff --git a/_old/features/reader/lib/pinchZoom.ts b/_old/features/reader/lib/pinchZoom.ts deleted file mode 100644 index 0130805..0000000 --- a/_old/features/reader/lib/pinchZoom.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createPinchGesture } from "@core/ui/touchscreen"; -import { clampZoom } from "./zoomHelpers"; -import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte"; - -export interface PinchTrackerOptions { - getZoom: () => number; - setZoom: (z: number) => void; - getInspectScale: () => number; - setInspectScale: (s: number) => void; - resetInspectPan: () => void; - isLongstrip: () => boolean; -} - -export type { PinchGesture as PinchTracker } from "@core/ui/touchscreen"; - -const INSPECT_ZOOM_MAX = 8; - -export function createPinchTracker(opts: PinchTrackerOptions) { - let startZoom = 0; - let startInspect = 0; - - return createPinchGesture({ - onPinch(scale) { - if (startZoom === 0) { - startZoom = opts.getZoom(); - startInspect = opts.getInspectScale(); - } - if (opts.isLongstrip()) { - opts.setZoom(clampZoom(startZoom * scale)); - } else { - const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale)); - if (next !== opts.getInspectScale()) { - if (next === 1) opts.resetInspectPan(); - opts.setInspectScale(next); - } - } - }, - onPinchEnd() { - startZoom = 0; - startInspect = 0; - }, - }); -} \ No newline at end of file diff --git a/_old/features/reader/lib/readerKeybinds.ts b/_old/features/reader/lib/readerKeybinds.ts deleted file mode 100644 index c8c6104..0000000 --- a/_old/features/reader/lib/readerKeybinds.ts +++ /dev/null @@ -1,61 +0,0 @@ -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; - toggleAutoScroll: () => 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(); } - else if (matchesKeybind(e, kb.toggleAutoScroll)) { e.preventDefault(); actions.toggleAutoScroll(); } - }; -} \ No newline at end of file diff --git a/_old/features/reader/lib/scrollHandler.ts b/_old/features/reader/lib/scrollHandler.ts deleted file mode 100644 index 0c08441..0000000 --- a/_old/features/reader/lib/scrollHandler.ts +++ /dev/null @@ -1,111 +0,0 @@ -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; - - let rafId: number | null = null; - - function tick() { - rafId = null; - - 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 lo = 0, hi = imgs.length - 1, best = 0; - while (lo <= hi) { - const mid = (lo + hi) >>> 1; - if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; } - else hi = mid - 1; - } - - const active = imgs[best]; - const activePage = Number(active.dataset.localPage); - const activeChId = Number(active.dataset.chapter); - - onPageChange(activePage); - if (activeChId) onChapterChange(activeChId); - - if (shouldAutoMark() && 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) { - const last = chunks[chunks.length - 1]; - if (last) onMarkRead(last.chapterId); - } - } - - const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; - if (pct >= 0.80) onAppend(); - } - - function onScroll() { - if (rafId !== null) return; - rafId = requestAnimationFrame(tick); - } - - containerEl.addEventListener("scroll", onScroll, { passive: true }); - - return () => { - containerEl.removeEventListener("scroll", onScroll); - if (rafId !== null) cancelAnimationFrame(rafId); - }; -} - -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()); -} \ No newline at end of file diff --git a/_old/features/reader/lib/zoomHelpers.ts b/_old/features/reader/lib/zoomHelpers.ts deleted file mode 100644 index f628f93..0000000 --- a/_old/features/reader/lib/zoomHelpers.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { clampZoom as _clampZoom, captureZoomAnchor, restoreZoomAnchor } from "@core/ui/zoom"; -import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte"; - -export { captureZoomAnchor, restoreZoomAnchor }; - -export function clampZoom(z: number): number { - return _clampZoom(z, ZOOM_MIN, ZOOM_MAX); -} \ No newline at end of file diff --git a/_old/features/reader/store/readerState.svelte.ts b/_old/features/reader/store/readerState.svelte.ts deleted file mode 100644 index 5737aac..0000000 --- a/_old/features/reader/store/readerState.svelte.ts +++ /dev/null @@ -1,110 +0,0 @@ -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); - presetOpen = $state(false); - presetNameInput = $state(""); - 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; } - if (this.presetOpen) { this.presetOpen = 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(); \ No newline at end of file diff --git a/_old/features/recent/components/HistoryPanel.svelte b/_old/features/recent/components/HistoryPanel.svelte deleted file mode 100644 index 3398c1d..0000000 --- a/_old/features/recent/components/HistoryPanel.svelte +++ /dev/null @@ -1,453 +0,0 @@ - - -
- {#if store.history.length === 0} -
-
- -
-

No reading history yet

-

Chapters you read will appear here

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

No results for "{search}"

-
- {:else} -
- {#if store.readingStats.totalChaptersRead > 0} -
-
- Reading Stats -
-
-
-
-
- {store.readingStats.currentStreakDays} - Day streak -
-
-
-
-
- {store.readingStats.totalChaptersRead} - Chapters read -
-
-
-
-
- {formatReadTime(store.readingStats.totalMinutesRead)} - Read time -
-
-
-
-
- {store.readingStats.totalMangaRead} - Series read -
-
-
-
- {/if} - - {#each groups as { label, items }} -
-
- {label} -
-
-
- {#each items as session (session.latestChapterId)} - - {/each} -
-
- {/each} -
- {/if} -
- - \ No newline at end of file diff --git a/_old/features/recent/components/Recent.svelte b/_old/features/recent/components/Recent.svelte deleted file mode 100644 index cb9e716..0000000 --- a/_old/features/recent/components/Recent.svelte +++ /dev/null @@ -1,234 +0,0 @@ - - -
-
- Recent - -
- - -
- -
- {#if tab === "updates"} - - {:else} -
- - historySearch = (e.target as HTMLInputElement).value} - /> - {#if historySearch} - - {/if} -
- {#if store.history.length > 0} - - {/if} - {/if} -
-
- -
- {#if tab === "updates"} - updatesRefreshFn = fn} - /> - {:else} - - {/if} -
-
- - diff --git a/_old/features/recent/components/UpdatesPanel.svelte b/_old/features/recent/components/UpdatesPanel.svelte deleted file mode 100644 index 4539637..0000000 --- a/_old/features/recent/components/UpdatesPanel.svelte +++ /dev/null @@ -1,631 +0,0 @@ - - -
-
-
-
- - {#if loading}Checking for updates…{:else if error}Update check failed{:else if updaterRunning}Library update in progress...{#if updaterProgressLabel} ({updaterProgressLabel}){/if}{:else}Up to date{/if} - -
- {#if !loading && lastUpdatedLabel} - Last updated: {lastUpdatedLabel} -
- {/if} - {#if !loading && updates.length > 0} - {updates.length} chapter{updates.length === 1 ? "" : "s"} - {/if} -
-
-
- - {#if loading && updates.length === 0} - - {:else if error} -
-
- -
-

Couldn't load updates

-

{error}

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

No recent library updates

-

Run a library update to populate this page.

-
- {:else} -
- {#each groups as { label, items } (label)} -
-
- {label} -
-
- -
- {#each items as item (item.id)} -
- - - -
- {/each} -
-
- {/each} -
- {/if} -
- - diff --git a/_old/features/series/components/ChapterList.svelte b/_old/features/series/components/ChapterList.svelte deleted file mode 100644 index 8390008..0000000 --- a/_old/features/series/components/ChapterList.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - -
- {#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} - - \ No newline at end of file diff --git a/_old/features/series/components/SeriesActions.svelte b/_old/features/series/components/SeriesActions.svelte deleted file mode 100644 index 3c79249..0000000 --- a/_old/features/series/components/SeriesActions.svelte +++ /dev/null @@ -1,632 +0,0 @@ - - -
-
- {#if hasSelection} - {selectedCount} selected - - - - - - {:else} -
- - {#if sortMenuOpen} - - {/if} -
- - {/if} -
- -
- -
- - {#if jumpOpen} -
- { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} /> - {#if jumpChapter} - - {:else if jumpInput.trim()} -

No match

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

Loading…

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

No folders yet

- {/if} - {#each allCategories as cat} - {@const isIn = mangaCategories.some(c => c.id === cat.id)} - - {/each} -
- {#if folderCreating} -
- { if (e.key === "Enter") submitNewFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} /> - - -
- {:else} - - {/if} -
- {/if} -
- - - {#if chapters.length > 0} -
- - {#if dlOpen} -
- {#if downloadedCount > 0} - -
- {/if} - {#if continueChapter} - {@const contIdx = sortedChapters.indexOf(continueChapter.chapter)} - {#if contIdx >= 0} - -
- {#each [5, 10, 25] as n} - {@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length} - - {/each} -
-
- {/if} - {/if} - {#if !showRange} - - {:else} -
- - e.key === "Enter" && enqueueRange()} use:focusOnMount /> - - e.key === "Enter" && enqueueRange()} /> - -
- {/if} -
- - - {#if downloadedCount > 0} -
- - {/if} -
- {/if} -
- {/if} - - - {#if totalPages > 1} - - {/if} -
-
- - \ No newline at end of file diff --git a/_old/features/series/components/SeriesDetail.svelte b/_old/features/series/components/SeriesDetail.svelte deleted file mode 100644 index fb6cfb9..0000000 --- a/_old/features/series/components/SeriesDetail.svelte +++ /dev/null @@ -1,765 +0,0 @@ - - -{#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 coverPickerOpen && store.activeManga} - coverPickerOpen = false} - /> -{/if} - -{#if linkPickerOpen && store.activeManga} - -{/if} -{/if} - - \ No newline at end of file diff --git a/_old/features/series/components/SeriesHeader.svelte b/_old/features/series/components/SeriesHeader.svelte deleted file mode 100644 index 0d1a529..0000000 --- a/_old/features/series/components/SeriesHeader.svelte +++ /dev/null @@ -1,386 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_old/features/series/index.ts b/_old/features/series/index.ts deleted file mode 100644 index a17bd27..0000000 --- a/_old/features/series/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/_old/features/series/lib/chapterList.ts b/_old/features/series/lib/chapterList.ts deleted file mode 100644 index 74bc190..0000000 --- a/_old/features/series/lib/chapterList.ts +++ /dev/null @@ -1,79 +0,0 @@ -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/_old/features/series/lib/mangaPrefs.ts b/_old/features/series/lib/mangaPrefs.ts deleted file mode 100644 index e4a6d5e..0000000 --- a/_old/features/series/lib/mangaPrefs.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/_old/features/series/panels/AutomationPanel.svelte b/_old/features/series/panels/AutomationPanel.svelte deleted file mode 100644 index 349ec39..0000000 --- a/_old/features/series/panels/AutomationPanel.svelte +++ /dev/null @@ -1,327 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_old/features/series/panels/CoverPickerPanel.svelte b/_old/features/series/panels/CoverPickerPanel.svelte deleted file mode 100644 index 8aef909..0000000 --- a/_old/features/series/panels/CoverPickerPanel.svelte +++ /dev/null @@ -1,212 +0,0 @@ - - -
{ if (e.target === e.currentTarget) onClose(); }} - onkeydown={(e) => e.key === "Escape" && onClose()} -> - -
- - \ No newline at end of file diff --git a/_old/features/series/panels/MarkersPanel.svelte b/_old/features/series/panels/MarkersPanel.svelte deleted file mode 100644 index 31faa3d..0000000 --- a/_old/features/series/panels/MarkersPanel.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - -
-
-
- - Markers - {#if markers.length > 0} - {markers.length} - {/if} -
- -
- -
- {#if grouped.length === 0} -
- -

No markers yet

-

Mark pages while reading with the marker button or keybind

-
- {:else} - {#each grouped as group} -
-
- {group.chapterName} - {group.items.length} -
- {#each group.items as m (m.id)} -
-
-
- {#if editingId === m.id} -
-
- {#each Object.entries(COLOR_HEX) as [c, hex]} - - {/each} -
- -
- - -
-
- {:else} - -
- - -
- {/if} -
-
- {/each} -
- {/each} - {/if} -
-
- - diff --git a/_old/features/series/panels/MigrateModal.svelte b/_old/features/series/panels/MigrateModal.svelte deleted file mode 100644 index 1d8e37a..0000000 --- a/_old/features/series/panels/MigrateModal.svelte +++ /dev/null @@ -1,533 +0,0 @@ - - - -
{ if (e.target === e.currentTarget) onClose(); }}> - -
- - \ No newline at end of file diff --git a/_old/features/series/panels/SeriesLinkPanel.svelte b/_old/features/series/panels/SeriesLinkPanel.svelte deleted file mode 100644 index d46031f..0000000 --- a/_old/features/series/panels/SeriesLinkPanel.svelte +++ /dev/null @@ -1,237 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_old/features/series/panels/TrackingPanel.svelte b/_old/features/series/panels/TrackingPanel.svelte deleted file mode 100644 index 453e355..0000000 --- a/_old/features/series/panels/TrackingPanel.svelte +++ /dev/null @@ -1,653 +0,0 @@ - - - { if (e.key === "Escape") { if (confirmUnbindId !== null) { confirmUnbindId = null; } else if (editingId !== null) { editingId = null; } else { onClose(); } } }} /> - - - -{#if confirmUnbindId !== null} - {@const rec = records.find(r => r.id === confirmUnbindId)} - {@const trk = rec ? trackerFor(rec.trackerId) : null} -
confirmUnbindId = null} onkeydown={(e) => { if (e.key === 'Escape') confirmUnbindId = null; }}> - -
-{/if} - - \ No newline at end of file diff --git a/_old/features/settings/components/Settings.css b/_old/features/settings/components/Settings.css deleted file mode 100644 index b1ac984..0000000 --- a/_old/features/settings/components/Settings.css +++ /dev/null @@ -1,1383 +0,0 @@ -/* ── 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 } } -@keyframes s-dropdown-in { from { transform: translateY(-6px) scale(0.98); opacity: 0 } to { transform: translateY(0) scale(1); 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; -} - - -/* ── 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, -.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, -.s-toggle-on .s-toggle-thumb { - transform: translateX(15px); - background: var(--bg-void); -} - - -/* ── System theme sync pair ───────────────────────────────────────── */ -.s-sync-pair { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1px; - border-top: 1px solid var(--border-dim); - background: var(--border-dim); -} - -.s-sync-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sp-3); - padding: 8px var(--sp-4); - background: var(--bg-raised); -} - -.s-sync-label { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - flex-shrink: 0; -} - -.s-sync-item .s-select-btn { - font-size: var(--text-xs); - min-width: 0; - padding: 4px 8px; -} - - -/* ── 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; -} - -.s-presets { - display: flex; - gap: var(--sp-2); - padding: var(--sp-3) var(--sp-4); -} - - -/* ── 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: fixed; /* portal sets top/left via inline style */ - min-width: 140px; - 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); - transform-origin: top right; -} -.s-select-menu.anims { animation: s-dropdown-in 0.15s cubic-bezier(0.22,1,0.36,1) both; } - -.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; - -webkit-appearance: none; - appearance: none; - height: 4px; - border-radius: 2px; - background: var(--border-strong); - outline: none; -} -.s-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--accent); - cursor: pointer; - border: none; - box-shadow: 0 1px 4px rgba(0,0,0,0.4); - transition: transform var(--t-fast), box-shadow var(--t-fast); -} -.s-slider::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: 0 2px 8px rgba(0,0,0,0.5); -} -.s-slider::-moz-range-thumb { - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--accent); - cursor: pointer; - border: none; - box-shadow: 0 1px 4px rgba(0,0,0,0.4); -} -.s-slider::-moz-range-track { - height: 4px; - border-radius: 2px; - background: var(--border-strong); -} - -.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); -} - -.s-subsection-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-1); - border-bottom: 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); - border-bottom: 1px solid var(--border-dim); -} -.s-storage-wrap:last-child { border-bottom: none; } - -.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); -} - -.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/_old/features/settings/components/Settings.svelte b/_old/features/settings/components/Settings.svelte deleted file mode 100644 index 9d64210..0000000 --- a/_old/features/settings/components/Settings.svelte +++ /dev/null @@ -1,193 +0,0 @@ - - - \ No newline at end of file diff --git a/_old/features/settings/components/ThemeEditor.svelte b/_old/features/settings/components/ThemeEditor.svelte deleted file mode 100644 index 9215291..0000000 --- a/_old/features/settings/components/ThemeEditor.svelte +++ /dev/null @@ -1,498 +0,0 @@ - - - - -
e.key === "Escape" && onClose()}> - -
- - \ No newline at end of file diff --git a/_old/features/settings/index.ts b/_old/features/settings/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/_old/features/settings/sections/AboutSettings.svelte b/_old/features/settings/sections/AboutSettings.svelte deleted file mode 100644 index 9e115d5..0000000 --- a/_old/features/settings/sections/AboutSettings.svelte +++ /dev/null @@ -1,299 +0,0 @@ - - -
- -
-

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 === "launching"} -
- Launching installer for {targetTag}… -
- {/if} - {#if updatePhase === "ready"} -
- {targetTag} downloaded — restart to finish installing. - - -
- {/if} - {#if updatePhase === "error"} -
- {updateError} - -
- {/if} -
-
- - {#if serverInfo} -
-

Server

-
-
-
- Version - - {serverInfo.version} - {#if serverInfo.buildType} - {serverInfo.buildType} - {/if} - -
-
- {#if serverInfo.buildTime} -
-
- Built - {fmtBuildTime(serverInfo.buildTime)} -
-
- {/if} - {#if webuiInfo?.channel} -
-
- Channel - {webuiInfo.channel} -
-
- {/if} -
-
- {/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

-
-
- GitHub → - Discord → - {#if serverInfo?.github && serverInfo.github !== "https://github.com/moku-project/Moku"} - Suwayomi GitHub → - {/if} - {#if serverInfo?.discord && serverInfo.discord !== "https://discord.gg/Jq3pwuNqPp"} - Suwayomi Discord → - {/if} -
-
-
- -
\ No newline at end of file diff --git a/_old/features/settings/sections/AppearanceSettings.svelte b/_old/features/settings/sections/AppearanceSettings.svelte deleted file mode 100644 index 56dc3a4..0000000 --- a/_old/features/settings/sections/AppearanceSettings.svelte +++ /dev/null @@ -1,171 +0,0 @@ - - -
- -
-
-
- Match system theme - Automatically switch theme when your OS switches between light and dark -
- -
- - {#if store.settings.systemThemeSync} -
-
- Dark theme -
- - {#if selectOpen === "sync-dark" || closingSelect === "sync-dark"} -
- {#each allThemeOptions as opt} - - {/each} -
- {/if} -
-
-
- Light theme -
- - {#if selectOpen === "sync-light" || closingSelect === "sync-light"} -
- {#each allThemeOptions as opt} - - {/each} -
- {/if} -
-
-
- {/if} -
- -
-

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/_old/features/settings/sections/AutomationSettings.svelte b/_old/features/settings/sections/AutomationSettings.svelte deleted file mode 100644 index d07ca84..0000000 --- a/_old/features/settings/sections/AutomationSettings.svelte +++ /dev/null @@ -1,320 +0,0 @@ - - -
- -
-

Behaviour

-
- - - - - - {#if enforceGlobal} -
- - Per-series overrides are paused. Disable enforce to allow custom rules. -
- {/if} - -
-
- -
-

Global Defaults

-
- -

Downloads

- -
-
- Auto-download new chapters - Queue new chapters when a series refreshes -
- -
- -
-
- Download ahead - Pre-fetch chapters while reading -
-
- {#each DOWNLOAD_AHEAD_OPTIONS as opt} - - {/each} -
-
- -
-
- Max chapters to keep - Delete oldest downloads when limit is exceeded -
-
- {#each MAX_KEEP_OPTIONS as opt} - - {/each} -
-
- -

On Read

- -
-
- Delete after reading - Remove download when a chapter is marked read -
- -
- - {#if getGlobal("deleteOnRead")} -
- Delete delay -
- {#each DELETE_DELAY_OPTIONS as opt} - - {/each} -
-
- {/if} - -

Updates

- -
-
- Default refresh interval - How often series check for new chapters by default -
-
- {#each REFRESH_INTERVAL_OPTIONS as opt} - - {/each} -
-
- -
-
- -
-

Custom Overrides

-
- -
-
- Series with custom rules - Per-series settings set via the series automation panel -
- 0}>{customCount} -
- -
-
- Reset all custom rules - Revert every series to the global defaults above -
- {#if confirmReset} -
- - -
- {:else} - - {/if} -
- -
-
- -
- - \ No newline at end of file diff --git a/_old/features/settings/sections/ContentSettings.svelte b/_old/features/settings/sections/ContentSettings.svelte deleted file mode 100644 index 7ec7950..0000000 --- a/_old/features/settings/sections/ContentSettings.svelte +++ /dev/null @@ -1,212 +0,0 @@ - - -
- -
-

Content Level

-
-
- Controls what content is visible across library, search, and discover. -
-
- {#each LEVELS as lvl} - {@const active = store.settings.contentLevel === lvl.value} - - {/each} -
-
-
- -
-

Source Overrides

-
- - - {#if store.settings.sourceOverridesEnabled} -
- -
- {#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} - {/if} -
-
- -
- - \ No newline at end of file diff --git a/_old/features/settings/sections/DevtoolsSettings.svelte b/_old/features/settings/sections/DevtoolsSettings.svelte deleted file mode 100644 index b9c2a50..0000000 --- a/_old/features/settings/sections/DevtoolsSettings.svelte +++ /dev/null @@ -1,247 +0,0 @@ - - -
- -
-

Toasts

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

Previews

-
-
-
Idle splashDismiss with any click or key
- -
-
-
- -
-

Biometrics

-
-
-
- Windows Hello - Available: {helloAvailable === null ? "…" : helloAvailable ? "yes" : "no"} -
- -
-
-
- -
- - {#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.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} -
- -
-
-
- -
-

Auth (UI Login)

-
-
- Mode {authStatus?.mode ?? "—"} - Session {authStatus?.hasSession ? "present" : "none"} - Refresh token {authStatus?.hasRefreshToken ? "present" : "none"} - Access expires in {fmtCountdown(authStatus?.accessExpiresInMs ?? null)} - Refresh expires in {fmtCountdown(authStatus?.refreshExpiresInMs ?? null)} - Refresh window {authStatus?.shouldRefreshSoon ? "open" : "not yet"} - Refresh in-flight {authStatus?.refreshInFlight ? "yes" : "no"} -
-
-
- Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)} - Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)} - Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry -
-
- - -
-
-
-
- -
\ No newline at end of file diff --git a/_old/features/settings/sections/FoldersSettings.svelte b/_old/features/settings/sections/FoldersSettings.svelte deleted file mode 100644 index b791875..0000000 --- a/_old/features/settings/sections/FoldersSettings.svelte +++ /dev/null @@ -1,430 +0,0 @@ - - -
-
-

Manage Folders

-
-
- Folders are stored as Suwayomi categories. Changes sync across all clients. -
- - {#if catsError} -
{catsError}
- {/if} - - {#if catsLoading} -

Loading folders…

- {:else} -
- {#each orderedAllIds as id} - {@const isBuiltin = id === "library" || id === "downloaded"} - {@const isCompleted = id === completedId} - {@const cat = isBuiltin ? null : (store.categories.find(c => String(c.id) === id) ?? null)} - {@const hidden = isHidden(id)} - - - {#if isBuiltin || cat} -
onDragStart(e, id)} - ondragover={(e) => onDragOver(e, id)} - ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null; } }} - ondrop={(e) => onDrop(e, id)} - ondragend={onDragEnd} - > - {#if isCompleted} - - - - - - {cat?.name ?? "Completed"} - {cat?.mangas?.nodes.length ?? 0} manga - built-in -
- - -
- - {:else if isBuiltin} - - {#if id === "library"}{:else}{/if} - - - {id === "library" ? "Saved" : "Downloaded"} - built-in -
- - -
- - {:else if cat} - {#if editingId === cat.id} - { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }} - onblur={commitEdit} use:focusInput /> - - {:else} -
onDragStart(e, id)} - ondragend={onDragEnd}> - - - - - { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name} -
- - {cat.mangas?.nodes.length ?? 0} manga - -
- - - - - -
- {/if} - {/if} -
- {/if} - {/each} -
- - {#if store.categories.filter(c => c.id !== 0 && c.name !== "Completed").length === 0} -

No custom folders yet. Create one below.

- {/if} - {/if} - -
- e.key === "Enter" && createFolder()} /> - -
-
-
-
- - \ No newline at end of file diff --git a/_old/features/settings/sections/GeneralSettings.svelte b/_old/features/settings/sections/GeneralSettings.svelte deleted file mode 100644 index 35df624..0000000 --- a/_old/features/settings/sections/GeneralSettings.svelte +++ /dev/null @@ -1,265 +0,0 @@ - - -
- -
-

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 URL - Base URL of your Suwayomi instance -
-
- updateSettings({ serverUrl: e.currentTarget.value })} - placeholder="http://localhost:4567" spellcheck="false" /> - -
-
- - - - - - {#if serverAdvancedOpen} -
-
-
- Server binary - Path to server executable — leave blank to use bundled -
-
- updateSettings({ serverBinary: e.currentTarget.value })} - placeholder="auto-detect" spellcheck="false" /> - -
-
-
- {/if} - -
-
- -
-

Inactivity

-
-
-
Idle screen timeoutShow the Moku idle splash after this much inactivity
-
- - {#if selectOpen === "idle-timeout" || closingSelect === "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} -
-
-
-
- -
-

Window

-
-
-
Close button behaviorWhat happens when you click the X button
-
- {#each [["ask","Ask"],["tray","Tray"],["quit","Quit"]] as [v, l]} - - {/each} -
-
-
-
- -
-

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/_old/features/settings/sections/KeybindsSettings.svelte b/_old/features/settings/sections/KeybindsSettings.svelte deleted file mode 100644 index 7fc6ec3..0000000 --- a/_old/features/settings/sections/KeybindsSettings.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - -
-
-

- 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/_old/features/settings/sections/LibrarySettings.svelte b/_old/features/settings/sections/LibrarySettings.svelte deleted file mode 100644 index 357fd0d..0000000 --- a/_old/features/settings/sections/LibrarySettings.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
- -
-

Display

-
- - - - {#if store.settings.libraryShowAllInSaved ?? true} - - {/if} -
-
- -
-

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} -
-
-
-
- -
-

Series

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

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

-
- -
-
- -
-

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/_old/features/settings/sections/ReaderSettings.svelte b/_old/features/settings/sections/ReaderSettings.svelte deleted file mode 100644 index 46385c7..0000000 --- a/_old/features/settings/sections/ReaderSettings.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - -
- -
-

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 <= 100) 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 > 100) { updateSettings({ readerZoom: 1.0 }); e.currentTarget.value = "100"; } }} - /> - % - -
-
- {#each [0, 10, 30, 50, 70, 90, 100] 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/_old/features/settings/sections/SecuritySettings.svelte b/_old/features/settings/sections/SecuritySettings.svelte deleted file mode 100644 index 7af3bb6..0000000 --- a/_old/features/settings/sections/SecuritySettings.svelte +++ /dev/null @@ -1,354 +0,0 @@ - - -
- - {#if secError} -
{secError}
- {/if} - -
-

- Server Authentication - - {store.settings.serverAuthMode === "BASIC_AUTH" ? "Basic Auth" : - store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Disabled"} - -

-
-
-
ModeHow Moku authenticates with the server
-
- {#each [{ value: "NONE", label: "None" }, { value: "BASIC_AUTH", label: "Basic" }, { value: "UI_LOGIN", label: "UI Login" }] 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} - -
-
-
-
- -
-

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/_old/features/settings/sections/StorageSettings.svelte b/_old/features/settings/sections/StorageSettings.svelte deleted file mode 100644 index 380be68..0000000 --- a/_old/features/settings/sections/StorageSettings.svelte +++ /dev/null @@ -1,863 +0,0 @@ - - -
- - {#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} - {#if downloadsPathInput.trim() !== confirmedDownloadsPath} - - {/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 !isExternalServer} - - {/if} - {#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 !isExternalServer} - - {/if} -
-
- -
- {/if} -
- -
- - {#if backupSectionOpen} -
- -

Library backup

- -
-
- 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} - -

App data backup

- -
-
- Export settings - Save all Moku app settings to a .zip via a native save dialog. -
- -
- -
-
- Import settings - Restore from a previously exported .zip file. Reloads the app immediately. -
- -
- - {#if appDataError} -
{appDataError}
- {/if} - - {#if appDataMsg} -
- {appDataMsg} -
- {/if} - - {#if appDataBackupDir} -
-
- Auto-backup location - Pre-update snapshots are kept here (last 5). -
- -
- {/if} - -
- {/if} -
- -
- - {#if resetSectionOpen} -
- {#each resetItems as item} -
-
- {item.label} - {item.desc} - {#if item.error}{item.error}{/if} -
-
- {#if item.state === "done"} - Done - {:else if item.state === "busy"} - - {:else if confirming === item.key} - Sure? - - - {:else} - - {/if} -
-
- {/each} -
- {/if} -
- -
\ No newline at end of file diff --git a/_old/features/settings/sections/TrackingSettings.svelte b/_old/features/settings/sections/TrackingSettings.svelte deleted file mode 100644 index 33ebd94..0000000 --- a/_old/features/settings/sections/TrackingSettings.svelte +++ /dev/null @@ -1,279 +0,0 @@ - - -
- -
-

Connected Trackers

-
- {#if trackersError} -
trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}
- {/if} - {#if trackersLoading} -

Loading trackers…

- {:else} - {#each trackers as tracker} -
-
- -
- {tracker.name} -
- - {tracker.isLoggedIn ? "Connected" : "Not connected"} - - {#if tracker.isLoggedIn && tracker.isTokenExpired} - Token expired — reconnect - {/if} -
-
-
-
- {#if tracker.isLoggedIn && tracker.isTokenExpired} - - - {:else if tracker.isLoggedIn} - - {:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id} - - {/if} -
- {#if oauthTrackerId === tracker.id} -
- {#if oauthError} -
oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}
- {/if} -

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} -
- {#if credsError} -
credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}
- {/if} - e.key === "Escape" && cancelCredentials()} use:focusEl /> - { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} /> -
- - -
-
- {/if} -
- {/each} - {/if} -
-
- -
-

Sync back from tracker

-
-
- Enable sync back - Mark chapters read locally based on tracker progress -
- -
- - {#if store.settings.trackerSyncBack} - - {#if store.settings.trackerSyncBackThreshold !== null} -
-
ToleranceMax chapter number difference allowed (1–20)
-
- - {store.settings.trackerSyncBackThreshold} - -
-
- {/if} - -
-
- Respect scanlator filter - Only mark chapters matching the series' active scanlator filter -
- -
- -
-
- Sync now - Apply tracker progress to all linked manga in your library -
- -
- {/if} -
- -
- - \ No newline at end of file diff --git a/_old/features/tracking/components/Tracking.svelte b/_old/features/tracking/components/Tracking.svelte deleted file mode 100644 index 38c1cb0..0000000 --- a/_old/features/tracking/components/Tracking.svelte +++ /dev/null @@ -1,128 +0,0 @@ - - -
- trackingState.loadAll()} - onTrackerChange={(id) => { activeTrackerId = id; statusFilter = "all"; }} - onStatusChange={(v) => statusFilter = v} - onSearchChange={(v) => searchQuery = v} - onSortChange={(v) => sortBy = v} - /> - -
- {#if trackingState.loadingAll} -
- -
- - {:else if trackingState.error} -
- {trackingState.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)} - selectedRecord = r} - /> - {/each} -
- {/if} -
-
- -{#if selectedRecord} - selectedRecord = null} /> -{/if} - - \ No newline at end of file diff --git a/_old/features/tracking/components/TrackingCard.svelte b/_old/features/tracking/components/TrackingCard.svelte deleted file mode 100644 index 8413e6c..0000000 --- a/_old/features/tracking/components/TrackingCard.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_old/features/tracking/components/TrackingPreview.svelte b/_old/features/tracking/components/TrackingPreview.svelte deleted file mode 100644 index 3615408..0000000 --- a/_old/features/tracking/components/TrackingPreview.svelte +++ /dev/null @@ -1,611 +0,0 @@ - - -
{ if (e.target === e.currentTarget) onClose(); }} - onkeydown={(e) => { if (e.key === 'Escape') onClose(); }} -> - -
- -{#if confirmUnbind} -
confirmUnbind = false} onkeydown={(e) => { if (e.key === 'Escape') confirmUnbind = false; }}> - -
-{/if} - - \ No newline at end of file diff --git a/_old/features/tracking/components/TrackingToolbar.svelte b/_old/features/tracking/components/TrackingToolbar.svelte deleted file mode 100644 index 5b10a6d..0000000 --- a/_old/features/tracking/components/TrackingToolbar.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - -
-
- Tracking - - {#if !loading && loggedIn.length > 0} -
- - {#each loggedIn as t} - - {/each} -
- {/if} - -
- -
-
- - {#if !loading && loggedIn.length > 0} -
-
- - onSearchChange((e.target as HTMLInputElement).value)} - /> -
- - - - -
- {/if} -
- - \ No newline at end of file diff --git a/_old/features/tracking/index.ts b/_old/features/tracking/index.ts deleted file mode 100644 index fdb557d..0000000 --- a/_old/features/tracking/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Tracking } from "./components/Tracking.svelte"; -export * from "./lib/trackingSync"; diff --git a/_old/features/tracking/lib/trackingSync.ts b/_old/features/tracking/lib/trackingSync.ts deleted file mode 100644 index 3028899..0000000 --- a/_old/features/tracking/lib/trackingSync.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { Tracker, TrackRecord } from "@types/index"; -import type { Chapter } from "@types/index"; -import { MARK_CHAPTERS_READ } from "@api/mutations/chapters"; -import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList"; - -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) }, - } - ); -} - -export interface SyncBackOptions { - threshold: number | null; - respectScanlatorFilter: boolean; - chapterPrefs: ChapterDisplayPrefs; -} - -export async function syncBackFromTracker( - records: TrackRecord[], - chapters: Chapter[], - opts: SyncBackOptions, - gqlFn: (query: string, vars: Record) => Promise, -): Promise { - const eligible = buildChapterList(chapters, { - ...opts.chapterPrefs, - sortDir: "asc", - ...(opts.respectScanlatorFilter ? {} : { - scanlatorFilter: [], - scanlatorBlacklist: [], - scanlatorForce: false, - }), - }); - const seenInt = new Map(); - for (const ch of eligible) { - const key = Math.floor(ch.chapterNumber); - if (!Number.isInteger(ch.chapterNumber)) continue; - if (!seenInt.has(key)) seenInt.set(key, ch); - } - const dedupedEligible = [...seenInt.values()]; - const decimalsByFloor = new Map(); - for (const ch of eligible) { - if (Number.isInteger(ch.chapterNumber)) continue; - const key = Math.floor(ch.chapterNumber); - const arr = decimalsByFloor.get(key) ?? []; - arr.push(ch); - decimalsByFloor.set(key, arr); - } - - const toMarkRead: number[] = []; - - for (const record of records) { - const remote = record.lastChapterRead; - if (!remote || remote <= 0) continue; - - for (const chapter of dedupedEligible) { - if (chapter.isRead) continue; - if (chapter.chapterNumber > remote) continue; - if (opts.threshold !== null && remote - chapter.chapterNumber > opts.threshold) continue; - toMarkRead.push(chapter.id); - for (const dec of decimalsByFloor.get(chapter.chapterNumber) ?? []) { - if (!dec.isRead) toMarkRead.push(dec.id); - } - } - } - - const ids = [...new Set(toMarkRead)]; - if (ids.length > 0) { - await gqlFn(MARK_CHAPTERS_READ, { ids, isRead: true }); - } - - return ids; -} \ No newline at end of file diff --git a/_old/features/tracking/store/trackingState.svelte.ts b/_old/features/tracking/store/trackingState.svelte.ts deleted file mode 100644 index bd68d2d..0000000 --- a/_old/features/tracking/store/trackingState.svelte.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { gql } from "@api/client"; -import { GET_MANGA_TRACK_RECORDS, GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking"; -import { GET_CHAPTERS } from "@api/queries/chapters"; -import { UPDATE_TRACK, FETCH_TRACK, UNBIND_TRACK } from "@api/mutations/tracking"; -import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList"; -import { syncBackFromTracker } from "@features/tracking/lib/trackingSync"; -import { store } from "@store/state.svelte"; -import type { TrackRecord, Tracker } from "@types/index"; -import type { Chapter } from "@types/index"; -import type { TrackerWithRecords } from "@features/tracking/lib/trackingSync"; - -const BOOT_SYNC_RATE_MS = 400; - -type RecordMap = Map; -type MangaBucket = { mangaId: number; records: TrackRecord[] }; - -class TrackingState { - private byManga: RecordMap = $state(new Map()); - - allTrackers: TrackerWithRecords[] = $state([]); - loadingAll: boolean = $state(false); - loadingFor: Set = $state(new Set()); - error: string | null = $state(null); - - recordsFor(mangaId: number): TrackRecord[] { - return this.byManga.get(mangaId) ?? []; - } - - private setFor(mangaId: number, records: TrackRecord[]) { - const next = new Map(this.byManga); - next.set(mangaId, records); - this.byManga = next; - } - - private patchFor(mangaId: number, updated: Partial & { id: number }) { - const records = this.recordsFor(mangaId).map(r => - r.id === updated.id ? { ...r, ...updated } : r - ); - this.setFor(mangaId, records); - - this.allTrackers = this.allTrackers.map(t => ({ - ...t, - trackRecords: { - nodes: t.trackRecords.nodes.map(r => - r.id === updated.id ? { ...r, ...updated } : r - ), - }, - })); - } - - async loadForManga(mangaId: number) { - if (this.loadingFor.has(mangaId)) return; - const existing = this.byManga.get(mangaId); - if (existing && existing.length > 0) return; - - const next = new Set(this.loadingFor); - next.add(mangaId); - this.loadingFor = next; - - try { - const res = await gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>( - GET_MANGA_TRACK_RECORDS, { mangaId } - ); - this.setFor(mangaId, res.manga.trackRecords.nodes); - } catch (e: any) { - this.error = e?.message ?? "Failed to load tracking"; - } finally { - const s = new Set(this.loadingFor); - s.delete(mangaId); - this.loadingFor = s; - } - } - - async loadAll() { - this.loadingAll = true; - this.error = null; - try { - const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS); - this.allTrackers = res.trackers.nodes; - - for (const tracker of res.trackers.nodes.filter(t => t.isLoggedIn)) { - for (const record of tracker.trackRecords.nodes) { - if (!record.manga?.id) continue; - const mangaId = record.manga.id; - const existing = this.byManga.get(mangaId) ?? []; - const merged = [...existing.filter(r => r.id !== record.id), record]; - this.setFor(mangaId, merged); - } - } - } catch (e: any) { - this.error = e?.message ?? "Failed to load tracking"; - } finally { - this.loadingAll = false; - } - } - - async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise { - const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>( - UPDATE_TRACK, { recordId: record.id, status } - ); - this.patchFor(mangaId, res.updateTrack.trackRecord); - return res.updateTrack.trackRecord; - } - - async updateScore(mangaId: number, record: TrackRecord, scoreString: string): Promise { - const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>( - UPDATE_TRACK, { recordId: record.id, scoreString } - ); - this.patchFor(mangaId, res.updateTrack.trackRecord); - return res.updateTrack.trackRecord; - } - - async updateChapterProgress(mangaId: number, record: TrackRecord, lastChapterRead: number): Promise { - const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>( - UPDATE_TRACK, { recordId: record.id, lastChapterRead } - ); - this.patchFor(mangaId, res.updateTrack.trackRecord); - return res.updateTrack.trackRecord; - } - - async unbind(mangaId: number, record: TrackRecord) { - await gql(UNBIND_TRACK, { recordId: record.id }); - this.setFor(mangaId, this.recordsFor(mangaId).filter(r => r.id !== record.id)); - this.allTrackers = this.allTrackers.map(t => ({ - ...t, - trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) }, - })); - } - - async syncFromRemote( - mangaId: number, - record: TrackRecord, - chapters: Chapter[], - prefs: ChapterDisplayPrefs, - ): Promise<{ fresh: TrackRecord; markedIds: number[] }> { - const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>( - FETCH_TRACK, { recordId: record.id } - ); - const fresh = res.fetchTrack.trackRecord; - this.patchFor(mangaId, fresh); - - const markedIds = await this._applyRemoteProgress(fresh, chapters, prefs); - return { fresh, markedIds }; - } - - private async _applyRemoteProgress( - record: TrackRecord, - chapters: Chapter[], - prefs: ChapterDisplayPrefs, - ): Promise { - if (!store.settings.trackerSyncBack) return []; - - return syncBackFromTracker( - [record], - chapters, - { - threshold: store.settings.trackerSyncBackThreshold ?? null, - respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true, - chapterPrefs: prefs, - }, - (query, vars) => gql(query, vars), - ); - } - - async updateFromRead( - mangaId: number, - chapter: Chapter, - chapterList: Chapter[], - prefs: ChapterDisplayPrefs, - ) { - const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" }); - const idx = filtered.findIndex(c => c.id === chapter.id); - if (idx < 0) return; - const position = idx + 1; - - const records = this.recordsFor(mangaId); - for (const record of records) { - try { - const completedValue = this._completedStatusFor(record.trackerId); - const isCompleted = completedValue !== null && record.status === completedValue; - const readingValue = this._readingStatusFor(record.trackerId); - const belowMax = record.totalChapters > 0 && (record.lastChapterRead ?? 0) < record.totalChapters; - - if ((isCompleted || belowMax) && readingValue !== null && position > (record.lastChapterRead ?? 0)) { - await gql<{ updateTrack: { trackRecord: TrackRecord } }>( - UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue } - ).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord)); - } else if (!isCompleted && position > (record.lastChapterRead ?? 0)) { - await this.updateChapterProgress(mangaId, record, position); - } - } catch {} - } - } - - async updateFromUnread( - mangaId: number, - chapterList: Chapter[], - prefs: ChapterDisplayPrefs, - ) { - const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" }); - const lastRead = [...filtered].reverse().find(c => c.isRead); - const position = lastRead ? filtered.findIndex(c => c.id === lastRead.id) + 1 : 0; - - const records = this.recordsFor(mangaId); - for (const record of records.filter(r => (r.lastChapterRead ?? 0) > position)) { - try { - const completedValue = this._completedStatusFor(record.trackerId); - const isCompleted = completedValue !== null && record.status === completedValue; - const belowMax = record.totalChapters > 0 && position < record.totalChapters; - const readingValue = this._readingStatusFor(record.trackerId); - - if ((isCompleted || belowMax) && readingValue !== null) { - await gql<{ updateTrack: { trackRecord: TrackRecord } }>( - UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue } - ).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord)); - } else { - await this.updateChapterProgress(mangaId, record, position); - } - } catch {} - } - } - - clear(mangaId: number) { - const next = new Map(this.byManga); - next.delete(mangaId); - this.byManga = next; - } - - private _statusesFor(trackerId: number): { value: number; name: string }[] { - return this.allTrackers.find(t => t.id === trackerId)?.statuses ?? []; - } - - private _completedStatusFor(trackerId: number): number | null { - const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "completed"); - return s?.value ?? null; - } - - private _readingStatusFor(trackerId: number): number | null { - const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "reading"); - return s?.value ?? null; - } - - async bootSync() { - if (!store.settings.trackerSyncBack) return; - - if (this.allTrackers.length === 0) await this.loadAll(); - - const buckets = new Map(); - - for (const tracker of this.allTrackers.filter(t => t.isLoggedIn)) { - const completedValue = this._completedStatusFor(tracker.id); - for (const record of tracker.trackRecords.nodes) { - const mangaId = record.manga?.id; - if (!mangaId) continue; - if (completedValue !== null && record.status === completedValue) continue; - const bucket = buckets.get(mangaId) ?? { mangaId, records: [] }; - bucket.records.push(record); - buckets.set(mangaId, bucket); - } - } - - const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); - - for (const { mangaId, records } of buckets.values()) { - const prefs = { ...(store.settings.mangaPrefs?.[mangaId] ?? {}) } as ChapterDisplayPrefs; - - let chapters: Chapter[]; - try { - const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }); - chapters = res.chapters.nodes; - } catch { - continue; - } - - const freshRecords: TrackRecord[] = []; - for (const record of records) { - await delay(BOOT_SYNC_RATE_MS); - try { - const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id }); - const fresh = res.fetchTrack.trackRecord; - this.patchFor(mangaId, fresh); - freshRecords.push(fresh); - } catch { - freshRecords.push(record); - } - } - - try { - await syncBackFromTracker( - freshRecords, - chapters, - { - threshold: store.settings.trackerSyncBackThreshold ?? null, - respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true, - chapterPrefs: prefs, - }, - (query, vars) => gql(query, vars), - ); - } catch {} - } - } -} - -export const trackingState = new TrackingState(); \ No newline at end of file diff --git a/_old/main.ts b/_old/main.ts deleted file mode 100644 index 1dbe5b5..0000000 --- a/_old/main.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { mount } from "svelte"; -import App from "./App.svelte"; -import "./design/tokens/index.css"; -import "./design/base/index.css"; - -mount(App, { target: document.getElementById("app")! }); \ No newline at end of file diff --git a/_old/shared/chrome/AuthGate.svelte b/_old/shared/chrome/AuthGate.svelte deleted file mode 100644 index 9aef13a..0000000 --- a/_old/shared/chrome/AuthGate.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -{#if boot.loginRequired} -
-
- -

moku

- - {store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Basic Auth"} - -

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

- - {#if boot.loginError} -

{boot.loginError}

- {/if} - -
- e.key === "Enter" && handleLogin()} /> - e.key === "Enter" && handleLogin()} /> -
- - - -
-
-{/if} - - \ No newline at end of file diff --git a/_old/shared/chrome/Layout.svelte b/_old/shared/chrome/Layout.svelte deleted file mode 100644 index a91bd61..0000000 --- a/_old/shared/chrome/Layout.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - -
-
- -
- {#if store.activeManga} - - {:else if store.genreFilter} - - {:else if store.navPage === "home"} - - {:else if store.navPage === "library"} - - {:else if store.navPage === "search"} - - {:else if store.navPage === "history"} - - {:else if store.navPage === "downloads"} - - {:else if store.navPage === "extensions"} - - {:else if store.navPage === "tracking"} - - {:else} - - {/if} -
-
-
- - diff --git a/_old/shared/chrome/Sidebar.svelte b/_old/shared/chrome/Sidebar.svelte deleted file mode 100644 index 1ba045b..0000000 --- a/_old/shared/chrome/Sidebar.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/_old/shared/chrome/SplashScreen.svelte b/_old/shared/chrome/SplashScreen.svelte deleted file mode 100644 index 77803b8..0000000 --- a/_old/shared/chrome/SplashScreen.svelte +++ /dev/null @@ -1,495 +0,0 @@ - - -
- {#if showCards} - - {#if showFps} - - {/if} - {/if} - - {#if mode === "idle" && lockEnabled} -
-
-
- Moku -
-
-

Enter PIN

-
-
- {#each Array(store.settings.appLockPin?.length ?? 4) as _, i} -
- {/each} -
- -
-
-
- - {:else if mode === "idle"} -
-
-
- Moku -
-

press any key to continue

-
- - {:else} -
- {#if !failed && !notConfigured} - - - - - {/if} - Moku -
-
-
- {#if failed || notConfigured} -
-

{failed ? "Could not reach server" : "Server not configured"}

-
- - -
-
- {:else} -

{ringFull ? "" : `Initializing server${dots}`}

- {/if} -
- - {#if lockEnabled} -
-
-

Enter PIN

-
-
- {#each Array(store.settings.appLockPin?.length ?? 4) as _, i} -
- {/each} -
- -
-
-
- {/if} -
- {/if} -
- - \ No newline at end of file diff --git a/_old/shared/chrome/TitleBar.svelte b/_old/shared/chrome/TitleBar.svelte deleted file mode 100644 index 92f3917..0000000 --- a/_old/shared/chrome/TitleBar.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - -{#if !isFullscreen} -
- {#if isMac}
{/if} - Moku - {#if !isMac} -
- - - -
- {/if} -
-{:else if isWindows} -
- - -
-{/if} - - \ No newline at end of file diff --git a/_old/shared/chrome/Toaster.svelte b/_old/shared/chrome/Toaster.svelte deleted file mode 100644 index d1b4a36..0000000 --- a/_old/shared/chrome/Toaster.svelte +++ /dev/null @@ -1,229 +0,0 @@ - - -{#if store.toasts.length} -
- {#each store.toasts as t (t.id)} - - {/each} -
-{/if} - -{#if detail} - -{/if} - - \ No newline at end of file diff --git a/_old/shared/manga/MangaPreview.svelte b/_old/shared/manga/MangaPreview.svelte deleted file mode 100644 index 00a3d7f..0000000 --- a/_old/shared/manga/MangaPreview.svelte +++ /dev/null @@ -1,923 +0,0 @@ - - -{#if store.previewManga} -
{ if (e.target === e.currentTarget) close(); }} - onkeydown={(e) => { if (e.key === "Escape") close(); }} -> - -
-{/if} - - -{#if linkPickerOpen && store.previewManga} - -{/if} - -{#if coverPickerOpen && store.previewManga} - { coverPickerOpen = false; }} - /> -{/if} - - - - \ No newline at end of file diff --git a/_old/shared/manga/SourceBrowse.svelte b/_old/shared/manga/SourceBrowse.svelte deleted file mode 100644 index 34f11ba..0000000 --- a/_old/shared/manga/SourceBrowse.svelte +++ /dev/null @@ -1,190 +0,0 @@ - - -{#if store.activeSource} -
-
- - {store.activeSource.displayName} -
- -
-
- {#each (["POPULAR", "LATEST"] as BrowseType[]) as mode} - - {/each} - {#if search}{/if} -
-
- - e.key === "Enter" && submitSearch()} /> -
-
- - {#if loading} -
- {#each Array(18) as _} -
- {/each} -
- {:else if mangas.length === 0} -
No results.
- {:else} -
- {#each mangas as m (m.id)} - - {/each} -
- {/if} - - {#if !loading && (page > 1 || hasNextPage)} - - {/if} -
-{/if} - -{#if ctx} - ctx = null} /> -{/if} - - \ No newline at end of file diff --git a/_old/shared/manga/ThreeDCard.svelte b/_old/shared/manga/ThreeDCard.svelte deleted file mode 100644 index d633a33..0000000 --- a/_old/shared/manga/ThreeDCard.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - -
-
- {@render children()} -
-
-
-
-
-
-
-
-
-
- - diff --git a/_old/shared/manga/Thumbnail.svelte b/_old/shared/manga/Thumbnail.svelte deleted file mode 100644 index 53f3134..0000000 --- a/_old/shared/manga/Thumbnail.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - \ No newline at end of file diff --git a/_old/shared/ui/ContextMenu.svelte b/_old/shared/ui/ContextMenu.svelte deleted file mode 100644 index 4ca4f77..0000000 --- a/_old/shared/ui/ContextMenu.svelte +++ /dev/null @@ -1,231 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_old/shared/ui/SourceList.svelte b/_old/shared/ui/SourceList.svelte deleted file mode 100644 index 3af803d..0000000 --- a/_old/shared/ui/SourceList.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - -
-
-

Sources

-
- - -
-
- -
-
- {#each langs as l} - - {/each} -
- - {#if loading} -
- {:else if groups.length === 0} -
No sources found.
- {:else} -
- {#each groups as g} - {@const single = g.sources.length === 1} - {@const open = expanded.has(g.name)} -
- - {#if !single && open} - {#each g.sources as src} - - {/each} - {/if} -
- {/each} -
- {/if} -
-
- - \ No newline at end of file diff --git a/_old/store/app.svelte.ts b/_old/store/app.svelte.ts deleted file mode 100644 index 99cf479..0000000 --- a/_old/store/app.svelte.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type NavPage = - | "home" | "library" | "sources" | "explore" - | "downloads" | "extensions" | "history" | "search" | "tracking"; - -class AppStore { - navPage: NavPage = $state("home"); - settingsOpen: boolean = $state(false); - searchPrefill: string = $state(""); - searchQuery: string = $state(""); - genreFilter: string = $state(""); - scrollPositions: Map = $state(new Map()); - - setNavPage(next: NavPage) { this.navPage = next; } - setSettingsOpen(next: boolean) { this.settingsOpen = next; } - setSearchPrefill(next: string) { this.searchPrefill = next; } - setSearchQuery(next: string) { this.searchQuery = next; } - setGenreFilter(next: string) { this.genreFilter = next; } - saveScroll(key: string, top: number) { - const m = new Map(this.scrollPositions); - m.set(key, top); - this.scrollPositions = m; - } - getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0; } -} - -export const app = new AppStore(); - -export function setNavPage(next: NavPage) { app.setNavPage(next); } -export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); } -export function setSearchPrefill(next: string) { app.setSearchPrefill(next); } -export function setSearchQuery(next: string) { app.setSearchQuery(next); } -export function setGenreFilter(next: string) { app.setGenreFilter(next); } -export function saveScroll(key: string, top: number) { app.saveScroll(key, top); } -export function getScroll(key: string): number { return app.getScroll(key); } diff --git a/_old/store/boot.svelte.ts b/_old/store/boot.svelte.ts deleted file mode 100644 index 494d77c..0000000 --- a/_old/store/boot.svelte.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { store } from "@store/state.svelte"; -import { probeServer, loginBasic, loginUI } from "@core/auth"; -import { trackingState } from "@features/tracking/store/trackingState.svelte"; -import { loadAllStores } from "@core/persistence/persist"; -import { notifyReauthSuccess } from "@api/client"; - -const MAX_ATTEMPTS = 15; -const BG_MAX_ATTEMPTS = 60; - -export const boot = $state({ - serverProbeOk: false, - failed: false, - notConfigured: false, - loginRequired: false, - loginError: null as string | null, - loginBusy: false, - loginUser: "", - loginPass: "", - sessionExpired: false, - skipped: false, -}); - -let probeGeneration = 0; - -export async function initStore() { - const saved = await loadAllStores(); - store.hydrate(saved); -} - -function handleProbeSuccess(gen: number) { - if (gen !== probeGeneration) return; - boot.serverProbeOk = true; - boot.failed = false; - boot.skipped = false; - trackingState.bootSync().catch(() => {}); -} - -function handleAuthRequired(gen: number) { - if (gen !== probeGeneration) return; - boot.serverProbeOk = true; - boot.failed = false; - const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode === "BASIC_AUTH") { - const user = store.settings.serverAuthUser?.trim() ?? ""; - const pass = store.settings.serverAuthPass?.trim() ?? ""; - if (user && pass) { - loginBasic(user, pass) - .then(() => { if (gen === probeGeneration) trackingState.bootSync().catch(() => {}); }) - .catch(() => { - if (gen !== probeGeneration) return; - boot.loginUser = store.settings.serverAuthUser ?? ""; - boot.loginRequired = true; - }); - return; - } - boot.loginUser = store.settings.serverAuthUser ?? ""; - boot.loginRequired = true; - return; - } - if (mode === "UI_LOGIN") { - boot.loginUser = store.settings.serverAuthUser ?? ""; - boot.loginRequired = true; - return; - } - trackingState.bootSync().catch(() => {}); -} - -export function startProbe() { - const gen = ++probeGeneration; - boot.failed = false; - boot.loginRequired = false; - boot.skipped = false; - let tries = 0; - - async function probe() { - if (gen !== probeGeneration) return; - tries++; - const result = await probeServer(); - if (gen !== probeGeneration) return; - - if (result === "ok") { handleProbeSuccess(gen); return; } - if (result === "auth_required") { handleAuthRequired(gen); return; } - if (tries >= MAX_ATTEMPTS) { boot.failed = true; startBackgroundProbe(gen); return; } - - setTimeout(probe, Math.min(300 + tries * 150, 1500)); - } - - setTimeout(probe, 100); -} - -function startBackgroundProbe(gen: number) { - let bgTries = 0; - - async function bgProbe() { - if (gen !== probeGeneration) return; - bgTries++; - const result = await probeServer(); - if (gen !== probeGeneration) return; - - if (result === "ok") { handleProbeSuccess(gen); return; } - if (result === "auth_required") { handleAuthRequired(gen); return; } - if (bgTries >= BG_MAX_ATTEMPTS) return; - - setTimeout(bgProbe, 2000); - } - - setTimeout(bgProbe, 2000); -} - -export function stopProbe() { - probeGeneration++; -} - -export async function submitLogin(onSuccess: () => void): Promise { - if (!boot.loginUser.trim() || !boot.loginPass.trim()) { - boot.loginError = "Username and password are required"; - return; - } - boot.loginBusy = true; - boot.loginError = null; - try { - const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode === "UI_LOGIN") { - await loginUI(boot.loginUser.trim(), boot.loginPass.trim()); - } else { - await loginBasic(boot.loginUser.trim(), boot.loginPass.trim()); - } - boot.loginRequired = false; - boot.sessionExpired = false; - boot.skipped = false; - boot.loginPass = ""; - boot.loginError = null; - notifyReauthSuccess(); - trackingState.bootSync().catch(() => {}); - onSuccess(); - } catch (e: any) { - boot.loginError = e?.message ?? "Login failed"; - } finally { - boot.loginBusy = false; - } -} - -export function retryBoot() { - boot.serverProbeOk = false; - boot.failed = false; - boot.notConfigured = false; - boot.loginRequired = false; - boot.skipped = false; - startProbe(); -} - -export function bypassBoot(onReady: () => void) { - const gen = probeGeneration; - boot.serverProbeOk = true; - boot.loginRequired = false; - boot.sessionExpired = false; - boot.skipped = true; - onReady(); - startBackgroundProbe(gen); -} \ No newline at end of file diff --git a/_old/store/discord.ts b/_old/store/discord.ts deleted file mode 100644 index 1e92b1c..0000000 --- a/_old/store/discord.ts +++ /dev/null @@ -1,69 +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"; -const BUTTONS = [ - { label: "GitHub", url: "https://github.com/moku-project/Moku" }, - { label: "Discord", url: "https://discord.gg/Jq3pwuNqPp" }, -]; - -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 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)}`; -} - -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: isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE, - 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/_old/store/index.ts b/_old/store/index.ts deleted file mode 100644 index 479fded..0000000 --- a/_old/store/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './app.svelte'; -export * from './boot.svelte'; -export * from './notifications.svelte'; -export * from './state.svelte'; \ No newline at end of file diff --git a/_old/store/notifications.svelte.ts b/_old/store/notifications.svelte.ts deleted file mode 100644 index b0b7835..0000000 --- a/_old/store/notifications.svelte.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface Toast { - id: string; - kind: "success" | "error" | "info" | "download"; - title: string; - body?: string; - duration?: number; -} - -export interface ActiveDownload { - chapterId: number; - mangaId: number; - progress: number; -} - -class NotificationStore { - toasts: Toast[] = $state([]); - activeDownloads: ActiveDownload[] = $state([]); - - addToast(toast: Omit) { - this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5); - } - - dismissToast(id: string) { - this.toasts = this.toasts.filter(x => x.id !== id); - } - - setActiveDownloads(next: ActiveDownload[]) { - this.activeDownloads = next; - } -} - -export const notifications = new NotificationStore(); - -export function addToast(toast: Omit) { notifications.addToast(toast); } -export function dismissToast(id: string) { notifications.dismissToast(id); } -export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next); } diff --git a/_old/store/state.svelte.ts b/_old/store/state.svelte.ts deleted file mode 100644 index a3930c5..0000000 --- a/_old/store/state.svelte.ts +++ /dev/null @@ -1,409 +0,0 @@ -import type { Manga, Chapter, Category, Source } from "../types"; -import type { Settings, ReaderSettings, ReaderPreset, CustomTheme, - LibraryFilter } from "../types/settings"; -import type { HistoryEntry, BookmarkEntry, MarkerEntry, MarkerColor, - ReadLogEntry, ReadingStats, LibraryUpdateEntry } from "../types/history"; -import { DEFAULT_KEYBINDS } from "../core/keybinds/defaultBinds"; -import { DEFAULT_SETTINGS } from "../types/settings"; -import { DEFAULT_READING_STATS } from "../types/history"; -import { notifications } from "./notifications.svelte"; -import { app } from "./app.svelte"; -import { persistSettings, persistLibrary, persistUpdates } from "../core/persistence/persist"; -import type { PersistedData } from "../core/persistence/persist"; -import { untrack } from "svelte"; - -function localDateStr(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; -} - -export type { NavPage } from "./app.svelte"; -export type { Toast, ActiveDownload } from "./notifications.svelte"; -export type { Settings, ReaderSettings, ReaderPreset, CustomTheme, - LibraryFilter, LibrarySortMode, LibrarySortDir, - LibraryStatusFilter, LibraryContentFilter, - PageStyle, FitMode, ReadingDirection, - ChapterSortDir, ChapterSortMode, - BuiltinTheme, Theme, ThemeTokens, - MangaPrefs } from "../types/settings"; -export { DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS, - DEFAULT_THEME_TOKENS } from "../types/settings"; -export type { HistoryEntry, BookmarkEntry, MarkerEntry, MarkerColor, - ReadLogEntry, ReadingStats, LibraryUpdateEntry } from "../types/history"; - -const STORE_VERSION = 3; -const AVG_MIN_PER_CHAPTER = 5; -const RESET_ON_UPGRADE: (keyof Settings)[] = ["serverBinary", "readerZoom", "uiZoom"]; - -function mergeSettings(saved: any): Settings { - return { - ...DEFAULT_SETTINGS, ...saved?.settings, - keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds }, - heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null], - mangaLinks: saved?.settings?.mangaLinks ?? {}, - mangaPrefs: saved?.settings?.mangaPrefs ?? {}, - customThemes: saved?.settings?.customThemes ?? [], - hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], - nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags, - nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [], - nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [], - libraryTabSort: saved?.settings?.libraryTabSort ?? {}, - libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, - libraryTabFilters: saved?.settings?.libraryTabFilters ?? {}, - extraScanDirs: saved?.settings?.extraScanDirs ?? [], - pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [], - readerPresets: saved?.settings?.readerPresets ?? [], - mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {}, - categoryFrecency: saved?.settings?.categoryFrecency ?? {}, - hiddenLibraryTabs: saved?.settings?.hiddenLibraryTabs ?? [], - libraryPinnedTabOrder: saved?.settings?.libraryPinnedTabOrder ?? [], - }; -} - -class Store { - settings: Settings = $state(mergeSettings(null)); - activeManga: Manga | null = $state(null); - previewManga: Manga | null = $state(null); - activeChapter: Chapter | null = $state(null); - activeChapterList: Chapter[] = $state([]); - pageUrls: string[] = $state([]); - pageNumber: number = $state(1); - libraryFilter: LibraryFilter = $state("all"); - categories: Category[] = $state([]); - activeSource: Source | null = $state(null); - libraryTagFilter: string[] = $state([]); - history: HistoryEntry[] = $state([]); - bookmarks: BookmarkEntry[]= $state([]); - markers: MarkerEntry[] = $state([]); - readLog: ReadLogEntry[] = $state([]); - readingStats: ReadingStats = $state({ ...DEFAULT_READING_STATS }); - dailyReadCounts: Record = $state({}); - searchCache: Map = $state(new Map()); - searchLibraryIds: Set = $state(new Set()); - searchSrcOffset: number = $state(0); - readerSessionId: number = $state(0); - libraryUpdates: LibraryUpdateEntry[] = $state([]); - lastLibraryRefresh: number = $state(0); - acknowledgedUpdates: Set = $state(new Set()); - isFullscreen: boolean = $state(false); - - #ready = false; - - get toasts() { return notifications.toasts; } - get activeDownloads() { return notifications.activeDownloads; } - get navPage() { return app.navPage; } - set navPage(v) { app.setNavPage(v); } - get settingsOpen() { return app.settingsOpen; } - set settingsOpen(v) { app.setSettingsOpen(v); } - get searchPrefill() { return app.searchPrefill; } - set searchPrefill(v) { app.setSearchPrefill(v); } - get searchQuery() { return app.searchQuery; } - set searchQuery(v) { app.setSearchQuery(v); } - get genreFilter() { return app.genreFilter; } - set genreFilter(v) { app.setGenreFilter(v); } - - hydrate(saved: PersistedData) { - if (this.#ready) return; - - if ((saved.storeVersion ?? 1) < STORE_VERSION && saved.settings) { - for (const key of RESET_ON_UPGRADE) - (saved.settings as any)[key] = (DEFAULT_SETTINGS as any)[key]; - } - - // Assign all persisted values outside of reactive tracking so the - // $effects registered below don't fire on this initial write. - untrack(() => { - this.settings = mergeSettings(saved); - this.history = saved.history ?? []; - this.bookmarks = saved.bookmarks ?? []; - this.markers = saved.markers ?? []; - this.readLog = saved.readLog ?? []; - this.readingStats = saved.readingStats ?? { ...DEFAULT_READING_STATS }; - this.dailyReadCounts = saved.dailyReadCounts ?? {}; - this.libraryUpdates = saved.libraryUpdates ?? []; - this.lastLibraryRefresh = saved.lastLibraryRefresh ?? 0; - this.acknowledgedUpdates = new Set(saved.acknowledgedUpdateIds ?? []); - }); - - // Mark ready before registering effects so the first reactive run - // (which Svelte schedules after the current microtask) is allowed through. - this.#ready = true; - - $effect.root(() => { - $effect(() => { - const s = this.settings; - if (!this.#ready) return; - persistSettings({ settings: s, storeVersion: STORE_VERSION }); - }); - - $effect(() => { - const h = this.history; - const bk = this.bookmarks; - const mk = this.markers; - const rl = this.readLog; - const rs = this.readingStats; - const dc = this.dailyReadCounts; - if (!this.#ready) return; - persistLibrary({ history: h, bookmarks: bk, markers: mk, readLog: rl, readingStats: rs, dailyReadCounts: dc }); - }); - - $effect(() => { - const lu = this.libraryUpdates; - const llr = this.lastLibraryRefresh; - const au = this.acknowledgedUpdates; - if (!this.#ready) return; - persistUpdates({ libraryUpdates: lu, lastLibraryRefresh: llr, acknowledgedUpdateIds: [...au] }); - }); - }); - } - - openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { - this.activeChapter = chapter; this.activeChapterList = chapterList; - if (manga !== undefined) this.activeManga = manga; - } - - closeReader() { - this.activeChapter = null; this.activeChapterList = []; - this.pageUrls = []; this.pageNumber = 1; - } - - addHistory(entry: HistoryEntry, completed = false, minutes?: number) { - this.history = [entry, ...this.history.filter(h => h.chapterId !== entry.chapterId)].slice(0, 500); - if (completed && !this.readLog.find(e => e.chapterId === entry.chapterId)) { - this.readLog = [...this.readLog, { mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes: minutes ?? AVG_MIN_PER_CHAPTER }]; - const uniqueChapters = new Set(this.readLog.map(e => e.chapterId)); - const uniqueManga = new Set(this.readLog.map(e => e.mangaId)); - const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0); - const todayStr = localDateStr(new Date()); - const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - const yesterdayStr = localDateStr(yesterday); - const lastDate = this.readingStats.lastStreakDate; - const streak = lastDate === todayStr ? this.readingStats.currentStreakDays - : lastDate === yesterdayStr ? this.readingStats.currentStreakDays + 1 : 1; - this.readingStats = { - totalChaptersRead: uniqueChapters.size, totalMangaRead: uniqueManga.size, - totalMinutesRead: totalMinutes, firstReadAt: this.readingStats.firstReadAt || entry.readAt, - lastReadAt: entry.readAt, currentStreakDays: streak, - longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr, - }; - const dayKey = localDateStr(new Date()); - this.dailyReadCounts = { ...this.dailyReadCounts, [dayKey]: (this.dailyReadCounts[dayKey] ?? 0) + 1 }; - } - } - - addBookmark(entry: Omit, label?: string) { - this.bookmarks = [{ ...entry, savedAt: Date.now(), label }, ...this.bookmarks.filter(b => b.chapterId !== entry.chapterId)].slice(0, 200); - } - - removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); } - clearBookmarks() { this.bookmarks = []; } - getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId); } - - addMarker(entry: Omit): string { - const id = Math.random().toString(36).slice(2); - this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }]; - return id; - } - - updateMarker(id: string, patch: Partial>) { - this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m); - } - - removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id); } - getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page); } - getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); } - getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); } - clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); } - clearHistory() { this.history = []; this.readLog = []; this.dailyReadCounts = {}; } - - clearHistoryForManga(mangaId: number) { - this.history = this.history.filter(x => x.mangaId !== mangaId); - this.readLog = this.readLog.filter(x => x.mangaId !== mangaId); - this.readingStats = { - ...this.readingStats, - totalChaptersRead: new Set(this.readLog.map(e => e.chapterId)).size, - totalMangaRead: new Set(this.readLog.map(e => e.mangaId)).size, - totalMinutesRead: this.readLog.reduce((sum, e) => sum + e.minutes, 0), - }; - } - - wipeAllData() { - this.history = []; this.readLog = []; this.markers = []; - this.dailyReadCounts = {}; - this.readingStats = { ...DEFAULT_READING_STATS }; - this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} }; - } - - linkManga(idA: number, idB: number) { - if (idA === idB) return; - const links = { ...this.settings.mangaLinks }; - links[idA] = [...new Set([...(links[idA] ?? []), idB])]; - links[idB] = [...new Set([...(links[idB] ?? []), idA])]; - this.settings = { ...this.settings, mangaLinks: links }; - } - - unlinkManga(idA: number, idB: number) { - const links = { ...this.settings.mangaLinks }; - links[idA] = (links[idA] ?? []).filter(id => id !== idB); - links[idB] = (links[idB] ?? []).filter(id => id !== idA); - if (!links[idA].length) delete links[idA]; - if (!links[idB].length) delete links[idB]; - this.settings = { ...this.settings, mangaLinks: links }; - } - - getLinkedMangaIds(mangaId: number) { return this.settings.mangaLinks[mangaId] ?? []; } - - setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) { - const slots = [...(this.settings.heroSlots ?? [null, null, null, null])]; - slots[index] = mangaId; - this.settings = { ...this.settings, heroSlots: slots }; - } - - saveCustomTheme(theme: CustomTheme) { - const i = this.settings.customThemes.findIndex(t => t.id === theme.id); - this.settings = { ...this.settings, customThemes: i >= 0 - ? this.settings.customThemes.map((t, j) => j === i ? theme : t) - : [...this.settings.customThemes, theme] }; - } - - deleteCustomTheme(id: string) { - this.settings = { ...this.settings, - customThemes: this.settings.customThemes.filter(t => t.id !== id), - theme: this.settings.theme === id ? "dark" : this.settings.theme }; - } - - toggleHiddenCategory(id: number) { - const ids = this.settings.hiddenCategoryIds ?? []; - this.settings = { ...this.settings, hiddenCategoryIds: ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id] }; - } - - clearSearchCache() { this.searchCache = new Map(); this.searchLibraryIds = new Set(); this.searchSrcOffset++; } - bumpReaderSession() { this.readerSessionId++; } - - setLibraryUpdates(entries: LibraryUpdateEntry[]) { this.libraryUpdates = entries; this.lastLibraryRefresh = Date.now(); } - clearLibraryUpdates() { this.libraryUpdates = []; this.lastLibraryRefresh = 0; this.acknowledgedUpdates = new Set(); } - - acknowledgeUpdate(mangaId: number) { - if (this.acknowledgedUpdates.has(mangaId)) return; - this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]); - } - - async checkAndMarkCompleted( - mangaId: number, chaps: Chapter[], categories: Category[], - gqlFn: (query: string, vars: Record) => Promise, - UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA?: string, mangaStatus?: string, - ): Promise { - if (this.settings.disableAutoComplete) return; - if (!chaps.length || mangaStatus === "ONGOING") return; - const completed = categories.find(c => c.name === "Completed"); - if (!completed) return; - const allRead = chaps.every(c => c.isRead); - if (allRead) { - await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error); - if (UPDATE_MANGA) await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error); - } else { - await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [], removeFrom: [completed.id] }).catch(console.error); - } - } - - togglePinnedSource(sourceId: string) { - const pins = this.settings.pinnedSourceIds ?? []; - this.settings = { ...this.settings, pinnedSourceIds: pins.includes(sourceId) ? pins.filter(id => id !== sourceId) : [...pins, sourceId] }; - } - - saveReaderPreset(name: string, settings: ReaderSettings): string { - const id = Math.random().toString(36).slice(2); - this.settings = { ...this.settings, readerPresets: [...(this.settings.readerPresets ?? []), { id, name: name.trim() || "Preset", settings }] }; - return id; - } - - updateReaderPreset(id: string, patch: Partial>) { - this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).map(p => p.id === id ? { ...p, ...patch } : p) }; - } - - deleteReaderPreset(id: string) { - this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).filter(p => p.id !== id) }; - } - - setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { - this.settings = { ...this.settings, mangaReaderSettings: { ...(this.settings.mangaReaderSettings ?? {}), [mangaId]: settings } }; - } - - clearMangaReaderSettings(mangaId: number) { - const next = { ...(this.settings.mangaReaderSettings ?? {}) }; - delete next[mangaId]; - this.settings = { ...this.settings, mangaReaderSettings: next }; - } - - bumpCategoryFrecency(catId: number) { - const prev = this.settings.categoryFrecency ?? {}; - this.settings = { ...this.settings, categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } }; - } - - setCategories(cats: Category[]) { this.categories = cats; } - setActiveManga(next: Manga | null) { this.activeManga = next; } - setPreviewManga(next: Manga | null) { this.previewManga = next; } - setActiveSource(next: Source | null) { this.activeSource = next; } - setPageUrls(next: string[]) { this.pageUrls = next; } - setPageNumber(next: number) { this.pageNumber = next; } - setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; } - setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; } - updateSettings(patch: Partial) { this.settings = { ...this.settings, ...patch }; } - resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; } -} - -export const store = new Store(); - -export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); } -export function closeReader() { store.closeReader(); } -export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); } -export function clearHistory() { store.clearHistory(); } -export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); } -export function wipeAllData() { store.wipeAllData(); } -export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); } -export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); } -export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); } -export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); } -export function setCategories(cats: Category[]) { store.setCategories(cats); } -export function setActiveManga(next: Manga | null) { store.setActiveManga(next); } -export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); } -export function setActiveSource(next: Source | null) { store.setActiveSource(next); } -export function setPageUrls(next: string[]) { store.setPageUrls(next); } -export function setPageNumber(next: number) { store.setPageNumber(next); } -export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); } -export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); } -export function togglePinnedSource(sourceId: string) { store.togglePinnedSource(sourceId); } -export function saveReaderPreset(name: string, settings: ReaderSettings): string { return store.saveReaderPreset(name, settings); } -export function updateReaderPreset(id: string, patch: Partial>) { store.updateReaderPreset(id, patch); } -export function deleteReaderPreset(id: string) { store.deleteReaderPreset(id); } -export function setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); } -export function clearMangaReaderSettings(mangaId: number) { store.clearMangaReaderSettings(mangaId); } -export function bumpCategoryFrecency(catId: number) { store.bumpCategoryFrecency(catId); } -export function updateSettings(patch: Partial) { store.updateSettings(patch); } -export function resetKeybinds() { store.resetKeybinds(); } -export function clearSearchCache() { store.clearSearchCache(); } -export function setLibraryUpdates(entries: LibraryUpdateEntry[]) { store.setLibraryUpdates(entries); } -export function clearLibraryUpdates() { store.clearLibraryUpdates(); } -export function acknowledgeUpdate(mangaId: number) { store.acknowledgeUpdate(mangaId); } -export function bumpReaderSession() { store.bumpReaderSession(); } -export function addBookmark(entry: Omit, label?: string) { store.addBookmark(entry, label); } -export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); } -export function clearBookmarks() { store.clearBookmarks(); } -export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); } -export function addMarker(entry: Omit): string { return store.addMarker(entry); } -export function updateMarker(id: string, patch: Partial>) { store.updateMarker(id, patch); } -export function removeMarker(id: string) { store.removeMarker(id); } -export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); } -export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); } -export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(mangaId); } -export function clearMarkersForManga(mangaId: number) { store.clearMarkersForManga(mangaId); } -export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); } -export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); } -export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); } -export async function checkAndMarkCompleted( - mangaId: number, chaps: Chapter[], categories: Category[], - gqlFn: (query: string, vars: Record) => Promise, - UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA?: string, mangaStatus?: string, -): Promise { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); } - -export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte"; -export { setNavPage, setSettingsOpen, setSearchPrefill, setSearchQuery, setGenreFilter, saveScroll, getScroll } from "./app.svelte"; \ No newline at end of file diff --git a/_old/types/api.ts b/_old/types/api.ts deleted file mode 100644 index 31e5933..0000000 --- a/_old/types/api.ts +++ /dev/null @@ -1,62 +0,0 @@ -export interface DownloadQueueItem { - progress: number; - state: "QUEUED" | "DOWNLOADING" | "FINISHED" | "ERROR"; - tries: number; - 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 PageInfo { - hasNextPage: boolean; -} - -export interface PaginatedConnection extends Connection { - pageInfo: PageInfo; - totalCount?: number; -} - -export interface MetaEntry { - key: string; - value: string; -} - -export interface UpdaterJobsInfo { - isRunning: boolean; - finishedJobs: number; - totalJobs: number; - skippedMangasCount: number; - skippedCategoriesCount: number; -} - -export interface UpdateStatus { - jobsInfo: UpdaterJobsInfo; -} - -export interface AboutServer { - name: string; - version: string; - buildType: string; - buildTime: string; - github: string; - discord: string; -} - -export interface ServerUpdateEntry { - channel: string; - tag: string; - url: string; -} \ No newline at end of file diff --git a/_old/types/chapter.ts b/_old/types/chapter.ts deleted file mode 100644 index cd2770b..0000000 --- a/_old/types/chapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface Chapter { - id: number; - name: string; - chapterNumber: number; - sourceOrder: number; - isRead: boolean; - isDownloaded: boolean; - isBookmarked: boolean; - pageCount: number; - mangaId: number; - fetchedAt?: string; - uploadDate?: string | null; - realUrl?: string | null; - url?: string; - lastPageRead?: number; - lastReadAt?: string; - scanlator?: string | null; - manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null; -} \ No newline at end of file diff --git a/_old/types/extension.ts b/_old/types/extension.ts deleted file mode 100644 index b8fbe6c..0000000 --- a/_old/types/extension.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface Source { - id: string; - name: string; - lang: string; - displayName: string; - iconUrl: string; - isNsfw: boolean; - isConfigurable: boolean; - supportsLatest: boolean; - baseUrl?: string | null; -} - -export interface Extension { - apkName: string; - pkgName: string; - name: string; - lang: string; - versionName: string; - isInstalled: boolean; - isObsolete: boolean; - hasUpdate: boolean; - iconUrl: string; -} \ No newline at end of file diff --git a/_old/types/history.ts b/_old/types/history.ts deleted file mode 100644 index 2505731..0000000 --- a/_old/types/history.ts +++ /dev/null @@ -1,35 +0,0 @@ -export interface HistoryEntry { - mangaId: number; mangaTitle: string; thumbnailUrl: string; - chapterId: number; chapterName: string; readAt: number; -} - -export interface BookmarkEntry { - mangaId: number; mangaTitle: string; thumbnailUrl: string; - chapterId: number; chapterName: string; pageNumber: number; - savedAt: number; label?: string; -} - -export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple"; - -export interface MarkerEntry { - id: string; mangaId: number; mangaTitle: string; thumbnailUrl: string; - chapterId: number; chapterName: string; pageNumber: number; - note: string; color: MarkerColor; createdAt: number; updatedAt?: number; -} - -export interface ReadLogEntry { mangaId: number; chapterId: number; readAt: number; minutes: number; } - -export interface ReadingStats { - totalChaptersRead: number; totalMangaRead: number; totalMinutesRead: number; - firstReadAt: number; lastReadAt: number; - currentStreakDays: number; longestStreakDays: number; lastStreakDate: string; -} - -export const DEFAULT_READING_STATS: ReadingStats = { - totalChaptersRead: 0, totalMangaRead: 0, totalMinutesRead: 0, - firstReadAt: 0, lastReadAt: 0, currentStreakDays: 0, longestStreakDays: 0, lastStreakDate: "", -}; - -export interface LibraryUpdateEntry { - mangaId: number; mangaTitle: string; thumbnailUrl: string; newChapters: number; checkedAt: number; -} \ No newline at end of file diff --git a/_old/types/index.ts b/_old/types/index.ts deleted file mode 100644 index ea90e1e..0000000 --- a/_old/types/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./manga"; -export * from "./chapter"; -export * from "./extension"; -export * from "./tracking"; -export * from "./api"; -export * from "./settings"; -export * from "./history"; \ No newline at end of file diff --git a/_old/types/manga.ts b/_old/types/manga.ts deleted file mode 100644 index 0401650..0000000 --- a/_old/types/manga.ts +++ /dev/null @@ -1,59 +0,0 @@ -export interface Category { - id: number; - name: string; - order: number; - default: boolean; - includeInUpdate: string; - includeInDownload: string; - mangas?: { nodes: Manga[] }; -} - -export interface ChapterRef { - id: number; - chapterNumber: number; - uploadDate?: string; - lastPageRead?: number; -} - -export interface Manga { - id: number; - title: string; - thumbnailUrl: string; - inLibrary: boolean; - initialized?: boolean; - downloadCount?: number; - unreadCount?: number; - bookmarkCount?: number; - hasDuplicateChapters?: boolean; - chapters?: { totalCount: number }; - description?: string | null; - status?: string | null; - author?: string | null; - artist?: string | null; - genre?: string[]; - realUrl?: string | null; - url?: string; - sourceId?: string; - inLibraryAt?: string | null; - lastFetchedAt?: string | null; - chaptersLastFetchedAt?: string | null; - thumbnailUrlLastFetched?: string | null; - age?: string | null; - chaptersAge?: string | null; - updateStrategy?: "ALWAYS_UPDATE" | "ONLY_FETCH_ONCE"; - latestFetchedChapter?: ChapterRef | null; - latestUploadedChapter?: ChapterRef | null; - latestReadChapter?: ChapterRef | null; - lastReadChapter?: ChapterRef | null; - firstUnreadChapter?: ChapterRef | null; - highestNumberedChapter?: ChapterRef | null; - source?: { id: string; name: string; displayName: string } | null; -} - -export interface MangaDetail extends Manga { - description: string | null; - author: string | null; - artist: string | null; - status: string | null; - genre: string[]; -} \ No newline at end of file diff --git a/_old/types/settings.ts b/_old/types/settings.ts deleted file mode 100644 index 2d991aa..0000000 --- a/_old/types/settings.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { DEFAULT_KEYBINDS, type Keybinds } from "../core/keybinds/defaultBinds"; - -export type PageStyle = "single" | "double" | "longstrip"; -export type FitMode = "width" | "height" | "screen" | "original"; -export type LibraryFilter = "all" | "library" | "downloaded" | string; -export type ReadingDirection = "ltr" | "rtl"; -export type ChapterSortDir = "desc" | "asc"; -export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; -export type ContentLevel = "strict" | "moderate" | "unrestricted"; - -export type LibrarySortMode = - | "az" | "unreadCount" | "totalChapters" - | "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded"; - -export type LibrarySortDir = "asc" | "desc"; - -export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN"; -export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked"; - -export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm"; -export type Theme = BuiltinTheme | string; - -export interface ThemeTokens { - "bg-void": string; "bg-base": string; "bg-surface": string; - "bg-raised": string; "bg-overlay": string; "bg-subtle": string; - "border-dim": string; "border-base": string; "border-strong": string; "border-focus": string; - "text-primary": string; "text-secondary": string; "text-muted": string; - "text-faint": string; "text-disabled": string; - "accent": string; "accent-dim": string; "accent-muted": string; - "accent-fg": string; "accent-bright": string; - "color-error": string; "color-error-bg": string; - "color-success": string; "color-info": string; "color-info-bg": string; -} - -export interface CustomTheme { id: string; name: string; tokens: ThemeTokens; } - -export const DEFAULT_THEME_TOKENS: ThemeTokens = { - "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", -}; - -export interface MangaPrefs { - autoDownload: boolean; downloadAhead: number; deleteOnRead: boolean; - deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean; - refreshInterval: "global" | "daily" | "weekly" | "manual"; - preferredScanlator: string; scanlatorFilter: string[]; - scanlatorBlacklist: string[]; scanlatorForce: boolean; - autoDownloadScanlators: string[]; - coverUrl?: string; -} - -export const DEFAULT_MANGA_PREFS: MangaPrefs = { - autoDownload: false, downloadAhead: 0, deleteOnRead: false, - deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false, - refreshInterval: "global", preferredScanlator: "", scanlatorFilter: [], - scanlatorBlacklist: [], scanlatorForce: false, - autoDownloadScanlators: [], -}; - -export interface ReaderSettings { - pageStyle: PageStyle; - fitMode: FitMode; - readingDirection: ReadingDirection; - readerZoom: number; - pageGap: boolean; - optimizeContrast: boolean; - offsetDoubleSpreads: boolean; - barPosition?: "top" | "left" | "right"; -} - -export interface ReaderPreset { - id: string; - name: string; - settings: ReaderSettings; -} - -export interface Settings { - pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode; - readerZoom: number; pageGap: boolean; optimizeContrast: boolean; - offsetDoubleSpreads: boolean; preloadPages: number; - autoMarkRead: boolean; autoNextChapter: boolean; - libraryCropCovers: boolean; libraryPageSize: number; - contentLevel: ContentLevel; sourceOverridesEnabled: boolean; - nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[]; - discordRpc: boolean; - chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number; - uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean; - serverUrl: string; serverBinary: string; serverBinaryArgs: string; autoStartServer: boolean; suwayomiWebUI: boolean; - preferredExtensionLang: string; keybinds: Keybinds; - idleTimeoutMin?: number; splashCards?: boolean; - storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number; - autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number; - heroSlots: (number | null)[]; mangaLinks: Record; - mangaPrefs: Record>; - serverAuthUser: string; serverAuthPass: string; - serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; - socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string; - socksProxyVersion: number; socksProxyUsername: string; socksProxyPassword: string; - flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number; - flareSolverrSessionName: string; flareSolverrSessionTtl: number; flareSolverrAsResponseFallback: boolean; - appLockEnabled: boolean; appLockPin: string; - customThemes: CustomTheme[]; hiddenCategoryIds: number[]; - defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean; - libraryTabSort: Record; - libraryTabStatus: Record; - libraryTabFilters: Record>>; - maxPageWidth?: number; uiScale?: number; - extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string; - qolAnimations: boolean; - libraryStatsAlways: boolean; - pinnedSourceIds: string[]; - readerPresets: ReaderPreset[]; - mangaReaderSettings: Record; - barPosition?: "top" | "left" | "right"; - trackerSyncBack: boolean; - trackerSyncBackThreshold: number | null; - trackerRespectScanlatorFilter: boolean; - pinchZoom?: boolean; - autoLinkOnOpen: boolean; - downloadToastsEnabled: boolean; - downloadAutoRetry: boolean; - hiddenLibraryTabs: string[]; - libraryPinnedTabOrder: string[]; - autoScroll?: boolean; - autoScrollSpeed?: number; - disableAutoComplete: boolean; -} - -export const DEFAULT_SETTINGS: Settings = { - pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width", - readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false, - preloadPages: 3, autoMarkRead: true, autoNextChapter: true, - libraryCropCovers: true, libraryPageSize: 48, - contentLevel: "strict", sourceOverridesEnabled: false, - nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [], - discordRpc: false, - chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25, - uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true, - serverUrl: "http://localhost:4567", serverBinary: "", serverBinaryArgs: "", autoStartServer: true, suwayomiWebUI: false, - preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS, - idleTimeoutMin: 5, splashCards: true, storageLimitGb: null, - markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true, - theme: "dark", libraryBranches: true, renderLimit: 48, - heroSlots: [null, null, null, null], mangaLinks: {}, mangaPrefs: {}, - serverAuthUser: "", serverAuthPass: "", serverAuthMode: "NONE", - socksProxyEnabled: false, socksProxyHost: "", socksProxyPort: "1080", - socksProxyVersion: 5, socksProxyUsername: "", socksProxyPassword: "", - flareSolverrEnabled: false, flareSolverrUrl: "http://localhost:8191", - flareSolverrTimeout: 60, flareSolverrSessionName: "moku", - flareSolverrSessionTtl: 15, flareSolverrAsResponseFallback: false, - appLockEnabled: false, appLockPin: "", - customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null, - savedIsDefaultCategory: false, - libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {}, - extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "", - qolAnimations: true, - libraryStatsAlways: false, - pinnedSourceIds: [], - readerPresets: [], - mangaReaderSettings: {}, - trackerSyncBack: false, - trackerSyncBackThreshold: 20, - trackerRespectScanlatorFilter: true, - pinchZoom: false, - autoLinkOnOpen: false, - downloadToastsEnabled: true, - downloadAutoRetry: false, - hiddenLibraryTabs: [], - libraryPinnedTabOrder: [], - autoScroll: false, - autoScrollSpeed: 5, - disableAutoComplete: false, -}; \ No newline at end of file diff --git a/_old/types/tracking.ts b/_old/types/tracking.ts deleted file mode 100644 index db90e06..0000000 --- a/_old/types/tracking.ts +++ /dev/null @@ -1,52 +0,0 @@ -export interface TrackerStatus { - value: number; - name: string; -} - -export interface Tracker { - id: number; - name: string; - icon: string; - isLoggedIn: boolean; - isTokenExpired: boolean; - authUrl: string | null; - supportsPrivateTracking: boolean; - supportsReadingDates: boolean; - supportsTrackDeletion: boolean; - scores: string[]; - statuses: TrackerStatus[]; -} - -export interface TrackRecord { - id: number; - trackerId: number; - mangaId: number; - remoteId: string; - libraryId: string | null; - title: string; - status: number; - score: number; - displayScore: string; - lastChapterRead: number; - totalChapters: number; - remoteUrl: string; - startDate: string; - finishDate: string; - private: boolean; - manga?: { id: number; title: string; thumbnailUrl: string; inLibrary?: boolean } | null; - tracker?: Pick | null; -} - -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; -} \ No newline at end of file diff --git a/package.json b/package.json index c67d0f4..71c6eec 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,11 @@ "vite": "^8.0.7" }, "dependencies": { + "@capacitor/app": "^8.1.0", "@capacitor/browser": "^8.0.3", "@capacitor/core": "^8.3.4", "@capacitor/filesystem": "^8.1.2", + "@capacitor/preferences": "^8.0.1", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e443563..ee24ace 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@capacitor/app': + specifier: ^8.1.0 + version: 8.1.0(@capacitor/core@8.3.4) '@capacitor/browser': specifier: ^8.0.3 version: 8.0.3(@capacitor/core@8.3.4) @@ -17,6 +20,9 @@ importers: '@capacitor/filesystem': specifier: ^8.1.2 version: 8.1.2(@capacitor/core@8.3.4) + '@capacitor/preferences': + specifier: ^8.0.1 + version: 8.0.1(@capacitor/core@8.3.4) '@tauri-apps/api': specifier: ^2.0.0 version: 2.11.0 @@ -84,6 +90,11 @@ importers: packages: + '@capacitor/app@8.1.0': + resolution: {integrity: sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/browser@8.0.3': resolution: {integrity: sha512-WJWPHEPbweiFoHYmVlCbZf5yrqJ2Rchx2Xvbmd+3Lf+Zkpq3nXBThThY2CF69lYEg1NINGF9BcHThIOEU1gZlQ==} peerDependencies: @@ -100,6 +111,11 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capacitor/preferences@8.0.1': + resolution: {integrity: sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/synapse@1.0.4': resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==} @@ -914,6 +930,10 @@ packages: snapshots: + '@capacitor/app@8.1.0(@capacitor/core@8.3.4)': + dependencies: + '@capacitor/core': 8.3.4 + '@capacitor/browser@8.0.3(@capacitor/core@8.3.4)': dependencies: '@capacitor/core': 8.3.4 @@ -931,6 +951,10 @@ snapshots: '@capacitor/core': 8.3.4 '@capacitor/synapse': 1.0.4 + '@capacitor/preferences@8.0.1(@capacitor/core@8.3.4)': + dependencies: + '@capacitor/core': 8.3.4 + '@capacitor/synapse@1.0.4': {} '@emnapi/core@1.10.0': diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 82c4892..28d5358 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,11 +1,12 @@ -import { initRequestManager } from '$lib/request-manager' -import { initPlatformService } from '$lib/platform-service' -import { appState } from '$lib/state/app.svelte' -import { configureAuth, probeServer } from '$lib/core/auth' -import { loadSettings, loadLibrary, loadUpdates } from '$lib/core/persistence/persist' -import { loadSettingsIntoState } from '$lib/state/settings.svelte' -import { historyState } from '$lib/state/history.svelte' -import { readerState } from '$lib/state/reader.svelte' +import { detectAdapter } from '$lib/platform-adapters' +import { initPlatformService } from '$lib/platform-service' +import { initRequestManager } from '$lib/request-manager' +import { appState } from '$lib/state/app.svelte' +import { configureAuth, probeServer } from '$lib/core/auth' +import { loadSettings, loadLibrary, loadUpdates } from '$lib/core/persistence/persist' +import { loadSettingsIntoState } from '$lib/state/settings.svelte' +import { historyState } from '$lib/state/history.svelte' +import { readerState } from '$lib/state/reader.svelte' const KEY_URL = 'moku_server_url' const KEY_AUTH = 'moku_auth_config' @@ -16,28 +17,6 @@ interface SavedAuth { pass?: string } -function isTauri(): boolean { return '__TAURI_INTERNALS__' in window } -function isCapacitor(): boolean { return 'Capacitor' in window } - -function detectPlatform(): 'tauri' | 'capacitor' | 'web' { - if (isTauri()) return 'tauri' - if (isCapacitor()) return 'capacitor' - return 'web' -} - -async function resolvePlatformAdapter() { - if (isTauri()) { - const { TauriAdapter } = await import('$lib/platform-adapters/tauri') - return new TauriAdapter() - } - if (isCapacitor()) { - const { CapacitorAdapter } = await import('$lib/platform-adapters/capacitor') - return new CapacitorAdapter() - } - const { WebAdapter } = await import('$lib/platform-adapters/web') - return new WebAdapter() -} - async function resolveServerAdapter() { const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi') return new SuwayomiAdapter() @@ -45,18 +24,18 @@ async function resolveServerAdapter() { async function boot() { try { - const [serverAdapter, platformAdapter] = await Promise.all([ - resolveServerAdapter(), - resolvePlatformAdapter(), - ]) + const platformAdapter = detectAdapter() + await platformAdapter.init() + + const serverAdapter = await resolveServerAdapter() - initRequestManager(serverAdapter) initPlatformService(platformAdapter) + initRequestManager(serverAdapter) - appState.platform = detectPlatform() + appState.platform = platformAdapter.platform appState.version = await platformAdapter.getVersion() - const [settingsData, libraryData, _updatesData] = await Promise.all([ + const [settingsData, libraryData] = await Promise.all([ loadSettings(), loadLibrary(), loadUpdates(), @@ -87,8 +66,8 @@ async function boot() { const probe = await probeServer() - if (probe === 'auth_required') { appState.status = 'auth'; return } - if (probe === 'unreachable') { + if (probe === 'auth_required') { appState.status = 'auth'; return } + if (probe === 'unreachable') { appState.error = `Could not reach server at ${savedUrl}` appState.status = 'error' return diff --git a/src/lib/components/home/RecsRow.svelte b/src/lib/components/home/RecsRow.svelte index 18c65c9..e49d175 100644 --- a/src/lib/components/home/RecsRow.svelte +++ b/src/lib/components/home/RecsRow.svelte @@ -38,25 +38,19 @@ let allRecs: RecommendedManga[] = $state([]) let loading = $state(false) - let _ctrl: AbortController | null = null $effect(() => { - const _history = history - const _library = libraryManga - if (!_history.length || !_library.length) { allRecs = []; return } - _ctrl?.abort() + if (!history.length || !libraryManga.length) { allRecs = []; return } const ctrl = new AbortController() - _ctrl = ctrl loading = true - fetchRecommendations(_history, _library, ctrl.signal) + fetchRecommendations(history, libraryManga, ctrl.signal) .then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false } }) .catch(() => { if (!ctrl.signal.aborted) loading = false }) + return () => ctrl.abort() }) - const genres = $derived(topGenres(history, libraryManga)) - - let genreIdx = $state(0) - + const genres = $derived(topGenres(history, libraryManga)) + let genreIdx = $state(0) const activeGenre = $derived(genres[genreIdx] ?? null) const visibleRecs = $derived( @@ -233,7 +227,6 @@ overflow: hidden; text-overflow: ellipsis; } - .empty-msg { font-family: var(--font-ui); font-size: var(--text-sm); diff --git a/src/lib/components/home/lib/recommendations.ts b/src/lib/components/home/lib/recommendations.ts index b15aaff..974ccbe 100644 --- a/src/lib/components/home/lib/recommendations.ts +++ b/src/lib/components/home/lib/recommendations.ts @@ -13,6 +13,7 @@ const TARGET_PER_GENRE = 20 export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] { const byId = new Map(libraryManga.map(m => [m.id, m])) const tally = new Map() + for (const session of history) { const manga = byId.get(session.mangaId) if (!manga?.genre?.length) continue @@ -23,6 +24,7 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string else tally.set(key, { count: 1, original: g }) } } + return [...tally.values()] .sort((a, b) => b.count - a.count) .slice(0, TOP_GENRES) @@ -35,25 +37,32 @@ export async function fetchRecommendations( signal?: AbortSignal, ): Promise { if (!history.length || !libraryManga.length) return [] + const genres = topGenres(history, libraryManga) if (!genres.length) return [] const adapter = getAdapter() const globalSeen = new Set(libraryManga.map(m => m.id)) - const merged: Manga[] = [] - for (const genre of genres) { - if (signal?.aborted) break - try { - const results = await adapter.getMangaByGenre(genre, { excludeInLibrary: true }, signal) - for (const m of results) { - if (globalSeen.has(m.id)) continue - globalSeen.add(m.id) - merged.push(m) - if (merged.length >= genres.length * TARGET_PER_GENRE) break + const perGenre = await Promise.all( + genres.map(async genre => { + if (signal?.aborted) return [] + try { + const { items } = await adapter.getMangaList({ tags: [genre], inLibrary: false }) + return items + } catch { + return [] } - } catch { - continue + }) + ) + + const merged: Manga[] = [] + for (const items of perGenre) { + for (const m of items) { + if (globalSeen.has(m.id)) continue + globalSeen.add(m.id) + merged.push(m) + if (merged.length >= genres.length * TARGET_PER_GENRE) break } } diff --git a/src/lib/core/persistence/credentialVault.ts b/src/lib/core/persistence/credentialVault.ts index 3289193..84cb1bd 100644 --- a/src/lib/core/persistence/credentialVault.ts +++ b/src/lib/core/persistence/credentialVault.ts @@ -1,88 +1,98 @@ -const VAULT_KEY = "moku-credential-vault"; -const SALT_ITERATIONS = 200_000; -const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"]; +import { platformService } from '$lib/platform-service' + +const VAULT_STORE_KEY = 'moku-vault' +const SALT_ITERATIONS = 200_000 +const KEY_USAGE: KeyUsage[] = ['encrypt', 'decrypt'] export interface VaultPayload { - refreshToken?: string; - basicUser?: string; - basicPass?: string; - authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE"; + refreshToken?: string + basicUser?: string + basicPass?: string + authMode: 'UI_LOGIN' | 'BASIC_AUTH' | 'NONE' } interface StoredVault { - salt: string; - iv: string; - data: string; + salt: string + iv: string + data: string } function toB64(buf: ArrayBuffer): string { - return btoa(String.fromCharCode(...new Uint8Array(buf))); + return btoa(String.fromCharCode(...new Uint8Array(buf))) } function fromB64(s: string): Uint8Array { - return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + return Uint8Array.from(atob(s), c => c.charCodeAt(0)) } async function deriveKey(pin: string, salt: Uint8Array): Promise { - const enc = new TextEncoder(); - const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); + const enc = new TextEncoder() + const keyMat = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey']) return crypto.subtle.deriveKey( - { name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" }, + { name: 'PBKDF2', salt, iterations: SALT_ITERATIONS, hash: 'SHA-256' }, keyMat, - { name: "AES-GCM", length: 256 }, + { name: 'AES-GCM', length: 256 }, false, KEY_USAGE, - ); + ) } -export function vaultExists(): boolean { - return !!localStorage.getItem(VAULT_KEY); -} - -export async function lockVault(pin: string, payload: VaultPayload): Promise { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const key = await deriveKey(pin, salt); - - const enc = new TextEncoder(); - const cipher = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - enc.encode(JSON.stringify(payload)), - ); - - localStorage.setItem(VAULT_KEY, JSON.stringify({ - salt: toB64(salt), - iv: toB64(iv), - data: toB64(cipher), - } satisfies StoredVault)); -} - -export async function unlockVault(pin: string): Promise { - const raw = localStorage.getItem(VAULT_KEY); - if (!raw) return null; - +async function readRaw(): Promise { try { - const stored = JSON.parse(raw) as StoredVault; - const key = await deriveKey(pin, fromB64(stored.salt)); - const plain = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: fromB64(stored.iv) }, - key, - fromB64(stored.data), - ); - return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload; + const raw = await platformService.getCredential(VAULT_STORE_KEY) + return raw ? JSON.parse(raw) as StoredVault : null } catch { - return null; + return null } } -export function clearVault(): void { - localStorage.removeItem(VAULT_KEY); +async function writeRaw(vault: StoredVault): Promise { + await platformService.storeCredential(VAULT_STORE_KEY, JSON.stringify(vault)) +} + +export async function vaultExists(): Promise { + return (await readRaw()) !== null +} + +export async function lockVault(pin: string, payload: VaultPayload): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const key = await deriveKey(pin, salt) + const enc = new TextEncoder() + + const cipher = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + enc.encode(JSON.stringify(payload)), + ) + + await writeRaw({ salt: toB64(salt), iv: toB64(iv), data: toB64(cipher) }) +} + +export async function unlockVault(pin: string): Promise { + const stored = await readRaw() + if (!stored) return null + + try { + const key = await deriveKey(pin, fromB64(stored.salt)) + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromB64(stored.iv) }, + key, + fromB64(stored.data), + ) + return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload + } catch { + return null + } +} + +export async function clearVault(): Promise { + await platformService.storeCredential(VAULT_STORE_KEY, '') } export async function rekeyVault(oldPin: string, newPin: string): Promise { - const payload = await unlockVault(oldPin); - if (!payload) return false; - await lockVault(newPin, payload); - return true; + const payload = await unlockVault(oldPin) + if (!payload) return false + await lockVault(newPin, payload) + return true } \ No newline at end of file diff --git a/src/lib/core/persistence/index.ts b/src/lib/core/persistence/index.ts index 1b1f9d9..a3714f7 100644 --- a/src/lib/core/persistence/index.ts +++ b/src/lib/core/persistence/index.ts @@ -1,5 +1,22 @@ -export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist"; -export type { PersistedData } from "./persist"; +export { + loadSettings, saveSettings, + loadLibrary, saveLibrary, + loadUpdates, saveUpdates, + loadBackups, saveBackups, +} from './persist' -export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault"; -export type { VaultPayload } from "./credentialVault"; \ No newline at end of file +export type { + PersistedSettings, + PersistedLibrary, + PersistedUpdates, +} from './persist' + +export { + vaultExists, + lockVault, + unlockVault, + clearVault, + rekeyVault, +} from './credentialVault' + +export type { VaultPayload } from './credentialVault' \ No newline at end of file diff --git a/src/lib/core/persistence/persist.ts b/src/lib/core/persistence/persist.ts index 3bce1ea..9263603 100644 --- a/src/lib/core/persistence/persist.ts +++ b/src/lib/core/persistence/persist.ts @@ -1,6 +1,6 @@ import { platformService } from '$lib/platform-service' -import type { ReadSession } from '$lib/types/history' -import type { BookmarkEntry, MarkerEntry } from '$lib/types/history' +import type { ReadSession } from '$lib/types/history' +import type { BookmarkEntry, MarkerEntry } from '$lib/types/history' const STORE_VERSION = 2 @@ -22,10 +22,6 @@ export interface PersistedUpdates { acknowledgedUpdateIds: number[] } -export interface PersistedBackups { - backupList: { url: string; name: string }[] -} - function migrateLibrary(raw: unknown, fromVersion: number): PersistedLibrary { const data = (raw ?? {}) as Record @@ -36,27 +32,25 @@ function migrateLibrary(raw: unknown, fromVersion: number): PersistedLibrary { pageNumber?: number; readAt: number }> - const sessions: ReadSession[] = oldHistory.map(e => ({ - id: crypto.randomUUID(), - mangaId: e.mangaId, - mangaTitle: e.mangaTitle, - thumbnailUrl: e.thumbnailUrl, - startChapterId: e.chapterId, - startChapterName: e.chapterName, - endChapterId: e.chapterId, - endChapterName: e.chapterName, - startPage: 1, - endPage: e.pageNumber ?? 1, - startedAt: e.readAt, - endedAt: e.readAt, - durationMs: 0, - chaptersSpanned: 1, - })) - return { - sessions, - bookmarks: (data.bookmarks ?? []) as BookmarkEntry[], - markers: (data.markers ?? []) as MarkerEntry[], + sessions: oldHistory.map(e => ({ + id: crypto.randomUUID(), + mangaId: e.mangaId, + mangaTitle: e.mangaTitle, + thumbnailUrl: e.thumbnailUrl, + startChapterId: e.chapterId, + startChapterName: e.chapterName, + endChapterId: e.chapterId, + endChapterName: e.chapterName, + startPage: 1, + endPage: e.pageNumber ?? 1, + startedAt: e.readAt, + endedAt: e.readAt, + durationMs: 0, + chaptersSpanned: 1, + })), + bookmarks: (data.bookmarks ?? []) as BookmarkEntry[], + markers: (data.markers ?? []) as MarkerEntry[], dailyReadCounts: (data.dailyReadCounts ?? {}) as Record, } } @@ -69,19 +63,30 @@ function migrateLibrary(raw: unknown, fromVersion: number): PersistedLibrary { } } +function evacuateLocalStorage(key: string): unknown | null { + if (typeof window === 'undefined') return null + try { + const raw = localStorage.getItem(key) + if (!raw) return null + const parsed = JSON.parse(raw) + localStorage.removeItem(key) + return parsed + } catch { + return null + } +} + export async function loadSettings(): Promise { - const raw = await platformService.loadStore('settings') + const raw = await platformService.loadStore('settings') const data = (raw ?? {}) as Record - const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku_settings') : null - if (legacyRaw && !data.settings) { - try { - const legacySettings = JSON.parse(legacyRaw) - localStorage.removeItem('moku_settings') - const result: PersistedSettings = { storeVersion: STORE_VERSION, settings: legacySettings } + if (!data.settings) { + const legacy = evacuateLocalStorage('moku_settings') + if (legacy) { + const result: PersistedSettings = { storeVersion: STORE_VERSION, settings: legacy } await saveSettings(result) return result - } catch {} + } } return { @@ -99,15 +104,13 @@ export async function loadLibrary(): Promise { const data = (raw ?? {}) as Record const version = (data.storeVersion as number) ?? 1 - const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku-store') : null - if (legacyRaw && !(data.sessions || data.history)) { - try { - const legacy = JSON.parse(legacyRaw) + if (!data.sessions && !data.history) { + const legacy = evacuateLocalStorage('moku-store') + if (legacy) { const migrated = migrateLibrary(legacy, 1) - localStorage.removeItem('moku-store') await saveLibrary(migrated) return migrated - } catch {} + } } return migrateLibrary(raw, version) @@ -136,15 +139,12 @@ export async function loadBackups(): Promise<{ url: string; name: string }[]> { const data = (raw ?? {}) as Record if (!data.backupList) { - try { - const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku_backups') : null - if (legacyRaw) { - const list = JSON.parse(legacyRaw) as { url: string; name: string }[] - localStorage.removeItem('moku_backups') - await saveBackups(list) - return list - } - } catch {} + const legacy = evacuateLocalStorage('moku_backups') + if (legacy) { + const list = legacy as { url: string; name: string }[] + await saveBackups(list) + return list + } return [] } diff --git a/src/lib/platform-adapters/capacitor/index.ts b/src/lib/platform-adapters/capacitor/adapter.ts similarity index 68% rename from src/lib/platform-adapters/capacitor/index.ts rename to src/lib/platform-adapters/capacitor/adapter.ts index af59608..2dd92f5 100644 --- a/src/lib/platform-adapters/capacitor/index.ts +++ b/src/lib/platform-adapters/capacitor/adapter.ts @@ -1,19 +1,27 @@ import type { - PlatformAdapter, - PlatformFeature, - ServerLaunchConfig, - DiscordPresence, - AppUpdateInfo, + PlatformAdapter, PlatformFeature, Platform, + ServerLaunchConfig, DiscordPresence, + AppUpdateInfo, StorageInfo, ReleaseInfo, + UpdateProgress, MigrateProgress, } from '$lib/platform-adapters/types' export class CapacitorAdapter implements PlatformAdapter { - async init() {} + readonly platform: Platform = 'capacitor' + + async init(): Promise {} + async destroy(): Promise {} isSupported(feature: PlatformFeature): boolean { const supported: PlatformFeature[] = ['biometric-auth', 'filesystem'] return supported.includes(feature) } + async getAppDir(): Promise { + const { Filesystem, Directory } = await import('@capacitor/filesystem') + const result = await Filesystem.getUri({ path: '', directory: Directory.Data }) + return result.uri + } + async loadStore(key: string): Promise { try { const { Preferences } = await import('@capacitor/preferences') @@ -31,39 +39,6 @@ export class CapacitorAdapter implements PlatformAdapter { } catch {} } - async launchServer(_config: ServerLaunchConfig) {} - async stopServer() {} - async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' } - - async readFile(path: string): Promise { - const { Filesystem, Directory } = await import('@capacitor/filesystem') - const result = await Filesystem.readFile({ path, directory: Directory.Data }) - const base64 = result.data as string - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) - return bytes - } - - async writeFile(path: string, data: Uint8Array): Promise { - const { Filesystem, Directory } = await import('@capacitor/filesystem') - const binary = String.fromCharCode(...data) - const base64 = btoa(binary) - await Filesystem.writeFile({ path, data: base64, directory: Directory.Data }) - } - - async pickFolder(): Promise { return null } - - async authenticateBiometric(): Promise { - try { - const { NativeBiometric } = await import('capacitor-native-biometric') - await NativeBiometric.verifyIdentity({ reason: 'Authenticate to access Moku', title: 'Biometric Auth' }) - return true - } catch { - return false - } - } - async storeCredential(key: string, value: string): Promise { const { NativeBiometric } = await import('capacitor-native-biometric') await NativeBiometric.setCredentials({ username: key, password: value, server: 'moku' }) @@ -79,14 +54,57 @@ export class CapacitorAdapter implements PlatformAdapter { } } - async setTitle(_title: string) {} - async minimize() {} - async maximize() {} - async close() {} - async toggleFullscreen() {} + async authenticateBiometric(): Promise { + try { + const { NativeBiometric } = await import('capacitor-native-biometric') + await NativeBiometric.verifyIdentity({ reason: 'Authenticate to access Moku', title: 'Biometric Auth' }) + return true + } catch { + return false + } + } - async setDiscordPresence(_presence: DiscordPresence) {} - async clearDiscordPresence() {} + async readFile(path: string): Promise { + const { Filesystem, Directory } = await import('@capacitor/filesystem') + const result = await Filesystem.readFile({ path, directory: Directory.Data }) + const binary = atob(result.data as string) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes + } + + async writeFile(path: string, data: Uint8Array): Promise { + const { Filesystem, Directory } = await import('@capacitor/filesystem') + await Filesystem.writeFile({ + path, + data: btoa(String.fromCharCode(...data)), + directory: Directory.Data, + }) + } + + async pickFolder(): Promise { return null } + async checkPathExists(_path: string): Promise { return false } + async createDirectory(_path: string): Promise {} + async openPath(_path: string): Promise {} + async getDefaultDownloadsPath(): Promise { return '' } + async getStorageInfo(_downloadsPath: string): Promise { + return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' } + } + async migrateDownloads(_src: string, _dst: string): Promise {} + async getAutoBackupDir(): Promise { return '' } + + async launchServer(_config: ServerLaunchConfig): Promise {} + async stopServer(): Promise {} + async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' } + + async setTitle(_title: string): Promise {} + async minimize(): Promise {} + async maximize(): Promise {} + async close(): Promise {} + async toggleFullscreen(): Promise {} + + async setDiscordPresence(_presence: DiscordPresence): Promise {} + async clearDiscordPresence(): Promise {} async getVersion(): Promise { const { App } = await import('@capacitor/app') @@ -100,26 +118,16 @@ export class CapacitorAdapter implements PlatformAdapter { } async checkForAppUpdate(): Promise { return null } - async installAppUpdate(): Promise {} + async installAppUpdate(_tag: string): Promise {} async restartApp(): Promise {} - - async getDefaultDownloadsPath(): Promise { return '' } - async getStorageInfo(): Promise<{ manga_bytes: number; total_bytes: number; free_bytes: number; path: string }> { - return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' } - } - async checkPathExists(_path: string): Promise { return false } - async createDirectory(_path: string): Promise {} - async openPath(_path: string): Promise {} - async getAutoBackupDir(): Promise { return '' } + async exitApp(): Promise {} + async listReleases(): Promise { return [] } async clearMokuCache(): Promise {} async clearSuwayomiCache(): Promise {} async resetSuwayomiData(): Promise {} - async exitApp(): Promise {} - async listReleases() { return [] } - async onUpdateProgress(_cb: (p: { downloaded: number; total: number | null }) => void): Promise<() => void> { return () => {} } + async onUpdateProgress(_cb: (p: UpdateProgress) => void): Promise<() => void> { return () => {} } async onUpdateLaunching(_cb: () => void): Promise<() => void> { return () => {} } - async onMigrateProgress(_cb: (p: { done: number; total: number; current: string }) => void): Promise<() => void> { return () => {} } - async migrateDownloads(_src: string, _dst: string): Promise {} + async onMigrateProgress(_cb: (p: MigrateProgress) => void): Promise<() => void> { return () => {} } } \ No newline at end of file diff --git a/src/lib/platform-adapters/index.ts b/src/lib/platform-adapters/index.ts new file mode 100644 index 0000000..bb98d36 --- /dev/null +++ b/src/lib/platform-adapters/index.ts @@ -0,0 +1,24 @@ +export type { PlatformAdapter, PlatformFeature, Platform } from './types' +export type { + ServerLaunchConfig, DiscordPresence, AppUpdateInfo, + StorageInfo, MigrateProgress, UpdateProgress, ReleaseInfo, +} from './types' + +export { TauriAdapter } from './tauri/adapter' +export { WebAdapter } from './web/adapter' +export { CapacitorAdapter } from './capacitor/adapter' + +import type { PlatformAdapter } from './types' +import { TauriAdapter } from './tauri/adapter' +import { CapacitorAdapter } from './capacitor/adapter' +import { WebAdapter } from './web/adapter' + +export function detectAdapter(): PlatformAdapter { + if (typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window) { + return new TauriAdapter() + } + if (typeof window !== 'undefined' && 'Capacitor' in window) { + return new CapacitorAdapter() + } + return new WebAdapter() +} \ No newline at end of file diff --git a/src/lib/platform-adapters/tauri/index.ts b/src/lib/platform-adapters/tauri/adapter.ts similarity index 71% rename from src/lib/platform-adapters/tauri/index.ts rename to src/lib/platform-adapters/tauri/adapter.ts index 8c2c8fb..f45d004 100644 --- a/src/lib/platform-adapters/tauri/index.ts +++ b/src/lib/platform-adapters/tauri/adapter.ts @@ -5,34 +5,21 @@ import { open } from '@tauri-apps/plu import { readFile, writeFile } from '@tauri-apps/plugin-fs' import { open as openUrl } from '@tauri-apps/plugin-shell' import { getVersion } from '@tauri-apps/api/app' -import { LazyStore } from '@tauri-apps/plugin-store' import { connect, disconnect, setActivity, clearActivity } from 'tauri-plugin-discord-rpc-api' import type { - PlatformAdapter, - PlatformFeature, - ServerLaunchConfig, - DiscordPresence, - AppUpdateInfo, - StorageInfo, - ReleaseInfo, - UpdateProgress, - MigrateProgress, + PlatformAdapter, PlatformFeature, Platform, + ServerLaunchConfig, DiscordPresence, + AppUpdateInfo, StorageInfo, ReleaseInfo, + UpdateProgress, MigrateProgress, } from '$lib/platform-adapters/types' -const APP_ID = '1487894643613106298' - -const storeCache = new Map() - -function getStore(key: string): LazyStore { - if (!storeCache.has(key)) { - storeCache.set(key, new LazyStore(`${key}.json`, { autoSave: false })) - } - return storeCache.get(key)! -} +const DISCORD_APP_ID = '1487894643613106298' export class TauriAdapter implements PlatformAdapter { + readonly platform: Platform = 'tauri' + async init() { - await connect(APP_ID).catch(() => {}) + await connect(DISCORD_APP_ID).catch(() => {}) } async destroy() { @@ -41,47 +28,54 @@ export class TauriAdapter implements PlatformAdapter { isSupported(feature: PlatformFeature): boolean { const supported: PlatformFeature[] = [ - 'server-management', - 'biometric-auth', - 'native-window', - 'filesystem', - 'app-updates', - 'discord-rpc', + 'server-management', 'biometric-auth', + 'native-window', 'filesystem', 'app-updates', 'discord-rpc', ] return supported.includes(feature) } + async getAppDir(): Promise { + return invoke('get_app_dir') + } + async loadStore(key: string): Promise { - return getStore(key).get(key) ?? null + try { + return await invoke('load_store', { key }) + } catch { + return null + } } async saveStore(key: string, value: unknown): Promise { - const store = getStore(key) - await store.set(key, value) - await store.save() + await invoke('save_store', { key, value: JSON.stringify(value) }) } - async launchServer(config: ServerLaunchConfig) { - await invoke('spawn_server', { - binary: config.binary ?? '', - binaryArgs: config.binaryArgs ?? null, - webUiEnabled: config.webUiEnabled ?? false, - }) + async storeCredential(key: string, value: string): Promise { + await invoke('store_credential', { key, value }) } - async stopServer() { - await invoke('kill_server') + async getCredential(key: string): Promise { + try { + return await invoke('get_credential', { key }) + } catch { + return null + } } - async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { - return 'stopped' + async authenticateBiometric(): Promise { + try { + await invoke('windows_hello_authenticate', { reason: 'Authenticate to access Moku' }) + return true + } catch { + return false + } } async readFile(path: string): Promise { return readFile(path) } - async writeFile(path: string, data: Uint8Array) { + async writeFile(path: string, data: Uint8Array): Promise { await writeFile(path, data) } @@ -94,11 +88,11 @@ export class TauriAdapter implements PlatformAdapter { return invoke('check_path_exists', { path }) } - async createDirectory(path: string) { + async createDirectory(path: string): Promise { await invoke('create_directory', { path }) } - async openPath(path: string) { + async openPath(path: string): Promise { await invoke('open_path', { path }) } @@ -110,46 +104,57 @@ export class TauriAdapter implements PlatformAdapter { return invoke('get_storage_info', { downloadsPath }) } - async migrateDownloads(src: string, dst: string) { + async migrateDownloads(src: string, dst: string): Promise { await invoke('migrate_downloads', { src, dst }) } - async authenticateBiometric(): Promise { - try { - await invoke('windows_hello_authenticate', { reason: 'Authenticate to access Moku' }) - return true - } catch { - return false - } + async getAutoBackupDir(): Promise { + return invoke('get_auto_backup_dir') } - async setTitle(title: string) { + async launchServer(config: ServerLaunchConfig): Promise { + await invoke('spawn_server', { + binary: config.binary ?? '', + binaryArgs: config.binaryArgs ?? null, + webUiEnabled: config.webUiEnabled ?? false, + }) + } + + async stopServer(): Promise { + await invoke('kill_server') + } + + async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { + return 'stopped' + } + + async setTitle(title: string): Promise { await getCurrentWindow().setTitle(title) } - async minimize() { + async minimize(): Promise { await getCurrentWindow().minimize() } - async maximize() { + async maximize(): Promise { const win = getCurrentWindow() await (await win.isMaximized() ? win.unmaximize() : win.maximize()) } - async close() { + async close(): Promise { await getCurrentWindow().close() } - async toggleFullscreen() { + async toggleFullscreen(): Promise { const win = getCurrentWindow() await win.setFullscreen(!await win.isFullscreen()) } - async setDiscordPresence(presence: DiscordPresence) { + async setDiscordPresence(presence: DiscordPresence): Promise { await setActivity(presence).catch(() => {}) } - async clearDiscordPresence() { + async clearDiscordPresence(): Promise { await clearActivity().catch(() => {}) } @@ -157,42 +162,57 @@ export class TauriAdapter implements PlatformAdapter { return getVersion() } - async openExternal(url: string) { + async openExternal(url: string): Promise { await openUrl(url) } - async restartApp() { - await invoke('restart_app') - } - - async exitApp() { - await invoke('exit_app') - } - async checkForAppUpdate(): Promise { const releases = await invoke>('list_releases') const current = await getVersion() const valid = releases.filter(r => r.tag_name?.trim()) if (!valid.length) return null + const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) const latest = valid.map(r => r.tag_name).sort((a, b) => { const pa = parse(a), pb = parse(b) for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0) return 0 })[0] + const pa = parse(latest), pb = parse(current) if (!pa.some((n, i) => n > (pb[i] ?? 0))) return null + const rel = valid.find(r => r.tag_name === latest)! return { version: latest.replace(/^v/, ''), url: rel.html_url, notes: rel.body } } + async installAppUpdate(tag: string): Promise { + await invoke('download_and_install_update', { tag }) + } + + async restartApp(): Promise { + await invoke('restart_app') + } + + async exitApp(): Promise { + await invoke('exit_app') + } + async listReleases(): Promise { const all = await invoke('list_releases') return all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim()) } - async installAppUpdate(tag: string) { - await invoke('download_and_install_update', { tag }) + async clearMokuCache(): Promise { + await invoke('clear_moku_cache') + } + + async clearSuwayomiCache(): Promise { + await invoke('clear_suwayomi_cache') + } + + async resetSuwayomiData(): Promise { + await invoke('reset_suwayomi_data') } async onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void> { @@ -203,22 +223,6 @@ export class TauriAdapter implements PlatformAdapter { return listen('update-launching', cb) } - async getAutoBackupDir(): Promise { - return invoke('get_auto_backup_dir') - } - - async clearMokuCache() { - await invoke('clear_moku_cache') - } - - async clearSuwayomiCache() { - await invoke('clear_suwayomi_cache') - } - - async resetSuwayomiData() { - await invoke('reset_suwayomi_data') - } - async onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void> { return listen('migrate_progress', e => cb(e.payload)) } diff --git a/src/lib/platform-adapters/tauri/updater.ts b/src/lib/platform-adapters/tauri/updater.ts index 3ef0979..9b9b129 100644 --- a/src/lib/platform-adapters/tauri/updater.ts +++ b/src/lib/platform-adapters/tauri/updater.ts @@ -6,32 +6,36 @@ function parse(tag: string): number[] { return tag.replace(/^v/, '').split('.').map(Number) } -function compare(a: number[], b: number[]): number { +function isNewer(candidate: number[], current: number[]): boolean { for (let i = 0; i < 3; i++) { - if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0) + if ((candidate[i] ?? 0) > (current[i] ?? 0)) return true + if ((candidate[i] ?? 0) < (current[i] ?? 0)) return false } - return 0 + return false } export async function checkForUpdateSilently(): Promise { try { const [currentVersion, releases] = await Promise.all([ getVersion(), - invoke>('list_releases'), + invoke>('list_releases'), ]) - const valid = releases.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim()) + const valid = releases.filter(r => r.tag_name?.trim()) if (!valid.length) return - const latestTag = valid + const latest = valid .map(r => r.tag_name) - .sort((a, b) => compare(parse(a), parse(b)))[0] - .replace(/^v/, '') + .sort((a, b) => { + const pa = parse(a), pb = parse(b) + for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0) + return 0 + })[0] - if (compare(parse(latestTag), parse(currentVersion)) < 0) { + if (isNewer(parse(latest), parse(currentVersion))) { toast({ kind: 'info', - message: `Update available — v${latestTag}`, + message: `Update available — ${latest}`, detail: 'Open Settings → About to install.', }) } diff --git a/src/lib/platform-adapters/types.ts b/src/lib/platform-adapters/types.ts index 4699199..3836592 100644 --- a/src/lib/platform-adapters/types.ts +++ b/src/lib/platform-adapters/types.ts @@ -6,9 +6,12 @@ export type PlatformFeature = | 'app-updates' | 'discord-rpc' +export type Platform = 'tauri' | 'capacitor' | 'web' + export interface ServerLaunchConfig { - port?: number - [key: string]: unknown + binary?: string + binaryArgs?: string + webUiEnabled?: boolean } export interface DiscordAssets { @@ -23,24 +26,12 @@ export interface DiscordButton { url: string } -export interface DiscordParty { - id?: string - currentSize?: number - maxSize?: number -} - -export interface DiscordTimestamps { - start?: number - end?: number -} - export interface DiscordPresence { state?: string details?: string assets?: DiscordAssets buttons?: DiscordButton[] - party?: DiscordParty - timestamps?: DiscordTimestamps + timestamps?: { start?: number; end?: number } } export interface AppUpdateInfo { @@ -76,25 +67,36 @@ export interface ReleaseInfo { } export interface PlatformAdapter { + readonly platform: Platform + init(): Promise destroy(): Promise isSupported(feature: PlatformFeature): boolean + getAppDir(): Promise + + loadStore(key: string): Promise + saveStore(key: string, value: unknown): Promise + + storeCredential(key: string, value: string): Promise + getCredential(key: string): Promise + authenticateBiometric(): Promise + + readFile(path: string): Promise + writeFile(path: string, data: Uint8Array): Promise + pickFolder(): Promise + checkPathExists(path: string): Promise + createDirectory(path: string): Promise + openPath(path: string): Promise + getDefaultDownloadsPath(): Promise + getStorageInfo(downloadsPath: string): Promise + migrateDownloads(src: string, dst: string): Promise + getAutoBackupDir(): Promise + launchServer(config: ServerLaunchConfig): Promise stopServer(): Promise getServerStatus(): Promise<'running' | 'stopped' | 'error'> - readFile(path: string): Promise - writeFile(path: string, data: Uint8Array): Promise - pickFolder(): Promise - - authenticateBiometric(): Promise - storeCredential(key: string, value: string): Promise - getCredential(key: string): Promise - - loadStore(key: string): Promise - saveStore(key: string, value: unknown): Promise - setTitle(title: string): Promise minimize(): Promise maximize(): Promise @@ -104,27 +106,19 @@ export interface PlatformAdapter { setDiscordPresence(presence: DiscordPresence): Promise clearDiscordPresence(): Promise - getVersion(): Promise - openExternal(url: string): Promise - checkForAppUpdate(): Promise - installAppUpdate(tag: string): Promise - restartApp(): Promise + getVersion(): Promise + openExternal(url: string): Promise + checkForAppUpdate(): Promise + installAppUpdate(tag: string): Promise + restartApp(): Promise + exitApp(): Promise + listReleases(): Promise - getDefaultDownloadsPath(): Promise - getStorageInfo(downloadsPath: string): Promise - checkPathExists(path: string): Promise - createDirectory(path: string): Promise - openPath(path: string): Promise - getAutoBackupDir(): Promise - - clearMokuCache(): Promise - clearSuwayomiCache(): Promise - resetSuwayomiData(): Promise - exitApp(): Promise + clearMokuCache(): Promise + clearSuwayomiCache(): Promise + resetSuwayomiData(): Promise onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void> onUpdateLaunching(cb: () => void): Promise<() => void> - listReleases(): Promise onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void> - migrateDownloads(src: string, dst: string): Promise } \ No newline at end of file diff --git a/src/lib/platform-adapters/web/index.ts b/src/lib/platform-adapters/web/adapter.ts similarity index 52% rename from src/lib/platform-adapters/web/index.ts rename to src/lib/platform-adapters/web/adapter.ts index 5c6aa21..4926fcc 100644 --- a/src/lib/platform-adapters/web/index.ts +++ b/src/lib/platform-adapters/web/adapter.ts @@ -1,22 +1,26 @@ import type { - PlatformAdapter, - PlatformFeature, - ServerLaunchConfig, - DiscordPresence, - AppUpdateInfo, - StorageInfo, - ReleaseInfo, - UpdateProgress, - MigrateProgress, + PlatformAdapter, PlatformFeature, Platform, + ServerLaunchConfig, DiscordPresence, + AppUpdateInfo, StorageInfo, ReleaseInfo, + UpdateProgress, MigrateProgress, } from '$lib/platform-adapters/types' +declare const __APP_VERSION__: string + export class WebAdapter implements PlatformAdapter { - async init() {} + readonly platform: Platform = 'web' + + async init(): Promise {} + async destroy(): Promise {} isSupported(_feature: PlatformFeature): boolean { return false } + async getAppDir(): Promise { + return '' + } + async loadStore(key: string): Promise { try { const raw = localStorage.getItem(`moku:${key}`) @@ -32,24 +36,40 @@ export class WebAdapter implements PlatformAdapter { } catch {} } - async launchServer(_config: ServerLaunchConfig) {} - async stopServer() {} - async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' } + async storeCredential(key: string, value: string): Promise { + localStorage.setItem(`moku:cred:${key}`, value) + } + + async getCredential(key: string): Promise { + return localStorage.getItem(`moku:cred:${key}`) + } + + async authenticateBiometric(): Promise { + return false + } async readFile(_path: string): Promise { return new Uint8Array() } - async writeFile(_path: string, _data: Uint8Array) {} + async writeFile(_path: string, _data: Uint8Array): Promise {} async pickFolder(): Promise { return null } + async checkPathExists(_path: string): Promise { return false } + async createDirectory(_path: string): Promise {} + async openPath(_path: string): Promise {} + async getDefaultDownloadsPath(): Promise { return '' } + async getStorageInfo(_downloadsPath: string): Promise { + return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' } + } + async migrateDownloads(_src: string, _dst: string): Promise {} + async getAutoBackupDir(): Promise { return '' } - async authenticateBiometric(): Promise { return false } - async storeCredential(_key: string, _value: string) {} - async getCredential(_key: string): Promise { return null } + async launchServer(_config: ServerLaunchConfig): Promise {} + async stopServer(): Promise {} + async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' } - async setTitle(title: string) { document.title = title } - async minimize() {} - async maximize() {} - async close() {} - - async toggleFullscreen() { + async setTitle(title: string): Promise { document.title = title } + async minimize(): Promise {} + async maximize(): Promise {} + async close(): Promise {} + async toggleFullscreen(): Promise { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen().catch(() => {}) } else { @@ -57,37 +77,24 @@ export class WebAdapter implements PlatformAdapter { } } - async setDiscordPresence(_presence: DiscordPresence) {} - async clearDiscordPresence() {} + async setDiscordPresence(_presence: DiscordPresence): Promise {} + async clearDiscordPresence(): Promise {} async getVersion(): Promise { return __APP_VERSION__ } - - async openExternal(url: string) { + async openExternal(url: string): Promise { window.open(url, '_blank', 'noopener,noreferrer') } - async checkForAppUpdate(): Promise { return null } - async installAppUpdate(_tag: string) {} - async restartApp() {} - - async getDefaultDownloadsPath(): Promise { return '' } - async getStorageInfo(_downloadsPath: string): Promise { - return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' } - } - async checkPathExists(_path: string): Promise { return false } - async createDirectory(_path: string) {} - async openPath(_path: string) {} - async getAutoBackupDir(): Promise { return '' } - - async clearMokuCache() {} - async clearSuwayomiCache() {} - async resetSuwayomiData() {} - async exitApp() {} - + async installAppUpdate(_tag: string): Promise {} + async restartApp(): Promise {} + async exitApp(): Promise {} async listReleases(): Promise { return [] } + async clearMokuCache(): Promise {} + async clearSuwayomiCache(): Promise {} + async resetSuwayomiData(): Promise {} + async onUpdateProgress(_cb: (p: UpdateProgress) => void): Promise<() => void> { return () => {} } async onUpdateLaunching(_cb: () => void): Promise<() => void> { return () => {} } async onMigrateProgress(_cb: (p: MigrateProgress) => void): Promise<() => void> { return () => {} } - async migrateDownloads(_src: string, _dst: string) {} } \ No newline at end of file diff --git a/src/lib/platform-service/index.ts b/src/lib/platform-service/index.ts index 5923ff1..86618c2 100644 --- a/src/lib/platform-service/index.ts +++ b/src/lib/platform-service/index.ts @@ -1,18 +1,12 @@ +import type { PlatformAdapter } from '$lib/platform-adapters/types' import type { - PlatformAdapter, - PlatformFeature, - ServerLaunchConfig, - DiscordPresence, - AppUpdateInfo, - StorageInfo, - ReleaseInfo, - UpdateProgress, - MigrateProgress, + PlatformFeature, ServerLaunchConfig, DiscordPresence, + AppUpdateInfo, StorageInfo, ReleaseInfo, UpdateProgress, MigrateProgress, } from '$lib/platform-adapters/types' -let adapter: PlatformAdapter +let adapter: PlatformAdapter | null = null -export function initPlatformService(a: PlatformAdapter) { +export function initPlatformService(a: PlatformAdapter): void { adapter = a } @@ -22,56 +16,58 @@ function get(): PlatformAdapter { } export const platformService = { - isSupported: (f: PlatformFeature) => get().isSupported(f), - init: () => get().init(), - destroy: () => get().destroy(), + get platform() { return get().platform }, - launchServer: (c: ServerLaunchConfig) => get().launchServer(c), - stopServer: () => get().stopServer(), - getServerStatus: () => get().getServerStatus(), + isSupported: (f: PlatformFeature) => get().isSupported(f), + init: () => get().init(), + destroy: () => get().destroy(), - readFile: (path: string) => get().readFile(path), - writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data), - pickFolder: () => get().pickFolder(), + getAppDir: () => get().getAppDir(), - authenticateBiometric: () => get().authenticateBiometric(), - storeCredential: (k: string, v: string) => get().storeCredential(k, v), - getCredential: (k: string) => get().getCredential(k), + loadStore: (key: string) => get().loadStore(key), + saveStore: (key: string, value: unknown) => get().saveStore(key, value), - loadStore: (key: string) => get().loadStore(key), - saveStore: (key: string, value: unknown) => get().saveStore(key, value), + storeCredential: (k: string, v: string) => get().storeCredential(k, v), + getCredential: (k: string) => get().getCredential(k), + authenticateBiometric: () => get().authenticateBiometric(), - setTitle: (title: string) => get().setTitle(title), - minimize: () => get().minimize(), - maximize: () => get().maximize(), - close: () => get().close(), - toggleFullscreen: () => get().toggleFullscreen(), + readFile: (path: string) => get().readFile(path), + writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data), + pickFolder: () => get().pickFolder(), + checkPathExists: (path: string) => get().checkPathExists(path), + createDirectory: (path: string) => get().createDirectory(path), + openPath: (path: string) => get().openPath(path), + getDefaultDownloadsPath: () => get().getDefaultDownloadsPath(), + getStorageInfo: (downloadsPath: string) => get().getStorageInfo(downloadsPath), + migrateDownloads:(src: string, dst: string) => get().migrateDownloads(src, dst), + getAutoBackupDir:() => get().getAutoBackupDir(), - setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p), + launchServer: (c: ServerLaunchConfig) => get().launchServer(c), + stopServer: () => get().stopServer(), + getServerStatus: () => get().getServerStatus(), + + setTitle: (title: string) => get().setTitle(title), + minimize: () => get().minimize(), + maximize: () => get().maximize(), + close: () => get().close(), + toggleFullscreen: () => get().toggleFullscreen(), + + setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p), clearDiscordPresence: () => get().clearDiscordPresence(), - getVersion: () => get().getVersion(), - openExternal: (url: string) => get().openExternal(url), - checkForAppUpdate: () => get().checkForAppUpdate(), - installAppUpdate: (tag: string) => get().installAppUpdate(tag), - restartApp: () => get().restartApp(), + getVersion: () => get().getVersion(), + openExternal: (url: string) => get().openExternal(url), + checkForAppUpdate: () => get().checkForAppUpdate(), + installAppUpdate: (tag: string) => get().installAppUpdate(tag), + restartApp: () => get().restartApp(), + exitApp: () => get().exitApp(), + listReleases: () => get().listReleases(), - getDefaultDownloadsPath: () => get().getDefaultDownloadsPath(), - getStorageInfo: (downloadsPath: string) => get().getStorageInfo(downloadsPath), - checkPathExists: (path: string) => get().checkPathExists(path), - createDirectory: (path: string) => get().createDirectory(path), - openPath: (path: string) => get().openPath(path), - getAutoBackupDir: () => get().getAutoBackupDir(), + clearMokuCache: () => get().clearMokuCache(), + clearSuwayomiCache: () => get().clearSuwayomiCache(), + resetSuwayomiData: () => get().resetSuwayomiData(), - clearMokuCache: () => get().clearMokuCache(), - clearSuwayomiCache: () => get().clearSuwayomiCache(), - resetSuwayomiData: () => get().resetSuwayomiData(), - exitApp: () => get().exitApp(), - - listReleases: () => get().listReleases(), - - onUpdateProgress: (cb: (p: UpdateProgress) => void) => get().onUpdateProgress(cb), - onUpdateLaunching: (cb: () => void) => get().onUpdateLaunching(cb), - onMigrateProgress: (cb: (p: MigrateProgress) => void) => get().onMigrateProgress(cb), - migrateDownloads: (src: string, dst: string) => get().migrateDownloads(src, dst), + onUpdateProgress: (cb: (p: UpdateProgress) => void) => get().onUpdateProgress(cb), + onUpdateLaunching: (cb: () => void) => get().onUpdateLaunching(cb), + onMigrateProgress: (cb: (p: MigrateProgress) => void) => get().onMigrateProgress(cb), } \ No newline at end of file diff --git a/src/lib/state/app.svelte.ts b/src/lib/state/app.svelte.ts index 0fa105d..41547e4 100644 --- a/src/lib/state/app.svelte.ts +++ b/src/lib/state/app.svelte.ts @@ -1,9 +1,11 @@ +import type { Platform } from '$lib/platform-adapters/types' + export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error' class AppStore { - settingsOpen: boolean = $state(false) - navPage: string = $state('') - scrollPositions: Map = $state(new Map()) + settingsOpen: boolean = $state(false) + navPage: string = $state('') + scrollPositions: Map = $state(new Map()) setSettingsOpen(next: boolean) { this.settingsOpen = next } setNavPage(next: string) { this.navPage = next } @@ -13,6 +15,7 @@ class AppStore { m.set(key, top) this.scrollPositions = m } + getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0 } } @@ -20,21 +23,22 @@ export const app = new AppStore() export const appState = $state({ status: 'booting' as AppStatus, - error: null as string | null, + error: null as string | null, serverUrl: '', authenticated: false, - authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', - platform: 'web' as 'web' | 'tauri' | 'capacitor', + authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', + platform: 'web' as Platform, version: '', libraryFilter: '', navPage: '', categories: [] as { id: number; name: string }[], history: [] as unknown[], toasts: [] as unknown[], + appDir: '', }) -export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) } -export function saveScroll(key: string, top: number) { app.saveScroll(key, top) } -export function getScroll(key: string): number { return app.getScroll(key) } -export function setGenreFilter(genre: string) { appState.libraryFilter = genre } -export function setNavPage(page: string) { app.setNavPage(page); appState.navPage = page } \ No newline at end of file +export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) } +export function saveScroll(key: string, top: number) { app.saveScroll(key, top) } +export function getScroll(key: string): number { return app.getScroll(key) } +export function setGenreFilter(genre: string) { appState.libraryFilter = genre } +export function setNavPage(page: string) { app.setNavPage(page); appState.navPage = page } \ No newline at end of file diff --git a/src/lib/state/boot.svelte.ts b/src/lib/state/boot.svelte.ts index e9aba9d..a8c10a1 100644 --- a/src/lib/state/boot.svelte.ts +++ b/src/lib/state/boot.svelte.ts @@ -1,5 +1,8 @@ +import { detectAdapter } from '$lib/platform-adapters' +import { initPlatformService } from '$lib/platform-service' +import { platformService } from '$lib/platform-service' import { probeServer, loginBasic, loginUI } from '$lib/core/auth' -import { appState } from '$lib/state/app.svelte' +import { appState } from '$lib/state/app.svelte' const MAX_ATTEMPTS = 15 const BG_MAX_ATTEMPTS = 60 @@ -19,6 +22,15 @@ export const boot = $state({ let probeGeneration = 0 +export async function initPlatform(): Promise { + const adapter = detectAdapter() + initPlatformService(adapter) + await adapter.init() + appState.platform = adapter.platform + appState.version = await platformService.getVersion() + appState.appDir = await platformService.getAppDir() +} + function handleProbeSuccess(gen: number) { if (gen !== probeGeneration) return boot.failed = false @@ -28,7 +40,12 @@ function handleProbeSuccess(gen: number) { appState.status = 'ready' } -function handleAuthRequired(gen: number, authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', user: string, pass: string) { +function handleAuthRequired( + gen: number, + authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', + user: string, + pass: string, +) { if (gen !== probeGeneration) return boot.failed = false @@ -79,10 +96,10 @@ export function startProbe( } function startBackgroundProbe( - gen: number, + gen: number, authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', - user: string, - pass: string, + user: string, + pass: string, ) { let bgTries = 0 diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a1cc05c..a76ef7d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,14 +1,12 @@