mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Restructure Repository for SvelteKit
This commit is contained in:
-364
@@ -1,364 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||
import { applyTheme } from "@core/theme";
|
||||
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
||||
import { checkForUpdateSilently } from "@core/updater";
|
||||
import Layout from "@shared/chrome/Layout.svelte";
|
||||
import Reader from "@features/reader/components/Reader.svelte";
|
||||
import Settings from "@features/settings/components/Settings.svelte";
|
||||
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
||||
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
||||
import Toaster from "@shared/chrome/Toaster.svelte";
|
||||
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
||||
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
||||
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
void platform();
|
||||
|
||||
let appReady = $state(false);
|
||||
let idle = $state(false);
|
||||
let devSplash = $state(false);
|
||||
|
||||
let themeEditorOpen = $state(false);
|
||||
let themeEditorEditId = $state<string | null>(null);
|
||||
|
||||
let closeDialogOpen = $state(false);
|
||||
let closeRemember = $state(false);
|
||||
|
||||
function openThemeEditor(id?: string | null) {
|
||||
themeEditorEditId = id ?? null;
|
||||
themeEditorOpen = true;
|
||||
}
|
||||
|
||||
function closeThemeEditor() {
|
||||
themeEditorOpen = false;
|
||||
themeEditorEditId = null;
|
||||
}
|
||||
|
||||
async function doQuit() {
|
||||
if (store.settings.autoStartServer) {
|
||||
await Promise.race([
|
||||
invoke("kill_server").catch(() => {}),
|
||||
new Promise(res => setTimeout(res, 2000)),
|
||||
]);
|
||||
}
|
||||
await invoke("exit_app");
|
||||
}
|
||||
|
||||
async function doHide() {
|
||||
await win.hide();
|
||||
}
|
||||
|
||||
async function handleCloseRequested() {
|
||||
const action = store.settings.closeAction ?? "ask";
|
||||
if (action === "tray") { await doHide(); return; }
|
||||
if (action === "quit") { await doQuit(); return; }
|
||||
closeDialogOpen = true;
|
||||
}
|
||||
|
||||
async function confirmClose(choice: "tray" | "quit") {
|
||||
closeDialogOpen = false;
|
||||
if (closeRemember) updateSettings({ closeAction: choice });
|
||||
closeRemember = false;
|
||||
if (choice === "tray") await doHide();
|
||||
else await doQuit();
|
||||
}
|
||||
|
||||
$effect(() => { void store.settings.theme; applyTheme(); });
|
||||
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
||||
$effect(() => mountZoomKey());
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
return mountIdleDetection(
|
||||
() => { idle = true; },
|
||||
() => { if (idle) idle = false; },
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
downloadStore.poll();
|
||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||
return () => clearInterval(dlInterval);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.settings.discordRpc) {
|
||||
initRpc();
|
||||
} else {
|
||||
clearReading();
|
||||
destroyRpc();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const next = downloadStore.queue.slice();
|
||||
downloadStore.detectTransitions(next);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
||||
|
||||
applyZoom();
|
||||
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
|
||||
const unlistenResize = await win.onResized(async () => {
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
|
||||
const unlistenScale = await win.onScaleChanged(async () => {
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||
|
||||
await initStore();
|
||||
startProbe();
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", {
|
||||
binary: store.settings.serverBinary,
|
||||
webUiEnabled: store.settings.suwayomiWebUI ?? false,
|
||||
}).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||
else console.warn("Could not start server:", err);
|
||||
});
|
||||
}
|
||||
|
||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||
"download-progress",
|
||||
e => setActiveDownloads(e.payload),
|
||||
);
|
||||
|
||||
return () => {
|
||||
stopProbe();
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
unlistenDownload();
|
||||
unlistenClose();
|
||||
destroyRpc();
|
||||
delete (window as any).__mokuShowSplash;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
|
||||
{:else if !appReady && !boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => { appReady = true; }}
|
||||
onRetry={retryBoot}
|
||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||
|
||||
{:else if boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { appReady = true; }} />
|
||||
|
||||
{:else}
|
||||
{#if idle && !store.activeChapter}
|
||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => { idle = false; }} />
|
||||
{/if}
|
||||
|
||||
{#if boot.sessionExpired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||
{/if}
|
||||
|
||||
<div id="app-shell" class="root">
|
||||
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||
{#if themeEditorOpen}
|
||||
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
||||
{/if}
|
||||
<MangaPreview />
|
||||
<Toaster />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if closeDialogOpen}
|
||||
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
||||
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="close-header">
|
||||
<p class="close-title">Close Moku?</p>
|
||||
<p class="close-sub">Choose how the app should exit.</p>
|
||||
</div>
|
||||
<div class="close-actions">
|
||||
<button class="close-btn" onclick={() => confirmClose("tray")}>
|
||||
<span class="close-btn-label">Minimize to Tray</span>
|
||||
<span class="close-btn-desc">Keep running in the background</span>
|
||||
</button>
|
||||
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
|
||||
<span class="close-btn-label">Quit</span>
|
||||
<span class="close-btn-desc">Stop Moku entirely</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
|
||||
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
|
||||
<span class="close-remember-label">Remember my choice</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
.close-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.close-dialog {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
width: 300px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||
0 20px 60px rgba(0,0,0,0.65),
|
||||
0 6px 20px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
.close-header { display: flex; flex-direction: column; gap: 3px; }
|
||||
|
||||
.close-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-sub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px var(--sp-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
|
||||
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
|
||||
.close-btn-danger .close-btn-label { color: var(--color-error); }
|
||||
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
|
||||
|
||||
.close-btn-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.close-btn-desc {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.close-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) 0 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.close-remember-toggle {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.close-remember-thumb {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
transition: transform var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close-remember-toggle.on .close-remember-thumb {
|
||||
transform: translateX(12px);
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.close-remember-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
</style>
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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<T> {
|
||||
data: T;
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
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<Response> {
|
||||
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<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const tryRefreshAndRetry = async (): Promise<T | null> => {
|
||||
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<T> => {
|
||||
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<T> = 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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./manga";
|
||||
export * from "./chapters";
|
||||
export * from "./downloads";
|
||||
export * from "./extensions";
|
||||
export * from "./tracking";
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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";
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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 |
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
: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);
|
||||
|
||||
--t-fast: 0.08s ease;
|
||||
--t-base: 0.14s ease;
|
||||
--t-slow: 0.22s ease;
|
||||
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 7px;
|
||||
--radius-xl: 10px;
|
||||
--radius-2xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--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;
|
||||
|
||||
--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;
|
||||
|
||||
--z-reader: 50;
|
||||
--z-modal: 100;
|
||||
--z-settings: 150;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-void);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#svelte {
|
||||
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; }
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
* {
|
||||
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; }
|
||||
|
||||
@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);
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
}
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<div id="svelte">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./selectPortal";
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './sort';
|
||||
export * from './filter';
|
||||
export * from './paginate';
|
||||
export * from './search';
|
||||
export * from './queue';
|
||||
@@ -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<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
concurrency = 6,
|
||||
): Promise<void> {
|
||||
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<string, Promise<unknown>>();
|
||||
|
||||
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
|
||||
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
||||
export function dedupeRequest<T>(
|
||||
keyOrFn: string | ((key: string) => Promise<T>),
|
||||
factory?: () => Promise<T>,
|
||||
): Promise<T> | ((key: string) => Promise<T>) {
|
||||
// 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<T>;
|
||||
const p = factory!().finally(() => _inflight.delete(key));
|
||||
_inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface PaginatedQuery<T> {
|
||||
fetchPage(page: number): Promise<T[]>;
|
||||
reset(): void;
|
||||
hasMore(): boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedQueryConfig<T> {
|
||||
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
|
||||
}
|
||||
|
||||
export function createPaginatedQuery<T>(
|
||||
config: PaginatedQueryConfig<T>,
|
||||
): PaginatedQuery<T> {
|
||||
let _hasMore = true;
|
||||
|
||||
return {
|
||||
async fetchPage(page) {
|
||||
const { items, hasNextPage } = await config.fetcher(page);
|
||||
_hasMore = hasNextPage;
|
||||
return items;
|
||||
},
|
||||
reset() { _hasMore = true; },
|
||||
hasMore() { return _hasMore; },
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export interface RetryOptions {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
||||
}
|
||||
|
||||
export async function fetchWithRetry<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './fetchWithRetry';
|
||||
export * from './batchRequests';
|
||||
export * from './createPaginatedQuery';
|
||||
@@ -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<string | null> | null = null;
|
||||
let _jwtSettingsBase: string | null = null;
|
||||
let _jwtSettings: JwtSettings | null = null;
|
||||
let _jwtSettingsFetchedAt = 0;
|
||||
|
||||
function authDebug(event: string, fields?: Record<string, unknown>) {
|
||||
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<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
|
||||
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<JwtSettings | null> {
|
||||
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<JwtSettings | null> {
|
||||
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<StoredUiAuthSession, "base">) => {
|
||||
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<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function bearerHeader(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
skipped = false,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
||||
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> = { "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";
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, Uint8Array> {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const files = new Map<string, Uint8Array>();
|
||||
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;
|
||||
}
|
||||
Vendored
-134
@@ -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<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
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<Record<string, string>> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const promise = new Promise<string>((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<string> {
|
||||
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;
|
||||
}
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
export * from './memoryCache';
|
||||
export * from './pageCache';
|
||||
export * from './imageCache';
|
||||
export * from './queryCache';
|
||||
Vendored
-84
@@ -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<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||
const aspectCache = new Map<string, number>();
|
||||
|
||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||
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<string[]> {
|
||||
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<number> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { store, linkManga } from "@store/state.svelte";
|
||||
import type { Manga } from "@types";
|
||||
|
||||
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(
|
||||
new URL("./autoLinkWorker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
|
||||
worker.onmessage = (e: MessageEvent<number[]>) => {
|
||||
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] ?? [],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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, CoverManga & { title: string }>,
|
||||
): 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<number, CoverManga & { title: string }>,
|
||||
): 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<string>();
|
||||
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<CoverCandidate[]> {
|
||||
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(" · ") };
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,3 +0,0 @@
|
||||
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
|
||||
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
|
||||
export type { Keybinds } from "./defaultBinds";
|
||||
@@ -1,5 +0,0 @@
|
||||
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
||||
export type { PersistedData } from "./persist";
|
||||
|
||||
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
||||
export type { VaultPayload } from "./credentialVault";
|
||||
@@ -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<string, number>;
|
||||
libraryUpdates: any[];
|
||||
lastLibraryRefresh: number;
|
||||
acknowledgedUpdateIds: number[];
|
||||
}
|
||||
|
||||
export async function loadAllStores(): Promise<PersistedData> {
|
||||
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<number>("storeVersion"),
|
||||
settingsStore.get<any>("settings"),
|
||||
libraryStore.get<any[]>("history"),
|
||||
libraryStore.get<any[]>("bookmarks"),
|
||||
libraryStore.get<any[]>("markers"),
|
||||
libraryStore.get<any[]>("readLog"),
|
||||
libraryStore.get<any>("readingStats"),
|
||||
libraryStore.get<Record<string, number>>("dailyReadCounts"),
|
||||
updatesStore.get<any[]>("libraryUpdates"),
|
||||
updatesStore.get<number>("lastLibraryRefresh"),
|
||||
updatesStore.get<number[]>("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<PersistedData | null> {
|
||||
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<string, number>;
|
||||
}) {
|
||||
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<BackupEntry[]> {
|
||||
const fromStore = await backupsStore.get<BackupEntry[]>("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<void> {
|
||||
await backupsStore.set("backupList", list);
|
||||
await backupsStore.save();
|
||||
}
|
||||
|
||||
export async function resetAuthSettings(): Promise<void> {
|
||||
const current = await settingsStore.get<any>("settings") ?? {};
|
||||
current.serverAuthMode = "NONE";
|
||||
current.serverAuthUser = "";
|
||||
current.serverAuthPass = "";
|
||||
await settingsStore.set("settings", current);
|
||||
await settingsStore.save();
|
||||
localStorage.removeItem("moku-credential-vault");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> | 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));
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './idle';
|
||||
export * from './zoom';
|
||||
export * from './touchscreen';
|
||||
@@ -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<void> {
|
||||
try {
|
||||
const [currentVersion, releases] = await Promise.all([
|
||||
getVersion(),
|
||||
invoke<Array<{ tag_name: string; html_url: string }>>("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 {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@import "./reset.css";
|
||||
@import "./animations.css";
|
||||
@import "./scrollbars.css";
|
||||
@import "./typography.css";
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@import "./original.css";
|
||||
@import "./dark.css";
|
||||
@import "./light.css";
|
||||
@import "./midnight.css";
|
||||
@import "./warm.css";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,5 +0,0 @@
|
||||
:root {
|
||||
--t-fast: 0.08s ease;
|
||||
--t-base: 0.14s ease;
|
||||
--t-slow: 0.22s ease;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
:root {
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 7px;
|
||||
--radius-xl: 10px;
|
||||
--radius-2xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
:root {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
:root {
|
||||
--z-reader: 50;
|
||||
--z-modal: 100;
|
||||
--z-settings: 150;
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, GET_CATEGORIES } from "@api/queries";
|
||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "@core/cache";
|
||||
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "@core/util";
|
||||
import { store, setGenreFilter, setPreviewManga, setNavPage } from "@store/state.svelte";
|
||||
import type { Manga, Source, Category } from "@types/index";
|
||||
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||
import {
|
||||
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
|
||||
parseTags, tagsLabel, matchesAllTags, runConcurrent,
|
||||
} from "@features/discover/lib/searchFilter";
|
||||
|
||||
const prevNavPage = store.navPage;
|
||||
const tags = $derived(parseTags(store.genreFilter));
|
||||
const primaryTag = $derived(tags[0] ?? "");
|
||||
const label = $derived(tagsLabel(tags));
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let sourceManga: Manga[] = $state([]);
|
||||
let loadingInitial = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
const nextPageMap = new Map<string, number>();
|
||||
let sources: Source[] = $state([]);
|
||||
let abortCtrl: AbortController | null = null;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||
});
|
||||
|
||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
||||
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
||||
|
||||
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
|
||||
|
||||
async function load(filter: string) {
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
loadingInitial = true;
|
||||
sourceManga = [];
|
||||
libraryManga = [];
|
||||
visibleCount = PAGE_SIZE;
|
||||
nextPageMap.clear();
|
||||
|
||||
const preferredLang = store.settings.preferredExtensionLang || "en";
|
||||
const t = parseTags(filter);
|
||||
const pt = t[0] ?? "";
|
||||
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const m = new Map(lib.mangas.nodes.map((x) => [x.id, x]));
|
||||
return all.mangas.nodes.map((x) => m.get(x.id) ?? x);
|
||||
}),
|
||||
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
|
||||
|
||||
cache.get(
|
||||
CACHE_KEYS.SOURCES,
|
||||
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
|
||||
Infinity,
|
||||
).then(async (allSources) => {
|
||||
const srcs = allSources.slice(0, MAX_SOURCES);
|
||||
sources = srcs;
|
||||
for (const src of srcs) nextPageMap.set(src.id, -1);
|
||||
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", t);
|
||||
const pageItems: Manga[] = [];
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
|
||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
).catch(() => null);
|
||||
if (!result || ctrl.signal.aborted) break;
|
||||
ps.add(page);
|
||||
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
|
||||
pageItems.push(...matching);
|
||||
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
||||
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
sourceManga = dedupeMangaById([...sourceManga, ...pageItems]);
|
||||
loadingInitial = false;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) loadingInitial = false;
|
||||
}).catch(() => { if (!ctrl.signal.aborted) loadingInitial = false; });
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
if (hasMoreVisible) { visibleCount += PAGE_SIZE; return; }
|
||||
const srcs = sources.filter((s) => (nextPageMap.get(s.id) ?? -1) > 0);
|
||||
if (!srcs.length) return;
|
||||
loadingMore = true;
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
try {
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
const page = nextPageMap.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
).catch(() => { nextPageMap.set(src.id, -1); return null; });
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
ps.add(page);
|
||||
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
|
||||
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) { visibleCount += PAGE_SIZE; loadingMore = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then((d) => { categories = d.categories.nodes.filter((c) => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: BookmarkSimple,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...categories.map((cat): MenuEntry => ({
|
||||
label: (cat.mangas?.nodes ?? []).some((x) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||
icon: Folder,
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: FolderSimplePlus,
|
||||
onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(
|
||||
CREATE_CATEGORY, { name: name.trim() },
|
||||
).catch(console.error);
|
||||
if (res) {
|
||||
const cat = res.createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
$effect(() => () => { abortCtrl?.abort(); });
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
||||
</button>
|
||||
<span class="title">{label}</span>
|
||||
{#if !loadingInitial || filtered.length > 0}
|
||||
<span class="result-count">{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}</span>
|
||||
{/if}
|
||||
{#if !loadingInitial && hasMoreNetwork}
|
||||
<span class="loading-hint">More loading…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingInitial && filtered.length === 0}
|
||||
<div class="grid">
|
||||
{#each Array(50) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">No manga found for "{label}".</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visibleItems as m, i (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||
<div class="cover-wrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<div class="show-more-cell">
|
||||
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); flex-shrink: 0; }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
|
||||
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||
.card:hover .card-title { color: var(--text-primary); }
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,337 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { runConcurrent } from "@core/async/batchRequests";
|
||||
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { preloadBlobUrls } from "@core/cache/imageCache";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga, Source } from "@types";
|
||||
import type { CachedManga } from "@features/discover/lib/searchFilter";
|
||||
|
||||
interface Props {
|
||||
allSources: Source[];
|
||||
availableLangs: string[];
|
||||
hasMultipleLangs: boolean;
|
||||
loadingSources: boolean;
|
||||
pendingPrefill: string;
|
||||
popularResults: (Manga & { _priority: number })[];
|
||||
popularLoading: boolean;
|
||||
sourceCache: Map<number, CachedManga>;
|
||||
onPrefillConsumed: () => void;
|
||||
onPreview: (m: Manga) => void;
|
||||
}
|
||||
let {
|
||||
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
||||
pendingPrefill, popularResults, popularLoading,
|
||||
sourceCache,
|
||||
onPrefillConsumed, onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
|
||||
let kw_query = $state("");
|
||||
let kw_results: SourceResult[] = $state([]);
|
||||
let kw_showAdvanced = $state(false);
|
||||
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||
let kw_abortCtrl: AbortController | null = null;
|
||||
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
interface SourceResult {
|
||||
source: Source;
|
||||
mangas: Manga[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (allSources.length) {
|
||||
const available = new Set(allSources.map((s) => s.lang));
|
||||
kw_selectedLangs = available.has(preferredLang)
|
||||
? new Set([preferredLang])
|
||||
: new Set(availableLangs.slice(0, 1));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||
const q = pendingPrefill;
|
||||
onPrefillConsumed();
|
||||
kw_query = q;
|
||||
kwDoSearch(q);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = kw_query;
|
||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
|
||||
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
|
||||
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
|
||||
});
|
||||
|
||||
function kwGetVisibleSources(): Source[] {
|
||||
let filtered = allSources;
|
||||
if (kw_selectedLangs.size > 0)
|
||||
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
||||
if (store.settings.contentLevel !== "unrestricted")
|
||||
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function kwDoSearch(q: string) {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
const visible = kwGetVisibleSources();
|
||||
if (!visible.length) return;
|
||||
kw_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
kw_abortCtrl = ctrl;
|
||||
const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||
kw_results = initial;
|
||||
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
|
||||
await runConcurrent(visible, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const idx = indexBySrcId.get(src.id)!;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||
preloadBlobUrls(
|
||||
mangas.map((m) => sourceCache.get(m.id)?.thumbnailUrl ?? m.thumbnailUrl),
|
||||
12,
|
||||
);
|
||||
const next = [...kw_results];
|
||||
next[idx] = { ...next[idx], mangas, loading: false };
|
||||
kw_results = next;
|
||||
} catch (e: any) {
|
||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||
const next = [...kw_results];
|
||||
next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" };
|
||||
kw_results = next;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
}
|
||||
|
||||
function kwToggleLang(lang: string) {
|
||||
const next = new Set(kw_selectedLangs);
|
||||
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
|
||||
else next.add(lang);
|
||||
kw_selectedLangs = next;
|
||||
}
|
||||
|
||||
const kw_visibleCount = $derived(kwGetVisibleSources().length);
|
||||
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
||||
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
||||
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
|
||||
|
||||
const kw_flatResults = $derived.by(() => {
|
||||
const all = kw_results.flatMap((r) =>
|
||||
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
|
||||
);
|
||||
const deduped = dedupeMangaByTitle(dedupeMangaById(all), store.settings.mangaLinks) as (Manga & { _sourceName?: string; _priority: number })[];
|
||||
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
kw_abortCtrl?.abort();
|
||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="keywordBar">
|
||||
<div class="searchBar">
|
||||
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
<input
|
||||
bind:this={kw_inputEl}
|
||||
bind:value={kw_query}
|
||||
class="searchInput"
|
||||
placeholder="Search across sources…"
|
||||
use:focusOnMount
|
||||
/>
|
||||
{#if kw_anyLoading}
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else if kw_query}
|
||||
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_results = []; kw_inputEl?.focus(); }}>×</button>
|
||||
{/if}
|
||||
{#if hasMultipleLangs}
|
||||
<button
|
||||
class="advancedBtn"
|
||||
class:advancedBtnActive={kw_showAdvanced}
|
||||
title="Language & filter options"
|
||||
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasMultipleLangs && kw_showAdvanced}
|
||||
<div class="advancedPanel">
|
||||
<div class="advancedHeader">
|
||||
<span class="advancedTitle">Languages</span>
|
||||
<div class="advancedActions">
|
||||
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
||||
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="langGrid">
|
||||
{#each availableLangs as lang (lang)}
|
||||
<button class="langChip" class:langChipActive={kw_selectedLangs.has(lang)} onclick={() => kwToggleLang(lang)}>
|
||||
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="advancedDivider"></div>
|
||||
<div class="advancedFooter">
|
||||
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !kw_query.trim()}
|
||||
{#if popularLoading && popularResults.length === 0}
|
||||
<div class="searchGrid">
|
||||
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
</div>
|
||||
{:else if popularResults.length > 0}
|
||||
<div class="searchHeader">
|
||||
<span class="searchLabel">Popular right now</span>
|
||||
</div>
|
||||
<div class="searchGrid">
|
||||
{#each popularResults as m (m.id)}
|
||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
<p class="srchTitle">{m.title}</p>
|
||||
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if popularLoading}
|
||||
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">Search across sources</p>
|
||||
<p class="emptyHint">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if kw_flatResults.length > 0}
|
||||
<div class="searchHeader">
|
||||
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div class="searchGrid">
|
||||
{#each kw_flatResults as m (m.id)}
|
||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
<p class="srchTitle">{m.title}</p>
|
||||
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if kw_anyLoading}
|
||||
{#each Array(6) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if kw_anyLoading}
|
||||
<div class="searchGrid">
|
||||
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
</div>
|
||||
{:else if kw_allDone && !kw_hasResults}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results for "{kw_query.trim()}"</p>
|
||||
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.clearBtn:hover { color: var(--text-muted); }
|
||||
|
||||
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.advancedPanel { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||
.advancedTitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.advancedActions { display: flex; gap: var(--sp-2); }
|
||||
.advancedLink { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||
.advancedLink:hover { opacity: 0.75; }
|
||||
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.langChip { padding: 3px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.langChip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.advancedDivider { height: 1px; background: var(--border-dim); }
|
||||
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
||||
|
||||
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
|
||||
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
|
||||
|
||||
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
||||
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
|
||||
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
|
||||
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
||||
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
|
||||
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||
</style>
|
||||
@@ -1,327 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { GET_SOURCES } from "@api/queries/extensions";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { FETCH_MANGA } from "@api/mutations/manga";
|
||||
import { runConcurrent } from "@core/async/batchRequests";
|
||||
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||
import { shouldHideNsfw } from "@core/util";
|
||||
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
|
||||
import {
|
||||
toCachedManga,
|
||||
type CachedManga,
|
||||
} from "@features/discover/lib/searchFilter";
|
||||
import type { Manga, Source } from "@types";
|
||||
|
||||
import KeywordTab from "./KeywordTab.svelte";
|
||||
import TagTab from "./TagTab.svelte";
|
||||
import SourceTab from "./SourceTab.svelte";
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
|
||||
const TABS = ["keyword", "tag", "source"] as const;
|
||||
|
||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||
let tabIndicator = $state({ left: 0, width: 0 });
|
||||
|
||||
function updateIndicator() {
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
|
||||
if (!active) return;
|
||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||
}
|
||||
|
||||
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
|
||||
const SEARCH_PAGES = 3;
|
||||
const SEARCH_LIMIT = 200;
|
||||
const SEARCH_BATCH = 20;
|
||||
const POPULAR_CACHE_PAGES = 3;
|
||||
|
||||
type SearchTab = "keyword" | "tag" | "source";
|
||||
let tab: SearchTab = $state("keyword");
|
||||
|
||||
let pendingPrefill = $state("");
|
||||
$effect(() => {
|
||||
if (store.searchPrefill) {
|
||||
const prefill = store.searchPrefill;
|
||||
untrack(() => {
|
||||
pendingPrefill = prefill;
|
||||
tab = "keyword";
|
||||
setSearchPrefill("");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let allSources: Source[] = $state([]);
|
||||
let localSource: Source | null = $state(null);
|
||||
let loadingSources = $state(false);
|
||||
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
|
||||
loadingSources = true;
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => {
|
||||
const nodes = d.sources.nodes;
|
||||
localSource = nodes.find((src: Source) => src.id === "0") ?? null;
|
||||
allSources = nodes.filter((src: Source) => src.id !== "0");
|
||||
startSourceCacheBuild();
|
||||
popularStart(allSources);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
let popular_raw: Manga[] = $state([]);
|
||||
let popular_loading = $state(false);
|
||||
let popular_moreLoading = $state(false);
|
||||
let popular_abortCtrl: AbortController | null = null;
|
||||
let popular_sourcePool: Source[] = $state([]);
|
||||
let popular_sourceCursor = $state(0);
|
||||
let popular_hasMore = $state(false);
|
||||
let popular_seenIds = new Set<number>();
|
||||
let popular_seenTitles = new Set<string>();
|
||||
|
||||
const popular_results: (Manga & { _priority: number })[] = $derived(
|
||||
popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) }))
|
||||
);
|
||||
|
||||
function popular_push(incoming: Manga[]) {
|
||||
const toAdd: Manga[] = [];
|
||||
for (const m of incoming) {
|
||||
if (shouldHideNsfw(m, store.settings)) continue;
|
||||
if (popular_seenIds.has(m.id)) continue;
|
||||
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
||||
if (popular_seenTitles.has(norm)) continue;
|
||||
popular_seenIds.add(m.id);
|
||||
popular_seenTitles.add(norm);
|
||||
toAdd.push(m);
|
||||
}
|
||||
if (!toAdd.length) return;
|
||||
popular_raw = [...popular_raw, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
async function popular_fanOut(signal: AbortSignal) {
|
||||
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
|
||||
if (!batch.length) { popular_hasMore = false; return; }
|
||||
|
||||
await runConcurrent(batch, async (src) => {
|
||||
for (let page = 1; page <= SEARCH_PAGES; page++) {
|
||||
if (signal.aborted) return;
|
||||
const key = `${src.id}|POPULAR|All:p${page}`;
|
||||
let mangas: Manga[];
|
||||
if (store.searchCache?.has(key)) {
|
||||
mangas = store.searchCache.get(key)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "POPULAR", page, query: null },
|
||||
signal,
|
||||
).then((d) => d.fetchSourceManga).catch(() => null);
|
||||
if (!result || signal.aborted) break;
|
||||
mangas = result.mangas;
|
||||
store.searchCache?.set(key, mangas);
|
||||
if (!result.hasNextPage) { popular_push(mangas); break; }
|
||||
}
|
||||
popular_push(mangas);
|
||||
}
|
||||
}, signal);
|
||||
|
||||
popular_sourceCursor += batch.length;
|
||||
popular_hasMore = popular_sourceCursor < popular_sourcePool.length;
|
||||
}
|
||||
|
||||
function popularStart(sources: Source[]) {
|
||||
if (popular_raw.length > 0) return;
|
||||
popular_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
popular_abortCtrl = ctrl;
|
||||
popular_seenIds.clear();
|
||||
popular_seenTitles.clear();
|
||||
popular_raw = [];
|
||||
popular_sourcePool = dedupeSourcesByLang(sources, preferredLang, store.settings, true);
|
||||
popular_sourceCursor = 0;
|
||||
popular_hasMore = false;
|
||||
popular_moreLoading = false;
|
||||
popular_loading = true;
|
||||
(async () => {
|
||||
try {
|
||||
while (!ctrl.signal.aborted && popular_sourceCursor < popular_sourcePool.length) {
|
||||
await popular_fanOut(ctrl.signal);
|
||||
}
|
||||
} catch {}
|
||||
if (!ctrl.signal.aborted) popular_loading = false;
|
||||
})();
|
||||
}
|
||||
|
||||
export const sourceCache = new Map<number, CachedManga>();
|
||||
|
||||
let sourceCacheReady = $state(false);
|
||||
let sourceCacheLoading = $state(false);
|
||||
let sourceCacheEnriching = $state(false);
|
||||
let sourceCacheAbort: AbortController | null = null;
|
||||
|
||||
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
|
||||
const tasks: { src: Source; page: number }[] = [];
|
||||
for (const src of sources) {
|
||||
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
|
||||
}
|
||||
await runConcurrent(tasks, async ({ src, page }) => {
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
const cacheKey = `${src.id}|POPULAR|All:p${page}`;
|
||||
let mangas: Manga[];
|
||||
if (store.searchCache?.has(cacheKey)) {
|
||||
mangas = store.searchCache.get(cacheKey)!;
|
||||
} else {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "POPULAR", page },
|
||||
signal,
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
mangas = d.fetchSourceManga.mangas;
|
||||
store.searchCache?.set(cacheKey, mangas);
|
||||
}
|
||||
for (const m of mangas) {
|
||||
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
}
|
||||
}, signal);
|
||||
}
|
||||
|
||||
async function enrichGenres(signal: AbortSignal) {
|
||||
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
|
||||
if (!unenriched.length) return;
|
||||
sourceCacheEnriching = true;
|
||||
await runConcurrent(unenriched, async (entry) => {
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchManga: { manga: Manga & { genre: string[]; status: string } } }>(
|
||||
FETCH_MANGA, { id: entry.id }, signal,
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
const updated = sourceCache.get(entry.id);
|
||||
if (updated) {
|
||||
updated.genre = d.fetchManga.manga.genre ?? [];
|
||||
updated.status = d.fetchManga.manga.status ?? updated.status;
|
||||
updated.lowerGenres = updated.genre.map((g) => g.toLowerCase());
|
||||
updated.genreEnriched = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
const updated = sourceCache.get(entry.id);
|
||||
if (updated) updated.genreEnriched = true;
|
||||
}
|
||||
}, signal);
|
||||
if (!signal.aborted) sourceCacheEnriching = false;
|
||||
}
|
||||
|
||||
function startSourceCacheBuild() {
|
||||
if (sourceCacheLoading || sourceCacheReady) return;
|
||||
sourceCacheAbort?.abort();
|
||||
const ctrl = new AbortController();
|
||||
sourceCacheAbort = ctrl;
|
||||
sourceCacheLoading = true;
|
||||
sourceCache.clear();
|
||||
const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||
buildSourceCache(dedupedSources, ctrl.signal)
|
||||
.then(() => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
sourceCacheReady = true;
|
||||
sourceCacheLoading = false;
|
||||
enrichGenres(ctrl.signal);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
sourceCacheLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
popular_abortCtrl?.abort();
|
||||
sourceCacheAbort?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="root anim-fade-in">
|
||||
<div class="header">
|
||||
<span class="heading">Search</span>
|
||||
|
||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
||||
{#if anims && tabIndicator.width > 0}
|
||||
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "keyword"; }}>
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
Keyword
|
||||
</button>
|
||||
<button class="tab" class:tabActive={tab === "tag"} onclick={() => { deprioritizeQueue(); tab = "tag"; }}>
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||
</svg>
|
||||
Tags
|
||||
</button>
|
||||
<button class="tab" class:tabActive={tab === "source"} onclick={() => { deprioritizeQueue(); tab = "source"; }}>
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||
</svg>
|
||||
Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tab === "keyword"}
|
||||
<KeywordTab
|
||||
{allSources}
|
||||
{availableLangs}
|
||||
{hasMultipleLangs}
|
||||
{loadingSources}
|
||||
{pendingPrefill}
|
||||
popularResults={popular_results}
|
||||
popularLoading={popular_loading}
|
||||
{sourceCache}
|
||||
query={store.searchQuery}
|
||||
onQueryChange={setSearchQuery}
|
||||
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||
onPreview={setPreviewManga}
|
||||
/>
|
||||
{:else if tab === "tag"}
|
||||
<TagTab
|
||||
{allSources}
|
||||
{sourceCache}
|
||||
{sourceCacheReady}
|
||||
{sourceCacheLoading}
|
||||
{sourceCacheEnriching}
|
||||
onPreview={setPreviewManga}
|
||||
/>
|
||||
{:else}
|
||||
<SourceTab
|
||||
{allSources}
|
||||
{availableLangs}
|
||||
{loadingSources}
|
||||
{localSource}
|
||||
onPreview={setPreviewManga}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||
.tabs { margin-left: auto; display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
|
||||
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
</style>
|
||||
@@ -1,369 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { shouldHideNsfw, shouldHideSource } from "@core/util";
|
||||
import { store } from "@store/state.svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
|
||||
import type { Manga, Source } from "@types";
|
||||
|
||||
interface Props {
|
||||
allSources: Source[];
|
||||
availableLangs: string[];
|
||||
loadingSources: boolean;
|
||||
localSource: Source | null;
|
||||
onPreview: (m: Manga) => void;
|
||||
}
|
||||
let { allSources, availableLangs, loadingSources, localSource, onPreview }: Props = $props();
|
||||
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
|
||||
let src_selectedLang = $state(preferredLang || "all");
|
||||
let src_activeSource: Source | null = $state(null);
|
||||
let src_browseResults: Manga[] = $state([]);
|
||||
let src_loadingBrowse = $state(false);
|
||||
let src_browseQuery = $state("");
|
||||
let src_submitted = $state("");
|
||||
let src_hasNextPage = $state(false);
|
||||
let src_currentPage = $state(1);
|
||||
let src_abortCtrl: AbortController | null = null;
|
||||
|
||||
let ctx_x = $state(0);
|
||||
let ctx_y = $state(0);
|
||||
let ctx_source: Source | null = $state(null);
|
||||
|
||||
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
|
||||
const pinnedSources = $derived(
|
||||
pinnedIds
|
||||
.map(id => allSources.find(s => s.id === id))
|
||||
.filter((s): s is Source => !!s)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!allSources.length) return;
|
||||
const langs = new Set(allSources.map((s) => s.lang));
|
||||
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
|
||||
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
|
||||
}
|
||||
});
|
||||
|
||||
const src_visibleSources = $derived.by(() => {
|
||||
const hide = (s: Source) => shouldHideSource(s, store.settings);
|
||||
if (src_selectedLang !== "all") {
|
||||
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
|
||||
}
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of allSources) {
|
||||
if (hide(s)) 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());
|
||||
});
|
||||
|
||||
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||
src_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
src_abortCtrl = ctrl;
|
||||
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type, page, query: q ?? null },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
|
||||
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
||||
src_currentPage = page;
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) src_loadingBrowse = false;
|
||||
}
|
||||
}
|
||||
|
||||
function srcSelectSource(src: Source) {
|
||||
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
|
||||
srcFetchBrowse(src, "POPULAR");
|
||||
}
|
||||
|
||||
function srcHandleSearch() {
|
||||
if (!src_activeSource || !src_browseQuery.trim()) return;
|
||||
src_submitted = src_browseQuery.trim();
|
||||
srcFetchBrowse(src_activeSource, "SEARCH", src_browseQuery.trim());
|
||||
}
|
||||
|
||||
function srcClearSearch() {
|
||||
src_browseQuery = ""; src_submitted = "";
|
||||
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, src: Source) {
|
||||
e.preventDefault();
|
||||
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
|
||||
}
|
||||
function closeCtx() { ctx_source = null; }
|
||||
|
||||
onDestroy(() => { src_abortCtrl?.abort(); });
|
||||
</script>
|
||||
|
||||
<div class="splitRoot">
|
||||
<div class="splitSidebar">
|
||||
<div class="srcLangRow">
|
||||
<span class="langPocketLabel">Language</span>
|
||||
<select class="langSelect" bind:value={src_selectedLang}>
|
||||
<option value="all">All</option>
|
||||
{#each availableLangs as lang (lang)}
|
||||
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loadingSources}
|
||||
<div class="splitLoading">
|
||||
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="splitList">
|
||||
{#if localSource}
|
||||
<button
|
||||
class="splitItem splitItemSource"
|
||||
class:splitItemActive={src_activeSource?.id === localSource.id}
|
||||
onclick={() => srcSelectSource(localSource)}
|
||||
oncontextmenu={(e) => openCtx(e, localSource)}
|
||||
>
|
||||
<div class="localSourceIcon">
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="splitItemLabel">Local Source</span>
|
||||
</button>
|
||||
<div class="localDivider"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedSources.length > 0}
|
||||
<p class="sectionLabel">Pinned</p>
|
||||
{#each pinnedSources as src (src.id)}
|
||||
<button
|
||||
class="splitItem splitItemSource"
|
||||
class:splitItemActive={src_activeSource?.id === src.id}
|
||||
onclick={() => srcSelectSource(src)}
|
||||
oncontextmenu={(e) => openCtx(e, src)}
|
||||
>
|
||||
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitItemLabel">{src.name}</span>
|
||||
<span class="pinIndicator" title="Pinned">
|
||||
<PushPin size={9} weight="fill" />
|
||||
</span>
|
||||
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="localDivider"></div>
|
||||
<p class="sectionLabel">All Sources</p>
|
||||
{/if}
|
||||
|
||||
{#each src_visibleSources as src (src.id)}
|
||||
<button
|
||||
class="splitItem splitItemSource"
|
||||
class:splitItemActive={src_activeSource?.id === src.id}
|
||||
onclick={() => srcSelectSource(src)}
|
||||
oncontextmenu={(e) => openCtx(e, src)}
|
||||
>
|
||||
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitItemLabel">{src.name}</span>
|
||||
{#if src_selectedLang === "all"}
|
||||
<span class="sourceLang">{src.lang.toUpperCase()}</span>
|
||||
{/if}
|
||||
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if src_visibleSources.length === 0}
|
||||
<p class="splitEmpty">No sources for this language</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="splitContent">
|
||||
{#if !src_activeSource}
|
||||
<div class="empty">
|
||||
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">Browse a source</p>
|
||||
<p class="emptyHint">Select a source to see its popular titles, or search within it.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="splitContentHeader">
|
||||
<div class="splitSourceTitle">
|
||||
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitContentTitle">{src_activeSource.displayName}</span>
|
||||
{#if src_loadingBrowse}
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else if src_browseResults.length > 0}
|
||||
<span class="splitResultCount">{src_browseResults.length} results</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sourceBrowseBar">
|
||||
<div class="searchBar" style="flex:1">
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
<input
|
||||
bind:value={src_browseQuery}
|
||||
class="searchInput"
|
||||
placeholder="Search {src_activeSource.displayName}…"
|
||||
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
|
||||
/>
|
||||
{#if src_submitted}
|
||||
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="searchBtn" onclick={srcHandleSearch} disabled={!src_browseQuery.trim() || src_loadingBrowse}>Search</button>
|
||||
</div>
|
||||
|
||||
{#if src_loadingBrowse && src_browseResults.length === 0}
|
||||
<div class="tagGrid">
|
||||
{#each Array(18) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if src_browseResults.length > 0}
|
||||
<div class="tagGrid">
|
||||
{#each src_browseResults as m, i (m.id)}
|
||||
<button class="card" onclick={() => onPreview(m)}>
|
||||
<div class="coverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if src_hasNextPage}
|
||||
<div class="showMoreCell">
|
||||
<button
|
||||
class="showMoreBtn"
|
||||
disabled={src_loadingBrowse}
|
||||
onclick={() => src_activeSource && srcFetchBrowse(src_activeSource, src_submitted ? "SEARCH" : "POPULAR", src_submitted || undefined, src_currentPage + 1)}
|
||||
>
|
||||
{src_loadingBrowse ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !src_loadingBrowse}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results</p>
|
||||
<p class="emptyHint">Try a different search term.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ctx_source}
|
||||
{@const isPinned = pinnedIds.includes(ctx_source.id)}
|
||||
<ContextMenu
|
||||
x={ctx_x}
|
||||
y={ctx_y}
|
||||
onClose={closeCtx}
|
||||
items={[
|
||||
{
|
||||
label: isPinned ? "Unpin source" : "Pin source",
|
||||
icon: isPinned ? PushPinSlash : PushPin,
|
||||
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Browse source",
|
||||
icon: ArrowRight,
|
||||
onClick: () => { srcSelectSource(ctx_source!); },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||
.langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.langSelect { appearance: none; -webkit-appearance: none; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 24px 4px 8px; cursor: pointer; max-width: 110px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
|
||||
.langSelect:hover { border-color: var(--border-strong); background-color: var(--bg-raised); color: var(--text-primary); }
|
||||
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
|
||||
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
|
||||
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
||||
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.splitItemActive:hover { background: var(--accent-muted); }
|
||||
.splitItemSource { gap: var(--sp-2); }
|
||||
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||
.sectionLabel { 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-2) var(--sp-3) var(--sp-1); margin: 0; }
|
||||
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
|
||||
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
|
||||
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
||||
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
|
||||
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
|
||||
.sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.clearBtn:hover { color: var(--text-muted); }
|
||||
.searchBtn { padding: 6px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); flex-shrink: 0; }
|
||||
.searchBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0; }
|
||||
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||
.skTitle { height: 10px; width: 80%; }
|
||||
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||
</style>
|
||||
@@ -1,474 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { MANGAS_BY_GENRE } from "@api/queries/manga";
|
||||
import { runConcurrent } from "@core/async/batchRequests";
|
||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "@core/util";
|
||||
import { store } from "@store/state.svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import {
|
||||
buildTagFilter,
|
||||
filterSourceCache,
|
||||
COMMON_GENRES,
|
||||
MANGA_STATUSES,
|
||||
type TagMode,
|
||||
type CachedManga,
|
||||
} from "@features/discover/lib/searchFilter";
|
||||
import type { Manga, Source } from "@types";
|
||||
|
||||
interface Props {
|
||||
allSources: Source[];
|
||||
sourceCache: Map<number, CachedManga>;
|
||||
sourceCacheReady: boolean;
|
||||
sourceCacheLoading: boolean;
|
||||
sourceCacheEnriching: boolean;
|
||||
onPreview: (m: Manga) => void;
|
||||
}
|
||||
let {
|
||||
allSources, sourceCache,
|
||||
sourceCacheReady, sourceCacheLoading, sourceCacheEnriching,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
const SEARCH_LIMIT = 200;
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
|
||||
let tag_activeTags: string[] = $state([]);
|
||||
let tag_activeStatuses: string[] = $state([]);
|
||||
let tag_tagMode: TagMode = $state("AND");
|
||||
let tag_tagFilter = $state("");
|
||||
|
||||
const tag_filteredGenres = $derived.by(() => {
|
||||
const q = tag_tagFilter.trim().toLowerCase();
|
||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : [...COMMON_GENRES];
|
||||
});
|
||||
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
|
||||
|
||||
let tag_localResults: Manga[] = $state([]);
|
||||
let tag_totalCount = $state(0);
|
||||
let tag_loadingLocal = $state(false);
|
||||
let tag_loadingMoreLocal = $state(false);
|
||||
let tag_localOffset = $state(0);
|
||||
let tag_localHasNext = $state(false);
|
||||
let tag_abortLocal: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _mode = tag_tagMode;
|
||||
const _statuses = tag_activeStatuses;
|
||||
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
|
||||
});
|
||||
|
||||
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
|
||||
if (activeTags.length === 0 && activeStatuses.length === 0) {
|
||||
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
|
||||
return;
|
||||
}
|
||||
tag_abortLocal?.abort();
|
||||
const ctrl = new AbortController();
|
||||
tag_abortLocal = ctrl;
|
||||
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
||||
tag_loadingLocal = true;
|
||||
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
|
||||
MANGAS_BY_GENRE,
|
||||
{ filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
|
||||
ctrl.signal,
|
||||
).then((d) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
|
||||
tag_totalCount = d.mangas.totalCount;
|
||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||
tag_localOffset = (store.settings.renderLimit ?? 48);
|
||||
}).catch((e: any) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
}).finally(() => {
|
||||
if (!ctrl.signal.aborted) tag_loadingLocal = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function tagLoadMoreLocal() {
|
||||
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
||||
tag_loadingMoreLocal = true;
|
||||
tag_abortLocal?.abort();
|
||||
const ctrl = new AbortController();
|
||||
tag_abortLocal = ctrl;
|
||||
try {
|
||||
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
|
||||
MANGAS_BY_GENRE,
|
||||
{ filter: buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
|
||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||
tag_localOffset += (store.settings.renderLimit ?? 48);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) tag_loadingMoreLocal = false;
|
||||
}
|
||||
}
|
||||
|
||||
let tag_searchSources = $state(false);
|
||||
let tag_sourceFiltered: CachedManga[] = $state([]);
|
||||
|
||||
let tag_sourceFanOut: Manga[] = $state([]);
|
||||
let tag_fanOutLoading = $state(false);
|
||||
let tag_fanOutAbort: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _mode = tag_tagMode;
|
||||
const _statuses = tag_activeStatuses;
|
||||
const _ready = sourceCacheReady;
|
||||
const _search = tag_searchSources;
|
||||
untrack(() => {
|
||||
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
|
||||
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, store.settings);
|
||||
} else {
|
||||
tag_sourceFiltered = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _search = tag_searchSources;
|
||||
untrack(() => {
|
||||
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
|
||||
tagStartFanOut(_tags[0]);
|
||||
} else {
|
||||
tag_fanOutAbort?.abort();
|
||||
tag_fanOutAbort = null;
|
||||
tag_sourceFanOut = [];
|
||||
tag_fanOutLoading = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function tagStartFanOut(genre: string) {
|
||||
tag_fanOutAbort?.abort();
|
||||
const ctrl = new AbortController();
|
||||
tag_fanOutAbort = ctrl;
|
||||
tag_sourceFanOut = [];
|
||||
tag_fanOutLoading = true;
|
||||
|
||||
const seenIds = new Set<number>();
|
||||
const seenTitles = new Set<string>();
|
||||
const genreLower = genre.toLowerCase();
|
||||
const srcs = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
for (let page = 1; page <= 2; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
|
||||
let mangas: Manga[];
|
||||
let hasNextPage = false;
|
||||
if (store.searchCache?.has(cacheKey)) {
|
||||
mangas = store.searchCache.get(cacheKey)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga).catch(() => null);
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
mangas = result.mangas;
|
||||
hasNextPage = result.hasNextPage;
|
||||
store.searchCache?.set(cacheKey, mangas);
|
||||
}
|
||||
if (ctrl.signal.aborted) return;
|
||||
const matching = mangas.filter((m) =>
|
||||
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
|
||||
);
|
||||
const candidates = (matching.length ? matching : mangas).filter(
|
||||
(m) => !shouldHideNsfw(m, store.settings)
|
||||
);
|
||||
const toAdd: Manga[] = [];
|
||||
for (const m of candidates) {
|
||||
if (seenIds.has(m.id)) continue;
|
||||
const norm = normalizeTitle(m.title);
|
||||
if (seenTitles.has(norm)) continue;
|
||||
seenIds.add(m.id);
|
||||
seenTitles.add(norm);
|
||||
toAdd.push(m);
|
||||
}
|
||||
if (toAdd.length) {
|
||||
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||
}
|
||||
if (!hasNextPage) return;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
|
||||
}
|
||||
|
||||
let tag_autoSearchFired = $state(false);
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _statuses = tag_activeStatuses;
|
||||
untrack(() => { tag_autoSearchFired = false; });
|
||||
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
|
||||
if (tag_localResults.length < 20) {
|
||||
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
||||
|
||||
const tag_mergedResults = $derived.by(() => {
|
||||
const fanOutMapped = tag_sourceFanOut.filter((m) => !tag_localIds.has(m.id));
|
||||
const cacheMapped: Manga[] = tag_sourceFiltered
|
||||
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some((f) => f.id === m.id))
|
||||
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
|
||||
return dedupeMangaByTitle(
|
||||
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
|
||||
store.settings.mangaLinks,
|
||||
);
|
||||
});
|
||||
|
||||
const tag_totalVisible = $derived(tag_mergedResults.length);
|
||||
|
||||
function tagToggleTag(tag: string) {
|
||||
tag_activeTags = tag_activeTags.includes(tag)
|
||||
? tag_activeTags.filter((t) => t !== tag)
|
||||
: [...tag_activeTags, tag];
|
||||
}
|
||||
|
||||
function tagToggleStatus(status: string) {
|
||||
tag_activeStatuses = tag_activeStatuses.includes(status)
|
||||
? tag_activeStatuses.filter((s) => s !== status)
|
||||
: [...tag_activeStatuses, status];
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
tag_abortLocal?.abort();
|
||||
tag_fanOutAbort?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="splitRoot">
|
||||
|
||||
<div class="splitSidebar">
|
||||
<div class="splitSearchWrap">
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="splitSearchIcon" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
<input bind:value={tag_tagFilter} class="splitSearchInput" placeholder="Filter genres…" />
|
||||
{#if tag_tagFilter}
|
||||
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="splitList">
|
||||
<div class="splitSectionLabel">Status</div>
|
||||
{#each MANGA_STATUSES as { value, label } (value)}
|
||||
<button class="splitItem" class:splitItemActive={tag_activeStatuses.includes(value)} onclick={() => tagToggleStatus(value)}>
|
||||
<span class="splitItemLabel">{label}</span>
|
||||
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark">✓</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
|
||||
{#each tag_filteredGenres as tag (tag)}
|
||||
<button class="splitItem" class:splitItemActive={tag_activeTags.includes(tag)} onclick={() => tagToggleTag(tag)}>
|
||||
<span class="splitItemLabel">{tag}</span>
|
||||
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark">✓</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if tag_filteredGenres.length === 0}
|
||||
<p class="splitEmpty">No matching genres</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="splitContent">
|
||||
{#if !tag_hasActiveFilters}
|
||||
<div class="empty">
|
||||
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">Browse by tag</p>
|
||||
<p class="emptyHint">Select a status or genre to find matching manga.</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
<div class="tagActiveBar">
|
||||
<div class="tagPillRow">
|
||||
{#each tag_activeStatuses as status (status)}
|
||||
<span class="tagPill tagPillStatus">
|
||||
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
|
||||
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
{#each tag_activeTags as tag (tag)}
|
||||
<span class="tagPill">
|
||||
{tag}
|
||||
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tagBarRight">
|
||||
{#if tag_activeTags.length > 1}
|
||||
<div class="tagModeToggle">
|
||||
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "AND"} title="Match ALL tags" onclick={() => (tag_tagMode = "AND")}>AND</button>
|
||||
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "OR"} title="Match ANY tag" onclick={() => (tag_tagMode = "OR")}>OR</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="tagModeBtn"
|
||||
class:tagModeBtnActive={tag_searchSources}
|
||||
title={sourceCacheLoading ? "Building source cache…" : sourceCacheReady ? "Search across sources" : "Sources unavailable"}
|
||||
disabled={!sourceCacheReady && !sourceCacheLoading}
|
||||
onclick={() => (tag_searchSources = !tag_searchSources)}
|
||||
>
|
||||
{#if sourceCacheLoading || tag_fanOutLoading}
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
|
||||
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM101.63,168h52.74C149,186.34,140,202.87,128,215.89,116,202.87,107,186.34,101.63,168ZM98,152a145.72,145.72,0,0,1,0-48h60a145.72,145.72,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.79a161.79,161.79,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154.37,88H101.63C107,69.66,116,53.13,128,40.11,140,53.13,149,69.66,154.37,88ZM174.21,104h38.46a88.15,88.15,0,0,1,0,48H174.21a161.79,161.79,0,0,0,0-48Zm32.32-16H170.71a133.32,133.32,0,0,0-22.7-45.8A88.21,88.21,0,0,1,206.53,88ZM108,42.2A133.32,133.32,0,0,0,85.29,88H49.47A88.21,88.21,0,0,1,108,42.2ZM49.47,168H85.29A133.32,133.32,0,0,0,108,213.8,88.21,88.21,0,0,1,49.47,168Zm98.53,45.8A133.32,133.32,0,0,0,170.71,168h35.82A88.21,88.21,0,0,1,148,213.8Z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
Sources{sourceCacheEnriching ? " ·" : ""}
|
||||
</button>
|
||||
<button class="tagClearAll" onclick={() => { tag_activeTags = []; tag_activeStatuses = []; }}>Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="splitContentHeader">
|
||||
<span class="splitContentTitle">
|
||||
{#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}
|
||||
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if tag_loadingLocal}
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="splitResultCount">
|
||||
{tag_totalVisible}{tag_localHasNext ? "+" : ""} results
|
||||
{#if tag_searchSources && sourceCacheReady}
|
||||
· {sourceCache.size} cached
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
{#if tag_loadingLocal}
|
||||
<div class="tagGrid">
|
||||
{#each Array(48) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tag_mergedResults.length > 0}
|
||||
<div class="tagGrid">
|
||||
{#each tag_mergedResults as m, i (m.id)}
|
||||
<button class="card" onclick={() => onPreview(m)}>
|
||||
<div class="coverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if tag_loadingMoreLocal}
|
||||
{#each Array(12) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results</p>
|
||||
<p class="emptyHint">
|
||||
{#if tag_searchSources}Try OR mode or broader tags.
|
||||
{:else}Try OR mode, enable Sources, or check your library.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.splitSearchWrap { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.splitSearchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-xs); color: var(--text-primary); font-family: var(--font-ui); min-width: 0; }
|
||||
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||
.splitSearchClear { color: var(--text-faint); font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.splitSearchClear:hover { color: var(--text-muted); }
|
||||
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||
.splitSectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: var(--sp-2) var(--sp-3) var(--sp-1); pointer-events: none; user-select: none; }
|
||||
.splitSectionLabelSpaced { margin-top: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.splitItemActive:hover { background: var(--accent-muted); }
|
||||
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
|
||||
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
|
||||
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
|
||||
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||
.tagPillStatus { background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent); border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent); color: var(--color-info, #4a90d9); }
|
||||
.tagPillRemove { color: currentColor; opacity: 0.6; font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||
.tagPillRemove:hover { opacity: 1; }
|
||||
.tagBarRight { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.tagModeToggle { display: flex; border: 1px solid var(--border-dim); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.tagModeBtn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-right: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.tagModeBtn:last-child { border-right: none; }
|
||||
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
|
||||
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
|
||||
|
||||
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||
.skTitle { height: 10px; width: 80%; }
|
||||
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||
</style>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as Search } from "./components/Search.svelte";
|
||||
export * from "./lib/searchFilter";
|
||||
@@ -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<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
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<string, unknown> {
|
||||
const genrePart: Record<string, unknown> | null =
|
||||
tags.length === 0 ? null :
|
||||
mode === "AND"
|
||||
? { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }
|
||||
: { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
|
||||
|
||||
const statusPart: Record<string, unknown> | 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<number, CachedManga>,
|
||||
tags: string[],
|
||||
mode: TagMode,
|
||||
statuses: string[],
|
||||
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { longPress } from "@core/ui/touchscreen";
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
import { pageProgress } from "../lib/downloadQueue";
|
||||
|
||||
interface Props {
|
||||
item: DownloadQueueItem;
|
||||
isActive: boolean;
|
||||
isRemoving: boolean;
|
||||
isSelected: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
item, isActive, isRemoving, isSelected,
|
||||
onRemove, onRetry, onSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const manga = $derived(item.chapter.manga);
|
||||
const pages = $derived(item.chapter.pageCount ?? 0);
|
||||
const prog = $derived(pageProgress(item.progress, pages));
|
||||
const isError = $derived(item.state === "ERROR");
|
||||
const pct = $derived(Math.round(item.progress * 100));
|
||||
|
||||
function rowLongPress(node: HTMLElement) {
|
||||
return longPress(node, {
|
||||
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="row"
|
||||
class:row-active={isActive}
|
||||
class:row-error={isError}
|
||||
class:row-selected={isSelected}
|
||||
class:row-removing={isRemoving}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
tabindex="0"
|
||||
use:rowLongPress
|
||||
onclick={(e) => { 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}
|
||||
<div class="thumb">
|
||||
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="thumb-img" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info">
|
||||
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
|
||||
<span class="chapter-name">{item.chapter.name}</span>
|
||||
{#if pages > 0}
|
||||
<div class="progress-row">
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" class:progress-error={isError} style="width:{pct}%"></div>
|
||||
</div>
|
||||
<span class="pages-label">
|
||||
{#if isActive}
|
||||
{prog.done}/{prog.total}
|
||||
{:else if isError}
|
||||
failed · {item.tries} {item.tries === 1 ? "try" : "tries"}
|
||||
{:else}
|
||||
{prog.total}p
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="row-right">
|
||||
<span class="state-label" class:state-error={isError}>{item.state}</span>
|
||||
<div class="actions">
|
||||
{#if isError}
|
||||
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
|
||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isActive}
|
||||
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
|
||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||
.row.row-active { border-color: var(--accent-dim); }
|
||||
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
|
||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||
|
||||
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
|
||||
.row-active .progress-bar { opacity: 1; }
|
||||
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
|
||||
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 2px; }
|
||||
.action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||
.action-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.action-btn.retry:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
</style>
|
||||
@@ -1,98 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch } from "phosphor-svelte";
|
||||
import DownloadItem from "./DownloadItem.svelte";
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
|
||||
interface Props {
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="list">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="sk-row">
|
||||
<div class="sk-thumb skeleton"></div>
|
||||
|
||||
<div class="sk-info">
|
||||
<div class="skeleton sk-title"></div>
|
||||
<div class="skeleton sk-chapter"></div>
|
||||
<div class="sk-progress-row">
|
||||
<div class="skeleton sk-bar"></div>
|
||||
<div class="skeleton sk-pages"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sk-right">
|
||||
<div class="skeleton sk-state"></div>
|
||||
<div class="sk-actions">
|
||||
<div class="skeleton sk-btn"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if queue.length === 0}
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each queue as item, i (item.chapter.id)}
|
||||
<DownloadItem
|
||||
{item}
|
||||
isActive={i === 0 && isRunning}
|
||||
isRemoving={dequeueing.has(item.chapter.id)}
|
||||
isSelected={selected.has(item.chapter.id)}
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton {
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
|
||||
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
|
||||
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.45s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
|
||||
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
|
||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
|
||||
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
|
||||
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
|
||||
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.sk-bar { flex: 1; height: 2px; }
|
||||
.sk-pages { width: 28px; height: 9px; }
|
||||
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.sk-state { width: 54px; height: 9px; }
|
||||
.sk-actions { display: flex; gap: 2px; }
|
||||
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
@@ -1,230 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
|
||||
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
|
||||
import DownloadQueue from "./DownloadQueue.svelte";
|
||||
import { downloadStore } from "../store/downloadState.svelte";
|
||||
import { formatEta } from "../lib/downloadQueue";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
onMount(() => {
|
||||
downloadStore.poll();
|
||||
});
|
||||
|
||||
let selectAnchor = $state<number | null>(null);
|
||||
let moveBy = $state(1);
|
||||
|
||||
const selectedErrorCount = $derived(
|
||||
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
|
||||
);
|
||||
|
||||
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (e.shiftKey && selectAnchor !== null) {
|
||||
downloadStore.selectRange(selectAnchor, chapterId);
|
||||
} else if (ctrl) {
|
||||
downloadStore.toggleSelect(chapterId);
|
||||
selectAnchor = chapterId;
|
||||
} else {
|
||||
if (downloadStore.selected.size > 1) {
|
||||
downloadStore.toggleSelect(chapterId);
|
||||
selectAnchor = chapterId;
|
||||
} else if (downloadStore.selected.size === 1 && downloadStore.selected.has(chapterId)) {
|
||||
downloadStore.clearSelection();
|
||||
selectAnchor = null;
|
||||
} else {
|
||||
downloadStore.selectOnly(chapterId);
|
||||
selectAnchor = chapterId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOff() {
|
||||
if (downloadStore.selected.size > 0) {
|
||||
downloadStore.clearSelection();
|
||||
selectAnchor = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
downloadStore.clearSelection();
|
||||
selectAnchor = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Downloads</h1>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={downloadStore.autoRetryEnabled}
|
||||
onclick={() => downloadStore.toggleAutoRetry()}
|
||||
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
|
||||
>
|
||||
<Repeat size={14} weight="regular" />
|
||||
</button>
|
||||
{#if downloadStore.hasErrored}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={() => downloadStore.retryAllErrored()}
|
||||
disabled={downloadStore.batchWorking}
|
||||
title="Retry all errored"
|
||||
>
|
||||
{#if downloadStore.batchWorking}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<ArrowClockwise size={14} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={downloadStore.toastsEnabled}
|
||||
onclick={() => downloadStore.toggleToasts()}
|
||||
title={downloadStore.toastsEnabled ? "Mute download notifications" : "Unmute download notifications"}
|
||||
>
|
||||
{#if downloadStore.toastsEnabled}
|
||||
<Bell size={14} weight="regular" />
|
||||
{:else}
|
||||
<BellSlash size={14} weight="regular" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:loading={downloadStore.togglingPlay}
|
||||
onclick={() => downloadStore.togglePlay()}
|
||||
disabled={downloadStore.togglingPlay || (downloadStore.queue.length === 0 && !downloadStore.isRunning)}
|
||||
title={downloadStore.isRunning ? "Pause" : "Resume"}
|
||||
>
|
||||
{#if downloadStore.togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else if downloadStore.isRunning}<Pause size={14} weight="fill" />
|
||||
{:else}<Play size={14} weight="fill" />{/if}
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:loading={downloadStore.clearing}
|
||||
onclick={() => downloadStore.clear()}
|
||||
disabled={downloadStore.clearing || downloadStore.queue.length === 0}
|
||||
title="Clear queue"
|
||||
>
|
||||
{#if downloadStore.clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else}<Trash size={14} weight="regular" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bar-wrap">
|
||||
<div class="status-bar" role="none">
|
||||
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
||||
<span class="status-text">
|
||||
{downloadStore.togglingPlay
|
||||
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
|
||||
: downloadStore.isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
{#if downloadStore.selected.size > 0}
|
||||
<div class="sel-controls">
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
|
||||
<ArrowLineUp size={12} weight="bold" />
|
||||
</button>
|
||||
<div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
|
||||
<CaretUp size={12} weight="bold" />
|
||||
</button>
|
||||
<input
|
||||
class="move-input"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={moveBy}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("down", moveBy); }} title="Move down">
|
||||
<CaretDown size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("bottom"); }} title="Move to bottom">
|
||||
<ArrowLineDown size={12} weight="bold" />
|
||||
</button>
|
||||
{#if selectedErrorCount > 0}
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.retrySelected(); }} title="Retry errors">
|
||||
<ArrowClockwise size={12} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="sel-action-btn sel-action-danger" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.dequeueSelected(); }} title="Remove selected">
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-sep"></div>
|
||||
<span class="status-count">{downloadStore.selected.size} selected</span>
|
||||
{:else}
|
||||
<div class="status-right">
|
||||
{#if downloadStore.isRunning && downloadStore.eta !== null}
|
||||
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
|
||||
{/if}
|
||||
<span class="status-count">{downloadStore.queue.length} queued</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === 'Escape' && handleClickOff()}>
|
||||
<DownloadQueue
|
||||
queue={downloadStore.queue}
|
||||
loading={downloadStore.loading}
|
||||
isRunning={downloadStore.isRunning}
|
||||
dequeueing={downloadStore.dequeueing}
|
||||
selected={downloadStore.selected}
|
||||
onRemove={(id) => downloadStore.dequeue(id)}
|
||||
onRetry={(id) => downloadStore.retryOne(id)}
|
||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
||||
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
|
||||
|
||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
|
||||
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.status-bar { cursor: default; }
|
||||
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.sel-action-danger:hover:not(:disabled) { 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); }
|
||||
|
||||
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
|
||||
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
|
||||
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
|
||||
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
.move-input:focus { color: var(--text-primary); }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { downloadStore } from "./store/downloadState.svelte";
|
||||
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
|
||||
@@ -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<void>,
|
||||
): AutoRetryHandle {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | 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; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<number>): 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<number>,
|
||||
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`;
|
||||
}
|
||||
@@ -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<number>());
|
||||
selected = $state(new Set<number>());
|
||||
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<boolean> {
|
||||
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 = `
|
||||
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
|
||||
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
|
||||
</div>
|
||||
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
|
||||
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
|
||||
The download queue is estimated to exceed 95% of your available storage. Download anyway?
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
|
||||
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
|
||||
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
|
||||
</div>
|
||||
`;
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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();
|
||||
@@ -1,134 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, CaretRight, CaretDown, Books } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Extension } from "@types/index";
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
base: string;
|
||||
primary: Extension;
|
||||
variants: Extension[];
|
||||
expanded: boolean;
|
||||
working: Set<string>;
|
||||
anims: boolean;
|
||||
sources: SourceEntry[];
|
||||
libraryCount: number;
|
||||
onToggle: (base: string) => void;
|
||||
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
||||
onLibrary: (pkgName: string, extensionName: string, iconUrl: string) => void;
|
||||
}
|
||||
|
||||
let { base, primary, variants, expanded, working, anims, sources, libraryCount, onToggle, onMutate, onLibrary }: Props = $props();
|
||||
|
||||
const clickable = $derived(primary.isInstalled);
|
||||
|
||||
const hasVariants = $derived(variants.length > 0);
|
||||
</script>
|
||||
|
||||
<div class="group">
|
||||
<svelte:element
|
||||
this={clickable ? "button" : "div"}
|
||||
class="row"
|
||||
class:row-clickable={clickable}
|
||||
onclick={clickable ? () => onLibrary(primary.pkgName, base, primary.iconUrl) : undefined}
|
||||
>
|
||||
<Thumbnail
|
||||
src={primary.iconUrl}
|
||||
alt={primary.name}
|
||||
class="icon"
|
||||
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")}
|
||||
/>
|
||||
<div class="info">
|
||||
<span class="name">{base}</span>
|
||||
<span class="meta">
|
||||
<span class="lang-tag">{primary.lang.toUpperCase()}</span>
|
||||
{#if primary.isInstalled}
|
||||
<span class="lib-badge" class:lib-badge-empty={libraryCount === 0}>
|
||||
<Books size={10} weight={libraryCount > 0 ? "fill" : "regular"} />
|
||||
{libraryCount > 0 ? libraryCount : 0}
|
||||
|
||||
</span>
|
||||
{/if}
|
||||
v{primary.versionName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if working.has(primary.pkgName)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if primary.hasUpdate}
|
||||
<div class="row-actions">
|
||||
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "update"); }}>Update</button>
|
||||
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||
</div>
|
||||
{:else if primary.isInstalled}
|
||||
<div class="row-actions">
|
||||
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
||||
{/if}
|
||||
|
||||
{#if hasVariants}
|
||||
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); onToggle(base); }} title="{variants.length + 1} languages">
|
||||
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||
<span class="expand-count">{variants.length + 1}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</svelte:element>
|
||||
|
||||
{#if expanded && hasVariants}
|
||||
<div class="variants" class:variants-anim={anims}>
|
||||
{#each variants as v}
|
||||
<div class="variant-row">
|
||||
<span class="lang-tag">{v.lang.toUpperCase()}</span>
|
||||
<span class="variant-name">{v.name}</span>
|
||||
<span class="variant-version">v{v.versionName}</span>
|
||||
{#if v.hasUpdate}<span class="update-badge-small">↑</span>{/if}
|
||||
<div class="variant-actions">
|
||||
{#if working.has(v.pkgName)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if v.hasUpdate}
|
||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "update")}>Update</button>
|
||||
{:else if v.isInstalled}
|
||||
<button class="action-btn-dim" onclick={() => onMutate(v.pkgName, "uninstall")}>Remove</button>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "install")}>Install</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.group { display: flex; flex-direction: column; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); width: 100%; text-align: left; background: none; }
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row-clickable { cursor: pointer; }
|
||||
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||
.lib-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.lib-badge-empty { border-color: var(--border-dim); background: var(--bg-overlay); color: var(--text-faint); }
|
||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||
.action-btn:hover { filter: brightness(1.1); }
|
||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
|
||||
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); }
|
||||
.variants-anim { animation: slideDown 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||
@keyframes slideDown { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.variant-row:hover { background: var(--bg-raised); }
|
||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.variant-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp, CheckCircle, Rows, Globe } from "phosphor-svelte";
|
||||
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||
|
||||
interface Props {
|
||||
filter: Filter;
|
||||
search: string;
|
||||
panel: Panel;
|
||||
refreshing: boolean;
|
||||
updateCount: number;
|
||||
updatingAll: boolean;
|
||||
availableLangs: string[];
|
||||
langFilter: string | null;
|
||||
anims: boolean;
|
||||
tabIndicator: { left: number; width: number };
|
||||
tabsEl: HTMLDivElement | undefined;
|
||||
onFilter: (f: Filter) => void;
|
||||
onSearch: (q: string) => void;
|
||||
onLang: (lang: string | null) => void;
|
||||
onPanel: (p: Panel) => void;
|
||||
onRefresh: () => void;
|
||||
onUpdateAll: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filter, search, panel, refreshing, updateCount, updatingAll,
|
||||
availableLangs, langFilter,
|
||||
anims, tabIndicator,
|
||||
tabsEl = $bindable(),
|
||||
onFilter, onSearch, onLang, onPanel, onRefresh, onUpdateAll,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="heading">Extensions</h1>
|
||||
|
||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
||||
{#if anims && tabIndicator.width > 0}
|
||||
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
|
||||
{#if f.id === "installed"}
|
||||
<CheckCircle size={11} weight="bold" />
|
||||
{:else if f.id === "available"}
|
||||
<Globe size={11} weight="bold" />
|
||||
{:else if f.id === "updates"}
|
||||
<ArrowCircleUp size={11} weight="bold" />
|
||||
{:else if f.id === "all"}
|
||||
<Rows size={11} weight="bold" />
|
||||
{/if}
|
||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearch((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => onPanel("repos")} title="Manage repos">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" class:active={panel === "apk"} onclick={() => onPanel("apk")} title="Install from URL">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
{#if updateCount > 0}
|
||||
<button class="icon-btn update-badge" onclick={onUpdateAll} disabled={updatingAll} title="Update all ({updateCount})">
|
||||
<ArrowCircleUp size={14} weight="fill" class={updatingAll ? "anim-spin" : ""} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if availableLangs.length > 1}
|
||||
<div class="lang-bar">
|
||||
<button class="lang-pill" class:active={langFilter === null} onclick={() => onLang(null)}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="lang-pill" class:active={langFilter === lang} onclick={() => onLang(langFilter === lang ? null : lang)}>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.4; }
|
||||
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
|
||||
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||
.tabs-anims .tab.active { background: transparent; border-color: transparent; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.icon-btn.update-badge { color: var(--accent-fg); }
|
||||
.icon-btn.update-badge:hover:not(:disabled) { background: var(--accent-muted); }
|
||||
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
|
||||
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
|
||||
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
</style>
|
||||
@@ -1,333 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { gql } from "@api/client";
|
||||
import { setPreviewManga } from "@store/state.svelte";
|
||||
import { GET_LIBRARY, GET_SOURCES } from "@api/queries";
|
||||
import { libraryByExtension, type LibraryManga, type SourceNode, type SourceLibrary } from "../lib/extensionLibrary";
|
||||
import SourceMigrateModal from "../panels/SourceMigrateModal.svelte";
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
pkgName: string;
|
||||
extensionName: string;
|
||||
iconUrl: string;
|
||||
cols: number;
|
||||
cropCovers: boolean;
|
||||
statsAlways: boolean;
|
||||
anims: boolean;
|
||||
sources: SourceEntry[];
|
||||
onBack: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
||||
|
||||
let groups: SourceLibrary[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let search = $state("");
|
||||
|
||||
type ContentFilter = "unread" | "downloaded";
|
||||
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
|
||||
let filterOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
|
||||
|
||||
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
||||
|
||||
const allManga = $derived(groups.flatMap(g => g.manga));
|
||||
|
||||
const filtered = $derived((() => {
|
||||
let items = allManga;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||||
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
|
||||
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
|
||||
return items;
|
||||
})());
|
||||
|
||||
let sourceNodes: SourceNode[] = $state([]);
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [libData, srcData] = await Promise.all([
|
||||
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY),
|
||||
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES),
|
||||
]);
|
||||
sourceNodes = srcData.sources.nodes;
|
||||
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFilter(f: ContentFilter) {
|
||||
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
activeFilters = {};
|
||||
}
|
||||
|
||||
function openMigrate(group: SourceLibrary) {
|
||||
const node = sourceNodes.find(s => s.id === group.sourceId);
|
||||
migrateTarget = {
|
||||
sourceId: group.sourceId,
|
||||
sourceName: group.displayName,
|
||||
iconUrl: (node as any)?.iconUrl ?? iconUrl,
|
||||
manga: group.manga,
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!filterOpen) return;
|
||||
function onOutside(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest(".filter-wrap")) filterOpen = false;
|
||||
}
|
||||
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||
return () => document.removeEventListener("mousedown", onOutside, true);
|
||||
});
|
||||
|
||||
const CONTENT_FILTERS: [ContentFilter, string][] = [
|
||||
["unread", "Unread"],
|
||||
["downloaded", "Downloaded"],
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="header-btn" onclick={onBack}>
|
||||
<ArrowLeft size={14} weight="bold" />
|
||||
</button>
|
||||
{#if iconUrl}
|
||||
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
{/if}
|
||||
<div class="title-block">
|
||||
<span class="eyebrow">In Library</span>
|
||||
<span class="title">{extensionName}</span>
|
||||
</div>
|
||||
{#if !loading}
|
||||
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
|
||||
{/if}
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="filter-wrap">
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:filter-btn-active={hasActiveFilters}
|
||||
title="Filter"
|
||||
onclick={() => filterOpen = !filterOpen}
|
||||
>
|
||||
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||
</button>
|
||||
{#if filterOpen}
|
||||
<div class="filter-panel" role="menu">
|
||||
<div class="panel-header">
|
||||
<span class="panel-heading">Filter</span>
|
||||
{#if hasActiveFilters}
|
||||
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Content</p>
|
||||
{#each CONTENT_FILTERS as [f, label]}
|
||||
<button
|
||||
class="panel-item"
|
||||
class:panel-item-active={activeFilters[f]}
|
||||
role="menuitem"
|
||||
onclick={() => toggleFilter(f)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
|
||||
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sources.length > 0}
|
||||
<button class="header-btn" onclick={onSettings} title="Extension settings">
|
||||
<GearSix size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each Array(12) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
|
||||
</div>
|
||||
{:else}
|
||||
{#if groups.length > 1}
|
||||
<div class="source-groups">
|
||||
{#each groups as group}
|
||||
<div class="source-group-header">
|
||||
<span class="source-group-name">{group.displayName}</span>
|
||||
<span class="source-group-count">{group.manga.length}</span>
|
||||
<button class="migrate-btn" onclick={() => openMigrate(group)} title="Migrate this source">
|
||||
<Swap size={12} weight="bold" />
|
||||
Migrate source
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if groups.length === 1}
|
||||
<div class="single-source-bar">
|
||||
<span class="source-group-name">{groups[0].displayName}</span>
|
||||
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
|
||||
<Swap size={12} weight="bold" />
|
||||
Migrate source
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each filtered as m (m.id)}
|
||||
{@const isCompleted = !m.unreadCount && m.downloadCount > 0}
|
||||
<button class="card" class:anims onclick={() => setPreviewManga(m as any)}>
|
||||
<div class="cover-wrap" class:completed={isCompleted}>
|
||||
<Thumbnail
|
||||
src={resolvedCover(m.id, m.thumbnailUrl)}
|
||||
alt={m.title}
|
||||
class="cover"
|
||||
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||
<div class="overlay-badges">
|
||||
{#if isCompleted}
|
||||
<span class="badge badge-done">✓ Done</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if migrateTarget}
|
||||
<SourceMigrateModal
|
||||
sourceId={migrateTarget.sourceId}
|
||||
sourceName={migrateTarget.sourceName}
|
||||
sourceIconUrl={migrateTarget.iconUrl}
|
||||
manga={migrateTarget.manga}
|
||||
onClose={() => migrateTarget = null}
|
||||
onDone={() => { migrateTarget = null; load(); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
|
||||
.header-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.header-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
|
||||
.title-block { display: flex; flex-direction: column; gap: 1px; }
|
||||
.eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||
|
||||
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.filter-wrap { position: relative; }
|
||||
.filter-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.filter-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.filter-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
||||
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
||||
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||
.panel-clear-btn:hover { color: var(--color-error); }
|
||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
||||
.panel-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
||||
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||
.panel-item-active:hover { background: var(--accent-dim); }
|
||||
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
|
||||
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); will-change: scroll-position; display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
|
||||
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.source-group-header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border-dim); }
|
||||
.single-source-bar { display: flex; align-items: center; gap: var(--sp-2); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border-dim); }
|
||||
.source-group-name { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); font-weight: var(--weight-medium); }
|
||||
.source-group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); }
|
||||
.migrate-btn { display: flex; align-items: center; gap: 5px; margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 9px; border-radius: var(--radius-sm); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.migrate-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card.anims:hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||
.card:hover .card-title { color: var(--text-primary); }
|
||||
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); will-change: transform; }
|
||||
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||
|
||||
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
||||
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
||||
.card-info-overlay.instant { transition: none; }
|
||||
.card-info-overlay.always { opacity: 1; }
|
||||
.card:hover .card-info-overlay { opacity: 1; }
|
||||
|
||||
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
||||
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
||||
.card.anims .card-title { transition: color var(--t-base); }
|
||||
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,416 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import { GET_EXTENSIONS, GET_SOURCES, GET_SETTINGS, GET_LOCAL_MANGA, GET_LIBRARY } from "@api/queries";
|
||||
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
|
||||
import type { Extension } from "@types/index";
|
||||
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||
import { libraryCountByPkg, type LibraryManga, type SourceNode } from "../lib/extensionLibrary";
|
||||
import ExtensionFilters from "./ExtensionFilters.svelte";
|
||||
import ExtensionCard from "./ExtensionCard.svelte";
|
||||
import ExtensionSettingsPanel from "../panels/ExtensionSettingsPanel.svelte";
|
||||
import ExtensionLibrary from "./ExtensionLibrary.svelte";
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
const cols = $derived(store.settings.libraryCols ?? 5);
|
||||
const cropCovers = $derived(store.settings.cropCovers ?? true);
|
||||
const statsAlways = $derived(store.settings.statsAlways ?? false);
|
||||
|
||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||
let tabIndicator = $state({ left: 0, width: 0 });
|
||||
|
||||
function updateIndicator() {
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||
if (!active) return;
|
||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||
}
|
||||
|
||||
let extensions: Extension[] = $state([]);
|
||||
let localMangaCount = $state(0);
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let filter = $state<Filter>("installed");
|
||||
let search = $state("");
|
||||
let langFilter = $state<string | null>(null);
|
||||
let working = $state(new Set<string>());
|
||||
let updatingAll = $state(false);
|
||||
let expanded = $state(new Set<string>());
|
||||
let panel = $state<Panel>(null);
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] };
|
||||
type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string };
|
||||
|
||||
let settingsTarget = $state<SettingsTarget | null>(null);
|
||||
let libraryTarget = $state<LibraryTarget | null>(null);
|
||||
let sourcesByPkg = $state<Record<string, SourceEntry[]>>({});
|
||||
let libCountByPkg = $state<Record<string, number>>({});
|
||||
|
||||
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
|
||||
let externalUrl = $state("");
|
||||
let installing = $state(false);
|
||||
let installError = $state<string | null>(null);
|
||||
let installSuccess = $state(false);
|
||||
|
||||
let repos = $state<string[]>([]);
|
||||
let reposLoading = $state(false);
|
||||
let newRepoUrl = $state("");
|
||||
let repoError = $state<string | null>(null);
|
||||
let savingRepos = $state(false);
|
||||
|
||||
async function load() {
|
||||
const [extData, srcData, libData] = await Promise.all([
|
||||
gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error),
|
||||
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES).catch(console.error),
|
||||
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY).catch(console.error),
|
||||
]);
|
||||
if (extData) extensions = extData.extensions.nodes;
|
||||
if (srcData) {
|
||||
const map: Record<string, SourceEntry[]> = {};
|
||||
for (const s of srcData.sources.nodes) {
|
||||
if (!s.isConfigurable || !s.extension?.pkgName) continue;
|
||||
const pkg = s.extension.pkgName;
|
||||
if (!map[pkg]) map[pkg] = [];
|
||||
map[pkg].push({ id: s.id, displayName: s.displayName });
|
||||
}
|
||||
sourcesByPkg = map;
|
||||
}
|
||||
if (libData && srcData) {
|
||||
libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalManga() {
|
||||
const d = await gql<{ mangas: { nodes: { id: number }[] } }>(GET_LOCAL_MANGA).catch(console.error);
|
||||
if (d) localMangaCount = d.mangas.nodes.length;
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
refreshing = true;
|
||||
const d = await gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
||||
.catch(console.error)
|
||||
.finally(() => refreshing = false);
|
||||
if (d) {
|
||||
extensions = d.fetchExtensions.extensions;
|
||||
addToast({ kind: "success", title: "Extensions refreshed", body: "Extension list is up to date" });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
reposLoading = true;
|
||||
try {
|
||||
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
|
||||
repos = d.settings.extensionRepos ?? [];
|
||||
} catch (e) { console.error(e); }
|
||||
finally { reposLoading = false; }
|
||||
}
|
||||
|
||||
async function saveRepos(updated: string[], intent: "add" | "remove") {
|
||||
savingRepos = true;
|
||||
try {
|
||||
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated });
|
||||
repos = d.setSettings.settings.extensionRepos;
|
||||
addToast(intent === "add"
|
||||
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
|
||||
: { kind: "info", title: "Repo removed", body: repos.find(r => !updated.includes(r)) ?? "" }
|
||||
);
|
||||
} catch (e: any) {
|
||||
repoError = e instanceof Error ? e.message : "Failed to save";
|
||||
} finally { savingRepos = false; }
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.trim();
|
||||
const err = validateUrl(url);
|
||||
if (err) { repoError = err; return; }
|
||||
if (repos.includes(url)) { repoError = "Repo already added"; return; }
|
||||
repoError = null; newRepoUrl = "";
|
||||
saveRepos([...repos, url], "add");
|
||||
}
|
||||
|
||||
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url), "remove"); }
|
||||
|
||||
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
|
||||
working = new Set(working).add(pkgName);
|
||||
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
|
||||
const gqlArgs = {
|
||||
install: { id: pkgName, install: true },
|
||||
update: { id: pkgName, update: true },
|
||||
uninstall: { id: pkgName, uninstall: true },
|
||||
}[op];
|
||||
try {
|
||||
await gql(UPDATE_EXTENSION, gqlArgs);
|
||||
await load();
|
||||
addToast({
|
||||
install: { kind: "download" as const, title: "Extension installed", body: label },
|
||||
update: { kind: "success" as const, title: "Extension updated", body: label },
|
||||
uninstall: { kind: "info" as const, title: "Extension removed", body: label },
|
||||
}[op]);
|
||||
} catch (e: any) {
|
||||
await load();
|
||||
addToast({ kind: "error", title: "Extension error", body: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
working.delete(pkgName); working = new Set(working);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
const pending = extensions.filter((e) => e.hasUpdate);
|
||||
if (!pending.length || updatingAll) return;
|
||||
updatingAll = true;
|
||||
for (const ext of pending) await mutate(ext.pkgName, "update");
|
||||
updatingAll = false;
|
||||
addToast({ kind: "success", title: "All extensions updated", body: `${pending.length} extension${pending.length === 1 ? "" : "s"} updated` });
|
||||
}
|
||||
|
||||
async function installExternal() {
|
||||
const url = externalUrl.trim();
|
||||
const err = validateUrl(url, ".apk");
|
||||
if (err) { installError = err; return; }
|
||||
installing = true; installError = null; installSuccess = false;
|
||||
try {
|
||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
||||
installSuccess = true; externalUrl = "";
|
||||
await load();
|
||||
addToast({ kind: "download", title: "Extension installed", body: url.split("/").pop() ?? url });
|
||||
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
|
||||
} catch (e: any) {
|
||||
installError = e instanceof Error ? e.message : "Install failed";
|
||||
addToast({ kind: "error", title: "Install failed", body: installError });
|
||||
} finally { installing = false; }
|
||||
}
|
||||
|
||||
function openPanel(p: Panel) {
|
||||
panel = panel === p ? null : p;
|
||||
installError = null; installSuccess = false; externalUrl = "";
|
||||
repoError = null; newRepoUrl = "";
|
||||
if (p === "repos") loadRepos();
|
||||
}
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
const next = new Set(expanded);
|
||||
next.has(base) ? next.delete(base) : next.add(base);
|
||||
expanded = next;
|
||||
}
|
||||
|
||||
function setFilter(f: Filter) {
|
||||
if (f === filter) return;
|
||||
filter = f;
|
||||
langFilter = null;
|
||||
}
|
||||
|
||||
const showLocal = $derived(
|
||||
(filter === "installed" || filter === "all") &&
|
||||
(search === "" || "local source".includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
const allGroups = $derived(groupExtensions(extensions, store.settings.preferredExtensionLang));
|
||||
|
||||
const groups = $derived(allGroups.filter(({ primary, variants }) => {
|
||||
const all = [primary, ...variants];
|
||||
const q = search.toLowerCase();
|
||||
const matchesSearch = all.some((e) => e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q));
|
||||
const matchesTab = all.some((e) => matchesFilter(e, filter));
|
||||
const matchesLang = langFilter === null || all.some((e) => e.lang === langFilter);
|
||||
return matchesSearch && matchesTab && matchesLang;
|
||||
}));
|
||||
|
||||
const availableLangs = $derived(
|
||||
[...new Set(extensions.filter((e) => matchesFilter(e, filter)).map((e) => e.lang))].sort()
|
||||
);
|
||||
|
||||
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
|
||||
|
||||
$effect(() => {
|
||||
untrack(async () => {
|
||||
loadLocalManga();
|
||||
await load();
|
||||
loading = false;
|
||||
fetchFromRepo();
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!panel) return;
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest(".ext-panel, .icon-btn")) panel = null;
|
||||
}
|
||||
document.addEventListener("mousedown", onMouseDown, true);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown, true);
|
||||
});
|
||||
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
{#if libraryTarget}
|
||||
<ExtensionLibrary
|
||||
pkgName={libraryTarget.pkgName}
|
||||
extensionName={libraryTarget.extensionName}
|
||||
iconUrl={libraryTarget.iconUrl}
|
||||
{cols} {cropCovers} {statsAlways} {anims}
|
||||
sources={sourcesByPkg[libraryTarget.pkgName] ?? []}
|
||||
onBack={() => libraryTarget = null}
|
||||
onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="root anim-fade-in">
|
||||
<ExtensionFilters
|
||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||
{anims} {tabIndicator} {updatingAll}
|
||||
bind:tabsEl
|
||||
onFilter={setFilter}
|
||||
onSearch={(q) => search = q}
|
||||
onLang={(l) => langFilter = l}
|
||||
onPanel={openPanel}
|
||||
onRefresh={fetchFromRepo}
|
||||
onUpdateAll={updateAll}
|
||||
/>
|
||||
|
||||
{#if panel === "apk"}
|
||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title-wrap"><span class="panel-title">Install from APK URL</span></span>
|
||||
</div>
|
||||
<div class="ext-row">
|
||||
<input
|
||||
class="ext-input" class:error={installError}
|
||||
placeholder="https://example.com/extension.apk"
|
||||
bind:value={externalUrl} disabled={installing}
|
||||
oninput={() => installError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||
use:focusOnMount
|
||||
/>
|
||||
<button class="install-btn" class:success={installSuccess}
|
||||
onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||
{:else}Install{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if panel === "repos"}
|
||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title-wrap"><span class="panel-title">Extension Repositories</span></span>
|
||||
</div>
|
||||
{#if reposLoading}
|
||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
{#if repos.length === 0}
|
||||
<div class="repo-empty">No repos configured.</div>
|
||||
{:else}
|
||||
<div class="repo-list">
|
||||
{#each repos as url}
|
||||
<div class="repo-row">
|
||||
<span class="repo-url">{url}</span>
|
||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ext-row">
|
||||
<input
|
||||
class="ext-input" class:error={repoError}
|
||||
placeholder="https://example.com/index.min.json"
|
||||
bind:value={newRepoUrl} disabled={savingRepos}
|
||||
oninput={() => repoError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||
/>
|
||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#if showLocal}
|
||||
<div class="local-row">
|
||||
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
||||
<div class="info">
|
||||
<span class="name">Local Source</span>
|
||||
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
|
||||
</div>
|
||||
<span class="local-badge">Built-in</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each groups as { base, primary, variants }}
|
||||
<ExtensionCard
|
||||
{base} {primary} {variants} {working} {anims}
|
||||
sources={sourcesByPkg[primary.pkgName] ?? []}
|
||||
libraryCount={libCountByPkg[primary.pkgName] ?? 0}
|
||||
expanded={expanded.has(base)}
|
||||
onToggle={toggleExpand}
|
||||
onMutate={mutate}
|
||||
onLibrary={(pkgName, extensionName, iconUrl) => libraryTarget = { pkgName, extensionName, iconUrl }}
|
||||
/>
|
||||
{/each}
|
||||
{#if !showLocal && groups.length === 0}
|
||||
<div class="empty" style="flex:1">No extensions found.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if settingsTarget}
|
||||
<ExtensionSettingsPanel
|
||||
extensionName={settingsTarget.extensionName}
|
||||
iconUrl={settingsTarget.iconUrl}
|
||||
sources={settingsTarget.sources}
|
||||
onClose={() => settingsTarget = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
:global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
:global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
:global(.icon-btn-active) { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
|
||||
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
|
||||
.panel-title-wrap { display: inline-flex; align-items: center; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 2px 8px; }
|
||||
.panel-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
|
||||
.ext-row { display: flex; gap: var(--sp-2); }
|
||||
.ext-input { flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
||||
.ext-input:focus { border-color: var(--border-focus); }
|
||||
.ext-input:disabled { opacity: 0.5; }
|
||||
.ext-input.error { border-color: var(--color-error) !important; }
|
||||
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base), opacity var(--t-base); white-space: nowrap; }
|
||||
.install-btn:hover:not(:disabled) { filter: brightness(1.15); }
|
||||
.install-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
|
||||
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-2); }
|
||||
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
|
||||
.repo-list { display: flex; flex-direction: column; gap: 2px; margin-bottom: var(--sp-2); }
|
||||
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-md); background: var(--bg-base); border: 1px solid var(--border-dim); }
|
||||
.repo-url { flex: 1; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; letter-spacing: var(--tracking-wide); }
|
||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||
@keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.local-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); margin-bottom: 1px; }
|
||||
.local-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.local-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as Extensions } from "./components/Extensions.svelte";
|
||||
export * from "./lib/extensionHelpers";
|
||||
@@ -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<string, Extension[]>();
|
||||
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" },
|
||||
];
|
||||
@@ -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<string, LibraryManga[]>();
|
||||
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<string, number> {
|
||||
const sourceIdToPkg = new Map<string, string>();
|
||||
for (const s of sources) {
|
||||
if (s.extension?.pkgName) sourceIdToPkg.set(s.id, s.extension.pkgName);
|
||||
}
|
||||
const counts: Record<string, number> = {};
|
||||
for (const m of libraryManga) {
|
||||
const pkg = sourceIdToPkg.get(m.source.id);
|
||||
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { X, CircleNotch, CaretUpDown, Check, CaretLeft } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { addToast } from "@store/state.svelte";
|
||||
import { GET_SOURCE_SETTINGS } from "@api/queries";
|
||||
import { UPDATE_SOURCE_PREFERENCE } from "@api/mutations";
|
||||
|
||||
interface Preference {
|
||||
type: string;
|
||||
key: string;
|
||||
CheckBoxTitle?: string;
|
||||
CheckBoxSummary?: string;
|
||||
CheckBoxDefault?: boolean;
|
||||
CheckBoxCurrentValue?: boolean;
|
||||
SwitchPreferenceTitle?: string;
|
||||
SwitchPreferenceSummary?: string;
|
||||
SwitchPreferenceDefault?: boolean;
|
||||
SwitchPreferenceCurrentValue?: boolean;
|
||||
ListPreferenceTitle?: string;
|
||||
ListPreferenceSummary?: string;
|
||||
ListPreferenceDefault?: string;
|
||||
ListPreferenceCurrentValue?: string;
|
||||
entries?: string[];
|
||||
entryValues?: string[];
|
||||
EditTextPreferenceTitle?: string;
|
||||
EditTextPreferenceSummary?: string;
|
||||
EditTextPreferenceDefault?: string;
|
||||
EditTextPreferenceCurrentValue?: string;
|
||||
dialogTitle?: string;
|
||||
dialogMessage?: string;
|
||||
MultiSelectListPreferenceTitle?: string;
|
||||
MultiSelectListPreferenceSummary?: string;
|
||||
MultiSelectListPreferenceDefault?: string[];
|
||||
MultiSelectListPreferenceCurrentValue?: string[];
|
||||
}
|
||||
|
||||
export type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
extensionName: string;
|
||||
iconUrl: string;
|
||||
sources: SourceEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { extensionName, iconUrl, sources, onClose }: Props = $props();
|
||||
|
||||
let phase = $state<"pick" | "settings">("pick");
|
||||
let activeSource = $state<SourceEntry | null>(null);
|
||||
let prefs = $state<Preference[]>([]);
|
||||
let loading = $state(false);
|
||||
let saving = $state<string | null>(null);
|
||||
let editKey = $state<string | null>(null);
|
||||
let editValue = $state("");
|
||||
let listOpen = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (sources.length === 1) openSource(sources[0]);
|
||||
});
|
||||
|
||||
async function openSource(src: SourceEntry) {
|
||||
activeSource = src;
|
||||
phase = "settings";
|
||||
loading = true;
|
||||
prefs = [];
|
||||
editKey = null;
|
||||
listOpen = null;
|
||||
try {
|
||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||
GET_SOURCE_SETTINGS,
|
||||
{ id: String(src.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToPicker() {
|
||||
phase = "pick";
|
||||
activeSource = null;
|
||||
prefs = [];
|
||||
editKey = null;
|
||||
listOpen = null;
|
||||
}
|
||||
|
||||
async function save(position: number, changeType: string, value: unknown) {
|
||||
if (!activeSource) return;
|
||||
const pref = prefs[position];
|
||||
saving = pref.key;
|
||||
try {
|
||||
await gql(UPDATE_SOURCE_PREFERENCE, {
|
||||
source: String(activeSource.id),
|
||||
change: { position, [changeType]: value },
|
||||
});
|
||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||
GET_SOURCE_SETTINGS,
|
||||
{ id: String(activeSource.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
||||
} finally {
|
||||
saving = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle(p: Preference) {
|
||||
return p.CheckBoxTitle ?? p.SwitchPreferenceTitle ?? p.ListPreferenceTitle
|
||||
?? p.EditTextPreferenceTitle ?? p.MultiSelectListPreferenceTitle ?? p.key;
|
||||
}
|
||||
function getSummary(p: Preference) {
|
||||
return p.CheckBoxSummary ?? p.SwitchPreferenceSummary ?? p.ListPreferenceSummary
|
||||
?? p.EditTextPreferenceSummary ?? p.MultiSelectListPreferenceSummary ?? null;
|
||||
}
|
||||
function getBoolValue(p: Preference) {
|
||||
if (p.type === "CheckBoxPreference")
|
||||
return p.CheckBoxCurrentValue ?? p.CheckBoxDefault ?? false;
|
||||
return p.SwitchPreferenceCurrentValue ?? p.SwitchPreferenceDefault ?? false;
|
||||
}
|
||||
function getListValue(p: Preference) {
|
||||
return p.ListPreferenceCurrentValue ?? p.ListPreferenceDefault ?? "";
|
||||
}
|
||||
function getListLabel(p: Preference, val: string) {
|
||||
const idx = p.entryValues?.indexOf(val) ?? -1;
|
||||
return idx >= 0 ? (p.entries?.[idx] ?? val) : val;
|
||||
}
|
||||
function getMultiValue(p: Preference): string[] {
|
||||
return p.MultiSelectListPreferenceCurrentValue ?? p.MultiSelectListPreferenceDefault ?? [];
|
||||
}
|
||||
function toggleMulti(position: number, p: Preference, val: string) {
|
||||
const current = getMultiValue(p);
|
||||
const next = current.includes(val) ? current.filter(v => v !== val) : [...current, val];
|
||||
save(position, "multiSelectState", next);
|
||||
}
|
||||
function submitEdit(position: number) {
|
||||
save(position, "editTextState", editValue);
|
||||
editKey = null;
|
||||
}
|
||||
function openEdit(p: Preference) {
|
||||
editKey = p.key;
|
||||
editValue = p.EditTextPreferenceCurrentValue ?? p.EditTextPreferenceDefault ?? "";
|
||||
}
|
||||
|
||||
function langTag(displayName: string) {
|
||||
const m = displayName.match(/\(([^)]+)\)$/);
|
||||
return m ? m[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (editKey) { editKey = null; return; }
|
||||
if (listOpen) { listOpen = null; return; }
|
||||
if (phase === "settings" && sources.length > 1) { backToPicker(); return; }
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="backdrop" role="dialog" aria-modal="true" onmousedown={onBackdrop}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-wrap">
|
||||
{#if phase === "settings" && sources.length > 1}
|
||||
<button class="icon-btn" onclick={backToPicker} title="Back">
|
||||
<CaretLeft size={13} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if iconUrl}
|
||||
<Thumbnail src={iconUrl} alt={extensionName} class="modal-ext-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
{/if}
|
||||
<div class="modal-titles">
|
||||
<span class="modal-eyebrow">Extension Settings</span>
|
||||
<span class="modal-title">
|
||||
{phase === "pick" ? extensionName : (activeSource?.displayName ?? extensionName)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="icon-btn" onclick={onClose}>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{#if phase === "pick"}
|
||||
<div class="source-list">
|
||||
{#each sources as src}
|
||||
{@const tag = langTag(src.displayName)}
|
||||
{@const baseName = src.displayName.replace(/\s*\([^)]+\)$/, "")}
|
||||
<button class="source-row" onclick={() => openSource(src)}>
|
||||
<span class="source-name">{baseName}</span>
|
||||
{#if tag}<span class="lang-badge">{tag}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#if loading}
|
||||
<div class="center-state">
|
||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
{:else if prefs.length === 0}
|
||||
<div class="center-state empty-state">No configurable settings.</div>
|
||||
{:else}
|
||||
<div class="pref-list">
|
||||
{#each prefs as pref, i}
|
||||
{@const title = getTitle(pref)}
|
||||
{@const summary = getSummary(pref)}
|
||||
{@const isSaving = saving === pref.key}
|
||||
|
||||
{#if pref.type === "CheckBoxPreference" || pref.type === "SwitchPreference"}
|
||||
{@const checked = getBoolValue(pref)}
|
||||
<div class="pref-row">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<button
|
||||
class="toggle" class:toggle-on={checked}
|
||||
disabled={isSaving}
|
||||
onclick={() => save(i, pref.type === "CheckBoxPreference" ? "checkBoxState" : "switchState", !checked)}
|
||||
>
|
||||
{#if isSaving}
|
||||
<CircleNotch size={10} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<span class="toggle-thumb"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if pref.type === "ListPreference"}
|
||||
{@const current = getListValue(pref)}
|
||||
<div class="pref-row pref-row-col">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<button
|
||||
class="select-btn" class:select-open={listOpen === pref.key}
|
||||
disabled={isSaving}
|
||||
onclick={() => listOpen = listOpen === pref.key ? null : pref.key}
|
||||
>
|
||||
<span class="select-val">{getListLabel(pref, current)}</span>
|
||||
{#if isSaving}
|
||||
<CircleNotch size={11} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<CaretUpDown size={11} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if listOpen === pref.key}
|
||||
<div class="dropdown">
|
||||
{#each (pref.entries ?? []) as entry, j}
|
||||
{@const val = pref.entryValues?.[j] ?? entry}
|
||||
<button
|
||||
class="dropdown-item" class:dropdown-item-active={val === current}
|
||||
onclick={() => { save(i, "listState", val); listOpen = null; }}
|
||||
>
|
||||
{entry}
|
||||
{#if val === current}<Check size={11} weight="bold" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if pref.type === "EditTextPreference"}
|
||||
{#if editKey === pref.key}
|
||||
<div class="pref-row pref-row-col edit-active">
|
||||
<div class="pref-text">
|
||||
{#if pref.dialogTitle}<span class="pref-title">{pref.dialogTitle}</span>{/if}
|
||||
{#if pref.dialogMessage}<span class="pref-summary">{pref.dialogMessage}</span>{/if}
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<input
|
||||
class="edit-input"
|
||||
bind:value={editValue}
|
||||
disabled={isSaving}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitEdit(i); if (e.key === "Escape") editKey = null; }}
|
||||
autofocus
|
||||
/>
|
||||
<button class="action-btn-dim" onclick={() => editKey = null}>Cancel</button>
|
||||
<button class="action-btn" onclick={() => submitEdit(i)} disabled={isSaving}>
|
||||
{#if isSaving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}Save{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="pref-row pref-row-btn" onclick={() => openEdit(pref)}>
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<span class="pref-value-hint">
|
||||
{pref.EditTextPreferenceCurrentValue ?? pref.EditTextPreferenceDefault ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{:else if pref.type === "MultiSelectListPreference"}
|
||||
{@const selected = getMultiValue(pref)}
|
||||
<div class="pref-row pref-row-col">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<div class="multi-list">
|
||||
{#each (pref.entries ?? []) as entry, j}
|
||||
{@const val = pref.entryValues?.[j] ?? entry}
|
||||
{@const on = selected.includes(val)}
|
||||
<button
|
||||
class="multi-item" class:multi-item-on={on}
|
||||
disabled={isSaving}
|
||||
onclick={() => toggleMulti(i, pref, val)}
|
||||
>
|
||||
<span class="multi-check">{#if on}<Check size={10} weight="bold" />{/if}</span>
|
||||
{entry}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: fadeIn 0.15s ease both;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
display: flex; flex-direction: column;
|
||||
width: 400px; max-width: calc(100vw - 32px); max-height: 78vh;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
:global(.modal-ext-icon) { width: 22px; height: 22px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.modal-titles { display: flex; flex-direction: column; gap: 1px; }
|
||||
.modal-eyebrow {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.modal-body { overflow-y: auto; flex: 1; }
|
||||
|
||||
.source-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||
.source-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px var(--sp-4);
|
||||
text-align: left;
|
||||
transition: background var(--t-fast);
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.source-row:hover { background: var(--bg-raised); }
|
||||
.source-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.lang-badge {
|
||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.center-state { display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||
.empty-state { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.pref-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||
.pref-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 10px var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.pref-row:last-child { border-bottom: none; }
|
||||
.pref-row-col { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
|
||||
.pref-row-btn { width: 100%; text-align: left; transition: background var(--t-fast); }
|
||||
.pref-row-btn:hover { background: var(--bg-raised); }
|
||||
.edit-active { background: var(--bg-raised); }
|
||||
|
||||
.pref-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.pref-title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.pref-summary {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.5;
|
||||
}
|
||||
.pref-value-hint {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative; width: 32px; height: 18px; border-radius: 9px;
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||
flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.toggle-on { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.toggle-thumb {
|
||||
position: absolute; left: 2px; width: 12px; height: 12px;
|
||||
border-radius: 50%; background: var(--text-faint);
|
||||
transition: left var(--t-base), background var(--t-base); pointer-events: none;
|
||||
}
|
||||
.toggle-on .toggle-thumb { left: 16px; background: var(--accent-fg); }
|
||||
.toggle:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.select-wrap { position: relative; }
|
||||
.select-btn {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
width: 100%; padding: 6px var(--sp-3);
|
||||
background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); color: var(--text-secondary); font-size: var(--text-sm);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.select-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
||||
.select-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.select-open { border-color: var(--border-focus); }
|
||||
.select-val { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); overflow: hidden;
|
||||
box-shadow: var(--shadow-lg); z-index: 10;
|
||||
animation: dropIn 0.12s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
@keyframes dropIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.dropdown-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 7px var(--sp-3);
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.dropdown-item:hover { background: var(--bg-raised); }
|
||||
.dropdown-item-active { color: var(--accent-fg); }
|
||||
|
||||
.edit-row { display: flex; gap: var(--sp-2); }
|
||||
.edit-input {
|
||||
flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm);
|
||||
outline: none; transition: border-color var(--t-base);
|
||||
}
|
||||
.edit-input:focus { border-color: var(--border-focus); }
|
||||
|
||||
.action-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim);
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1);
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.action-btn-dim {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: none; color: var(--text-faint); border: 1px solid var(--border-dim);
|
||||
flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.action-btn-dim:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.multi-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.multi-item {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px var(--sp-2); border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); color: var(--text-muted); border: 1px solid transparent;
|
||||
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.multi-item:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.multi-item-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.multi-check {
|
||||
width: 14px; height: 14px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; color: var(--accent-fg);
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.multi-item-on .multi-check { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
</style>
|
||||
@@ -1,448 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle, Swap } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { GET_SOURCES } from "@api/queries/extensions";
|
||||
import { UPDATE_MANGA } from "@api/mutations/manga";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import type { Manga, Chapter, Source } from "@types";
|
||||
import type { LibraryManga } from "../lib/extensionLibrary";
|
||||
|
||||
interface Props {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
sourceIconUrl: string;
|
||||
manga: LibraryManga[];
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
let { sourceId, sourceName, sourceIconUrl, manga, onClose, onDone }: Props = $props();
|
||||
|
||||
type Phase = "pick-target" | "review" | "migrating" | "done";
|
||||
|
||||
interface EntryResult {
|
||||
manga: LibraryManga;
|
||||
match: Manga | null;
|
||||
chapters: Chapter[];
|
||||
similarity: number;
|
||||
status: "pending" | "searching" | "found" | "no-match" | "migrated" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wordsA = new Set(norm(a));
|
||||
const wordsB = new Set(norm(b));
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
const intersection = [...wordsA].filter(w => wordsB.has(w)).length;
|
||||
return intersection / new Set([...wordsA, ...wordsB]).size;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && phase !== "migrating") onClose(); }
|
||||
|
||||
let phase: Phase = $state("pick-target");
|
||||
let allSources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let targetSource: Source | null = $state(null);
|
||||
let selectedLang = $state("all");
|
||||
let langStripEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
let entries: EntryResult[] = $state([]);
|
||||
let searchProgress = $state({ done: 0, total: 0 });
|
||||
let migrateProgress = $state({ done: 0, total: 0, failed: 0 });
|
||||
|
||||
const availableLangs = $derived.by(() => {
|
||||
const langs = Array.from(new Set<string>(allSources.map(s => s.lang))).sort();
|
||||
const en = langs.indexOf("en");
|
||||
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
|
||||
return langs;
|
||||
});
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
const visibleSources = $derived.by(() => {
|
||||
if (selectedLang !== "all") return allSources.filter(s => s.lang === selectedLang);
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of allSources) {
|
||||
const existing = map.get(s.name);
|
||||
if (!existing || s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
|
||||
const foundCount = $derived(entries.filter(e => e.status === "found").length);
|
||||
const noMatchCount = $derived(entries.filter(e => e.status === "no-match").length);
|
||||
const migratedCount = $derived(entries.filter(e => e.status === "migrated").length);
|
||||
const failedCount = $derived(entries.filter(e => e.status === "failed").length);
|
||||
|
||||
$effect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then(d => {
|
||||
allSources = d.sources.nodes.filter(s => s.id !== "0" && s.id !== sourceId);
|
||||
const prefLang = store?.settings?.preferredExtensionLang ?? "";
|
||||
const langs = new Set(allSources.map(s => s.lang));
|
||||
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function scrollLangStrip(dir: -1 | 1) {
|
||||
if (!langStripEl) return;
|
||||
const chips = Array.from(langStripEl.children) as HTMLElement[];
|
||||
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
|
||||
if (dir === 1) {
|
||||
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||
} else {
|
||||
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
|
||||
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
async function startSearch(target: Source) {
|
||||
targetSource = target;
|
||||
phase = "review";
|
||||
entries = manga.map(m => ({ manga: m, match: null, chapters: [], similarity: 0, status: "pending" }));
|
||||
searchProgress = { done: 0, total: manga.length };
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
entries[i] = { ...entries[i], status: "searching" };
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: target.id, type: "SEARCH", page: 1, query: entries[i].manga.title,
|
||||
});
|
||||
const results = d.fetchSourceManga.mangas
|
||||
.map(m => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
|
||||
.sort((a, b) => b.similarity - a.similarity);
|
||||
|
||||
if (results.length > 0 && results[0].similarity > 0.3) {
|
||||
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
|
||||
} else {
|
||||
entries[i] = { ...entries[i], status: "no-match" };
|
||||
}
|
||||
} catch (e: any) {
|
||||
entries[i] = { ...entries[i], status: "no-match", error: e.message };
|
||||
}
|
||||
searchProgress = { done: i + 1, total: manga.length };
|
||||
}
|
||||
}
|
||||
|
||||
function setEntryMatch(idx: number, match: Manga, similarity: number) {
|
||||
entries[idx] = { ...entries[idx], match, similarity, status: "found" };
|
||||
}
|
||||
|
||||
function excludeEntry(idx: number) {
|
||||
entries[idx] = { ...entries[idx], status: "no-match", match: null };
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
const toMigrate = entries.filter(e => e.status === "found" && e.match);
|
||||
migrateProgress = { done: 0, total: toMigrate.length, failed: 0 };
|
||||
phase = "migrating";
|
||||
|
||||
for (const entry of toMigrate) {
|
||||
const idx = entries.indexOf(entry);
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: entry.match!.id });
|
||||
const newChaps = d.fetchChapters.chapters;
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
|
||||
for (const nc of newChaps) {
|
||||
const oldIdx = entries[idx].manga;
|
||||
if (oldIdx) {
|
||||
toMarkRead.push(nc.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
|
||||
await gql(UPDATE_MANGA, { id: entry.match!.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: entry.manga.id, inLibrary: false });
|
||||
|
||||
entries[idx] = { ...entries[idx], status: "migrated" };
|
||||
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1 };
|
||||
} catch (e: any) {
|
||||
entries[idx] = { ...entries[idx], status: "failed", error: e.message };
|
||||
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1, failed: migrateProgress.failed + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
phase = "done";
|
||||
addToast({
|
||||
kind: "success",
|
||||
title: "Migration complete",
|
||||
body: `${migrateProgress.done - migrateProgress.failed} migrated, ${migrateProgress.failed} failed`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="source-context">
|
||||
<div class="source-icon-wrap">
|
||||
<Thumbnail src={sourceIconUrl} alt={sourceName} class="src-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<div class="source-context-info">
|
||||
<span class="modal-eyebrow">Source migration</span>
|
||||
<span class="modal-title">{sourceName}</span>
|
||||
<span class="modal-sub">{manga.length} {manga.length === 1 ? "title" : "titles"} in library</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if phase !== "migrating"}
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
{#if phase === "pick-target"}
|
||||
<div class="phase-label-row">
|
||||
<span class="phase-label">Select destination source</span>
|
||||
</div>
|
||||
{#if loadingSources}
|
||||
<div class="centered"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else if allSources.length === 0}
|
||||
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
||||
{:else}
|
||||
{#if hasMultipleLangs}
|
||||
<div class="src-lang-bar">
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}>‹</button>
|
||||
<div class="src-lang-chips" bind:this={langStripEl}>
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}>›</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="source-list">
|
||||
{#each visibleSources as src}
|
||||
<button class="source-row" onclick={() => startSearch(src)}>
|
||||
<div class="source-icon-wrap">
|
||||
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<div class="source-info">
|
||||
<span class="source-name">{src.displayName}</span>
|
||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
</div>
|
||||
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if phase === "review" || phase === "migrating" || phase === "done"}
|
||||
<div class="review-header">
|
||||
<div class="review-route">
|
||||
<div class="review-source">
|
||||
<div class="source-icon-wrap small">
|
||||
<Thumbnail src={sourceIconUrl} alt={sourceName} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<span class="review-source-name">{sourceName}</span>
|
||||
</div>
|
||||
<ArrowRight size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
{#if targetSource}
|
||||
<div class="review-source">
|
||||
<div class="source-icon-wrap small">
|
||||
<Thumbnail src={targetSource.iconUrl} alt={targetSource.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<span class="review-source-name">{targetSource.displayName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if phase === "review"}
|
||||
<div class="review-progress-row">
|
||||
<div class="review-progress-bar">
|
||||
<div class="review-progress-fill" style="width:{searchProgress.total ? (searchProgress.done / searchProgress.total) * 100 : 0}%"></div>
|
||||
</div>
|
||||
<span class="review-progress-label">
|
||||
{#if searchProgress.done < searchProgress.total}
|
||||
Searching {searchProgress.done + 1} / {searchProgress.total}…
|
||||
{:else}
|
||||
{foundCount} found · {noMatchCount} no match
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else if phase === "migrating"}
|
||||
<div class="review-progress-row">
|
||||
<div class="review-progress-bar">
|
||||
<div class="review-progress-fill" style="width:{migrateProgress.total ? (migrateProgress.done / migrateProgress.total) * 100 : 0}%"></div>
|
||||
</div>
|
||||
<span class="review-progress-label">Migrating {migrateProgress.done} / {migrateProgress.total}…</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="done-summary">
|
||||
<Check size={13} weight="bold" style="color:var(--color-success)" />
|
||||
<span class="done-label">{migratedCount} migrated{failedCount > 0 ? ` · ${failedCount} failed` : ""}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="entry-list">
|
||||
{#each entries as entry, idx}
|
||||
<div class="entry-row" class:entry-migrated={entry.status === "migrated"} class:entry-failed={entry.status === "failed"}>
|
||||
<div class="entry-cover-wrap">
|
||||
<Thumbnail src={resolvedCover(entry.manga.id, entry.manga.thumbnailUrl)} alt={entry.manga.title} class="entry-cover" />
|
||||
</div>
|
||||
|
||||
<div class="entry-info">
|
||||
<span class="entry-title">{entry.manga.title}</span>
|
||||
{#if entry.status === "found" && entry.match}
|
||||
<span class="entry-match">
|
||||
<Sparkle size={9} weight="fill" style="color:var(--accent-fg);flex-shrink:0" />
|
||||
{entry.match.title}
|
||||
<span class="entry-sim">{Math.round(entry.similarity * 100)}%</span>
|
||||
</span>
|
||||
{:else if entry.status === "no-match"}
|
||||
<span class="entry-no-match">No match found</span>
|
||||
{:else if entry.status === "searching"}
|
||||
<span class="entry-searching">Searching…</span>
|
||||
{:else if entry.status === "migrated"}
|
||||
<span class="entry-done">Migrated</span>
|
||||
{:else if entry.status === "failed"}
|
||||
<span class="entry-fail">{entry.error ?? "Failed"}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="entry-status">
|
||||
{#if entry.status === "searching"}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if entry.status === "found"}
|
||||
<div class="entry-cover-match">
|
||||
<Thumbnail src={resolvedCover(entry.match!.id, entry.match!.thumbnailUrl)} alt={entry.match!.title} class="entry-match-cover" />
|
||||
</div>
|
||||
{#if phase === "review"}
|
||||
<button class="entry-exclude-btn" onclick={() => excludeEntry(idx)} title="Exclude from migration">
|
||||
<X size={10} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if entry.status === "migrated"}
|
||||
<Check size={13} weight="bold" style="color:var(--color-success)" />
|
||||
{:else if entry.status === "failed"}
|
||||
<Warning size={13} weight="light" style="color:var(--color-error)" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if phase === "review" && searchProgress.done === searchProgress.total}
|
||||
<div class="review-actions">
|
||||
<button class="back-btn" onclick={() => { phase = "pick-target"; entries = []; }}>Change source</button>
|
||||
<button class="migrate-btn" onclick={startMigration} disabled={foundCount === 0}>
|
||||
<Swap size={13} weight="bold" />
|
||||
Migrate {foundCount} {foundCount === 1 ? "title" : "titles"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if phase === "done"}
|
||||
<div class="review-actions">
|
||||
<button class="migrate-btn" onclick={onDone}><Check size={13} weight="bold" /> Done</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
|
||||
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 560px; max-height: 84vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
|
||||
|
||||
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.source-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||
.source-icon-wrap { width: 36px; height: 36px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.source-icon-wrap.small { width: 20px; height: 20px; border-radius: var(--radius-sm); }
|
||||
:global(.src-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.source-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; margin-top: 2px; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
.phase-label-row { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; }
|
||||
.phase-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-widest); text-transform: uppercase; }
|
||||
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
|
||||
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); flex-shrink: 0; }
|
||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||
|
||||
.review-header { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.review-route { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.review-source { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
.review-source-name { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: var(--weight-medium); }
|
||||
.review-progress-row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.review-progress-bar { flex: 1; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.review-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.review-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; flex-shrink: 0; }
|
||||
.done-summary { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.done-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.entry-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
|
||||
.entry-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast); }
|
||||
.entry-row:hover { background: var(--bg-raised); }
|
||||
.entry-migrated { opacity: 0.5; }
|
||||
.entry-failed { border-color: rgba(180,60,60,0.15); background: rgba(180,60,60,0.04); }
|
||||
.entry-cover-wrap { width: 28px; height: 42px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
:global(.entry-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.entry-info { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; overflow: hidden; }
|
||||
.entry-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.entry-match { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.entry-sim { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 0 4px; font-size: 9px; flex-shrink: 0; }
|
||||
.entry-no-match { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.entry-searching { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.entry-done { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-success); letter-spacing: var(--tracking-wide); }
|
||||
.entry-fail { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.entry-status { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.entry-cover-match { width: 24px; height: 36px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
:global(.entry-match-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.entry-exclude-btn { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.entry-exclude-btn:hover { color: var(--color-error); background: var(--bg-raised); }
|
||||
|
||||
.review-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.back-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.migrate-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,201 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { HistoryEntry } from "@store/state.svelte";
|
||||
import type { Manga } from "@types";
|
||||
import { timeAgo } from "../lib/homeHelpers";
|
||||
|
||||
let {
|
||||
entries,
|
||||
libraryManga,
|
||||
onresume,
|
||||
onviewhistory,
|
||||
onopenlibrary,
|
||||
}: {
|
||||
entries: HistoryEntry[];
|
||||
libraryManga: Manga[];
|
||||
onresume: (entry: HistoryEntry) => void;
|
||||
onviewhistory: () => void;
|
||||
onopenlibrary: () => void;
|
||||
} = $props();
|
||||
|
||||
function thumbFor(entry: HistoryEntry): string {
|
||||
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
|
||||
{#if entries.length > 0}
|
||||
<button class="see-all" onclick={onviewhistory}>
|
||||
Full History <ArrowRight size={9} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
{#if entries.length > 0}
|
||||
{#each entries as entry (entry.chapterId)}
|
||||
<button class="row" onclick={() => onresume(entry)}>
|
||||
<Thumbnail src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
|
||||
<div class="row-info">
|
||||
<span class="row-title">{entry.mangaTitle}</span>
|
||||
<span class="row-sub">
|
||||
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<span class="row-time">{timeAgo(entry.readAt)}</span>
|
||||
<span class="row-play"><Play size={10} weight="fill" /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="row row-sk">
|
||||
<div class="sk-thumb"></div>
|
||||
<div class="row-info">
|
||||
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
|
||||
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
|
||||
</div>
|
||||
<div class="sk sk-time"></div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="placeholder-overlay">
|
||||
<button class="placeholder-cta" onclick={onopenlibrary}>
|
||||
<BookOpen size={12} weight="light" /> Start reading
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-2);
|
||||
}
|
||||
.section-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.see-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.see-all:hover { color: var(--accent-fg); }
|
||||
|
||||
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 7px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row:hover .row-play { opacity: 1; }
|
||||
|
||||
:global(.row-thumb) {
|
||||
width: 33px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.row-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row-sub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row-time {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
||||
|
||||
.row-sk { cursor: default; pointer-events: none; }
|
||||
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
|
||||
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
||||
.sk-title { height: 11px; margin-bottom: 5px; }
|
||||
.sk-sub { height: 9px; }
|
||||
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
||||
|
||||
.placeholder { position: relative; }
|
||||
.placeholder-overlay {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0; bottom: -1px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-bottom: var(--sp-4);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%);
|
||||
}
|
||||
.placeholder-cta {
|
||||
pointer-events: all;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.13);
|
||||
color: rgba(255,255,255,0.62);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
|
||||
</style>
|
||||
@@ -1,285 +0,0 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
dailyReadCounts,
|
||||
}: {
|
||||
dailyReadCounts: Record<string, number>;
|
||||
} = $props();
|
||||
|
||||
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
|
||||
if (count === 0) return 0;
|
||||
if (count === 1) return 1;
|
||||
if (count <= 3) return 2;
|
||||
if (count <= 6) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
let tip: { text: string; x: number; y: number } | null = $state(null);
|
||||
|
||||
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const label = cell.count === 0
|
||||
? `No chapters — ${fmtDate(cell.dateStr)}`
|
||||
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""} — ${fmtDate(cell.dateStr)}`;
|
||||
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
|
||||
}
|
||||
|
||||
function hideTip() { tip = null; }
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function localDateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
let wrapEl: HTMLElement;
|
||||
let cellSize = $state(12);
|
||||
let numWeeks = $state(26);
|
||||
|
||||
const GAP = 3;
|
||||
const DAY_GUTTER = 28;
|
||||
const LEGEND_H = 20;
|
||||
const MONTH_H = 14;
|
||||
const ROWS = 7;
|
||||
|
||||
$effect(() => {
|
||||
if (!wrapEl) return;
|
||||
const obs = new ResizeObserver(() => {
|
||||
const h = wrapEl.clientHeight;
|
||||
const w = wrapEl.clientWidth;
|
||||
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
|
||||
cellSize = cs;
|
||||
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
|
||||
});
|
||||
obs.observe(wrapEl);
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
const visibleWeeks = $derived((() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = localDateStr(today);
|
||||
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
||||
|
||||
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
|
||||
for (let wi = numWeeks - 1; wi >= 0; wi--) {
|
||||
const week: typeof weeks[0] = [];
|
||||
for (let di = 0; di < 7; di++) {
|
||||
const d = new Date(weekEnd);
|
||||
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
||||
const dateStr = localDateStr(d);
|
||||
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return weeks;
|
||||
})());
|
||||
|
||||
const monthLabels = $derived((() => {
|
||||
const labels: { label: string; colIndex: number }[] = [];
|
||||
let lastMonth = -1;
|
||||
visibleWeeks.forEach((week, ci) => {
|
||||
const first = week[0];
|
||||
if (!first) return;
|
||||
const m = new Date(first.dateStr + "T00:00:00").getMonth();
|
||||
if (m !== lastMonth) {
|
||||
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
|
||||
lastMonth = m;
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
})());
|
||||
|
||||
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
|
||||
</script>
|
||||
|
||||
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
|
||||
|
||||
<div class="month-row">
|
||||
<div class="day-gutter"></div>
|
||||
<div class="month-cells">
|
||||
{#each visibleWeeks as _week, ci}
|
||||
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
|
||||
<div class="month-label">{lbl?.label ?? ""}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-row">
|
||||
<div class="day-labels">
|
||||
{#each DAY_LABELS as d}
|
||||
<span class="day-label">{d}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="cell-grid">
|
||||
{#each visibleWeeks as week}
|
||||
<div class="week-col">
|
||||
{#each week as cell}
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<button
|
||||
class="cell intensity-{intensity(cell.count)}"
|
||||
class:cell-today={cell.isToday}
|
||||
class:cell-future={cell.isFuture}
|
||||
onmouseover={(e) => showTip(e, cell)}
|
||||
onmouseleave={hideTip}
|
||||
aria-label="{cell.count} chapters on {cell.dateStr}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-label">Less</span>
|
||||
{#each [0, 1, 2, 3, 4] as lvl}
|
||||
<div class="legend-cell intensity-{lvl}"></div>
|
||||
{/each}
|
||||
<span class="legend-label">More</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{#if tip}
|
||||
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.heatmap-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.month-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.day-gutter { width: 28px; flex-shrink: 0; }
|
||||
.month-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.month-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding-left: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.day-labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
}
|
||||
.day-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 8px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
height: var(--cell);
|
||||
line-height: var(--cell);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||
gap: 3px;
|
||||
overflow: visible;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
}
|
||||
.week-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
width: var(--cell);
|
||||
height: var(--cell);
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: filter var(--t-fast), transform var(--t-fast);
|
||||
}
|
||||
.cell:hover:not(.cell-future) {
|
||||
filter: brightness(1.5);
|
||||
transform: scale(1.2);
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
|
||||
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
|
||||
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
|
||||
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
|
||||
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
|
||||
|
||||
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
|
||||
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.legend-cell {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.legend-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.heatmap-tip {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -100%);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -1,194 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, X as XIcon } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga } from "@types";
|
||||
|
||||
let {
|
||||
slotIndex,
|
||||
libraryManga,
|
||||
loading,
|
||||
onpin,
|
||||
onclose,
|
||||
}: {
|
||||
slotIndex: 1 | 2 | 3;
|
||||
libraryManga: Manga[];
|
||||
loading: boolean;
|
||||
onpin: (m: Manga) => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let search = $state("");
|
||||
|
||||
function focusEl(node: HTMLElement) { node.focus(); }
|
||||
|
||||
const results = $derived(
|
||||
search.trim()
|
||||
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
|
||||
: libraryManga.slice(0, 20)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
onkeydown={(e) => { if (e.key === "Escape") onclose(); }}
|
||||
>
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
|
||||
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
|
||||
</div>
|
||||
<div class="list">
|
||||
{#if loading}
|
||||
<p class="empty-msg">Loading…</p>
|
||||
{:else if results.length === 0}
|
||||
<p class="empty-msg">No results</p>
|
||||
{:else}
|
||||
{#each results as m (m.id)}
|
||||
<button class="list-row" onclick={() => onpin(m)}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
|
||||
<div class="row-info">
|
||||
<span class="row-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
z-index: var(--z-settings);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal {
|
||||
width: min(460px, calc(100vw - 48px));
|
||||
max-height: 68vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
animation: scaleIn 0.14s ease both;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.list::-webkit-scrollbar { display: none; }
|
||||
|
||||
.empty-msg {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
padding: var(--sp-4) var(--sp-3);
|
||||
text-align: center;
|
||||
}
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
padding: 8px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.list-row:hover { background: var(--bg-raised); }
|
||||
:global(.row-thumb) {
|
||||
height: 50px;
|
||||
width: 35px;
|
||||
aspect-ratio: 1 / 1.42;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
display: block;
|
||||
}
|
||||
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.row-title {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row-source {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -1,589 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from "phosphor-svelte";
|
||||
import type { Manga, Chapter } from "@types";
|
||||
import type { HistoryEntry } from "@store/state.svelte";
|
||||
import { store, setGenreFilter, setNavPage } from "@store/state.svelte";
|
||||
import { timeAgo } from "../lib/homeHelpers";
|
||||
|
||||
interface HeroSlot {
|
||||
kind: "continue" | "pinned" | "empty";
|
||||
entry?: HistoryEntry;
|
||||
manga?: Manga;
|
||||
slotIndex: number;
|
||||
}
|
||||
|
||||
let {
|
||||
resolvedSlots,
|
||||
activeIdx = $bindable(),
|
||||
heroThumb,
|
||||
heroTitle,
|
||||
heroManga,
|
||||
heroEntry,
|
||||
heroMangaId,
|
||||
heroChapters,
|
||||
heroNewChapter,
|
||||
loadingHeroChapters,
|
||||
resuming,
|
||||
onresume,
|
||||
onopenchapter,
|
||||
oncyclenext,
|
||||
oncycleprev,
|
||||
ongotoslot,
|
||||
onopenpicker,
|
||||
onunpin,
|
||||
onviewall,
|
||||
}: {
|
||||
resolvedSlots: HeroSlot[];
|
||||
activeIdx: number;
|
||||
heroThumb: string;
|
||||
heroTitle: string;
|
||||
heroManga: Manga | null | undefined;
|
||||
heroEntry: HistoryEntry | null;
|
||||
heroMangaId: number | null;
|
||||
heroChapters: Chapter[];
|
||||
heroNewChapter: Chapter | null;
|
||||
loadingHeroChapters: boolean;
|
||||
resuming: boolean;
|
||||
onresume: () => void;
|
||||
onopenchapter: (ch: Chapter) => void;
|
||||
oncyclenext: () => void;
|
||||
oncycleprev: () => void;
|
||||
ongotoslot: (i: number) => void;
|
||||
onopenpicker: (i: 1 | 2 | 3) => void;
|
||||
onunpin: (i: 1 | 2 | 3) => void;
|
||||
onviewall: () => void;
|
||||
} = $props();
|
||||
|
||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
||||
const TOTAL_SLOTS = 4;
|
||||
</script>
|
||||
|
||||
<div class="hero-stage">
|
||||
{#key heroThumb}
|
||||
{#if heroThumb}
|
||||
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
||||
{:else}
|
||||
<div class="hero-backdrop hero-bd-empty"></div>
|
||||
{/if}
|
||||
{/key}
|
||||
<div class="hero-scrim"></div>
|
||||
|
||||
<button
|
||||
class="hero-cover-col"
|
||||
onclick={onresume}
|
||||
disabled={resuming || activeSlot?.kind === "empty"}
|
||||
aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}
|
||||
>
|
||||
{#if heroThumb}
|
||||
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
||||
{#if activeSlot?.kind === "continue"}
|
||||
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="hero-details">
|
||||
{#if activeSlot?.kind === "empty"}
|
||||
<p class="hero-empty-title">Nothing here yet</p>
|
||||
<p class="hero-empty-sub">
|
||||
{activeSlot.slotIndex === 0
|
||||
? "Read a manga to see it here"
|
||||
: "Pin a manga or keep reading to fill this slot"}
|
||||
</p>
|
||||
{#if activeSlot.slotIndex !== 0}
|
||||
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
|
||||
<PushPin size={11} weight="fill" /> Pin manga
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="hero-tags">
|
||||
{#if activeSlot?.kind === "continue"}
|
||||
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
|
||||
{:else}
|
||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
||||
{/if}
|
||||
{#if heroNewChapter && !heroNewChapter.isRead}
|
||||
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
|
||||
{/if}
|
||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
||||
<button
|
||||
class="hero-tag hero-tag-genre"
|
||||
onclick={() => { setGenreFilter(g); setNavPage("explore"); }}
|
||||
>{g}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2 class="hero-title">{heroTitle}</h2>
|
||||
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
|
||||
|
||||
{#if heroEntry}
|
||||
<p class="hero-progress">
|
||||
<Clock size={10} weight="light" />
|
||||
{heroEntry.chapterName}
|
||||
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
|
||||
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if heroManga?.description}
|
||||
<p class="hero-desc">{heroManga.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="hero-actions">
|
||||
{#if activeSlot?.kind === "continue"}
|
||||
<button class="hero-cta" onclick={onresume} disabled={resuming}>
|
||||
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
||||
</button>
|
||||
{:else if heroManga}
|
||||
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
|
||||
<BookOpen size={11} weight="light" /> View manga
|
||||
</button>
|
||||
{/if}
|
||||
{#if activeSlot?.slotIndex !== 0}
|
||||
{#if activeSlot?.kind === "pinned"}
|
||||
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
|
||||
<XIcon size={10} weight="bold" /> Unpin
|
||||
</button>
|
||||
{:else}
|
||||
<button class="hero-cta-ghost" onclick={() => onopenpicker(activeSlot!.slotIndex as 1 | 2 | 3)}>
|
||||
<PushPin size={10} weight="light" /> Pin
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="hero-nav-row">
|
||||
<button class="hero-nav-btn" onclick={oncycleprev} aria-label="Previous">
|
||||
<ArrowLeft size={12} weight="bold" />
|
||||
</button>
|
||||
<div class="hero-dots">
|
||||
{#each resolvedSlots as slot, i}
|
||||
<button
|
||||
class="hero-dot"
|
||||
class:active={activeIdx === i}
|
||||
class:pinned={slot.kind === "pinned"}
|
||||
onclick={() => ongotoslot(i)}
|
||||
aria-label="Slot {i + 1}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="hero-nav-btn" onclick={oncyclenext} aria-label="Next">
|
||||
<ArrowRight size={12} weight="bold" />
|
||||
</button>
|
||||
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-chapters">
|
||||
<div class="hero-chapters-header">
|
||||
<ListBullets size={11} weight="bold" /><span>Up Next</span>
|
||||
</div>
|
||||
|
||||
{#if activeSlot?.kind === "empty"}
|
||||
<p class="hero-chapters-empty">No chapters to show</p>
|
||||
{:else if loadingHeroChapters}
|
||||
{#each Array(4) as _}
|
||||
<div class="chapter-row-sk">
|
||||
<div class="sk sk-num"></div>
|
||||
<div class="sk-info">
|
||||
<div class="sk sk-name"></div>
|
||||
<div class="sk sk-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if heroChapters.length === 0}
|
||||
<p class="hero-chapters-empty">No chapters available</p>
|
||||
{:else}
|
||||
{#each heroChapters as ch (ch.id)}
|
||||
{@const isCurrent = heroEntry?.chapterId === ch.id}
|
||||
<button
|
||||
class="chapter-row"
|
||||
class:chapter-row-current={isCurrent}
|
||||
class:chapter-row-read={ch.isRead && !isCurrent}
|
||||
onclick={() => onopenchapter(ch)}
|
||||
>
|
||||
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
|
||||
<div class="ch-info">
|
||||
<span class="ch-name">{ch.name}</span>
|
||||
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
|
||||
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
|
||||
{:else if ch.isRead}
|
||||
<span class="ch-meta ch-read">Read</span>
|
||||
{:else if ch.uploadDate}
|
||||
<span class="ch-meta">
|
||||
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
|
||||
.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if heroManga}
|
||||
<button class="ch-view-all" onclick={onviewall}>
|
||||
All chapters <ArrowRight size={9} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 374px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.hero-backdrop {
|
||||
position: absolute;
|
||||
inset: -14px;
|
||||
background-size: cover;
|
||||
background-position: center 25%;
|
||||
filter: blur(22px) saturate(2.4) brightness(0.4);
|
||||
transform: scale(1.07);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
animation: backdropIn 0.5s ease both;
|
||||
}
|
||||
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
||||
|
||||
.hero-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
|
||||
}
|
||||
|
||||
.hero-cover-col {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
width: 256px;
|
||||
height: 374px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--bg-raised);
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
|
||||
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
||||
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
|
||||
.hero-cover-empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.cover-resume-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-details {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
overflow: hidden;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
||||
.hero-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
|
||||
.hero-tag-new { background: rgba(74, 222, 128, 0.15); color: #86efac; border-color: rgba(74, 222, 128, 0.25); }
|
||||
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
|
||||
|
||||
.hero-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: #fff;
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.hero-author {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.hero-prog-page { color: rgba(255, 255, 255, 0.35); }
|
||||
.hero-prog-time { margin-left: auto; color: rgba(255, 255, 255, 0.3); }
|
||||
|
||||
.hero-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-empty-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-empty-sub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255, 255, 255, 0.26);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
|
||||
|
||||
.hero-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
transition: filter var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
|
||||
.hero-cta:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.hero-cta-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero-cta-ghost:hover { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.82); }
|
||||
|
||||
.hero-nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.hero-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.hero-nav-btn:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
|
||||
.hero-dots { display: flex; gap: 5px; align-items: center; }
|
||||
.hero-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
|
||||
}
|
||||
.hero-dot:hover { background: rgba(255, 255, 255, 0.48); }
|
||||
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
|
||||
.hero-dot.pinned { background: rgba(168, 132, 232, 0.5); }
|
||||
.hero-dot.pinned.active { background: #c4a8f0; }
|
||||
.hero-counter {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hero-chapters {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: clamp(180px, 30%, 232px);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-4) var(--sp-3);
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-chapters-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
padding-bottom: var(--sp-2);
|
||||
margin-bottom: var(--sp-1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-chapters-empty {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-3) 0;
|
||||
}
|
||||
|
||||
.chapter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 7px var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.chapter-row:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
.chapter-row-current { background: rgba(255, 255, 255, 0.1) !important; }
|
||||
|
||||
.ch-num {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: rgba(255, 255, 255, 0.32);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
min-width: 36px;
|
||||
}
|
||||
.chapter-row-current .ch-num { color: var(--accent-fg); }
|
||||
|
||||
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.ch-name {
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chapter-row-read .ch-name { color: rgba(255, 255, 255, 0.32); }
|
||||
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
|
||||
.ch-meta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: rgba(255, 255, 255, 0.26);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.ch-read { color: rgba(255, 255, 255, 0.18); }
|
||||
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
|
||||
|
||||
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
|
||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||
.sk { background: rgba(255, 255, 255, 0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
||||
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
|
||||
.sk-name { height: 11px; width: 85%; }
|
||||
.sk-meta { height: 9px; width: 50%; }
|
||||
|
||||
.ch-view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--sp-2) var(--sp-2) 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.ch-view-all:hover { color: var(--accent-fg); }
|
||||
|
||||
@keyframes backdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
|
||||
</style>
|
||||
@@ -1,376 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { gql, resolveImageUrl } from "@api/client";
|
||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||
import { GET_LIBRARY } from "@api/queries/manga";
|
||||
import { cache, CACHE_KEYS } from "@core/cache";
|
||||
import { store, openReader, setHeroSlot, setNavPage, setLibraryFilter, clearLibraryUpdates } from "@store/state.svelte";
|
||||
import type { HistoryEntry } from "@store/state.svelte";
|
||||
import type { Manga, Chapter } from "@types";
|
||||
import { buildReaderChapterList } from "@features/series/lib/chapterList";
|
||||
import HeroStage from "./HeroStage.svelte";
|
||||
import HeroSlotPicker from "./HeroSlotPicker.svelte";
|
||||
import ActivityFeed from "./ActivityFeed.svelte";
|
||||
import ActivityHeatmap from "./ActivityHeatmap.svelte";
|
||||
import RecsRow from "./RecsRow.svelte";
|
||||
import StatsGrid from "./StatsGrid.svelte";
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let loadingLibrary: boolean = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
loadLibrary();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function loadLibrary() {
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||
)
|
||||
.then(m => { libraryManga = m; })
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
}
|
||||
|
||||
function resetAndReload() {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
loadingLibrary = true;
|
||||
heroChapters = [];
|
||||
heroAllChapters = [];
|
||||
heroChaptersFor = null;
|
||||
loadLibrary();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (store.navPage === "home") untrack(() => resetAndReload());
|
||||
});
|
||||
$effect(() => {
|
||||
const sessionId = store.readerSessionId;
|
||||
if (sessionId === 0) return;
|
||||
untrack(() => resetAndReload());
|
||||
});
|
||||
|
||||
const continueReading = $derived((() => {
|
||||
const seen = new Set<number>();
|
||||
const out: HistoryEntry[] = [];
|
||||
for (const e of store.history) {
|
||||
if (seen.has(e.mangaId)) continue;
|
||||
seen.add(e.mangaId);
|
||||
out.push(e);
|
||||
if (out.length >= 10) break;
|
||||
}
|
||||
return out;
|
||||
})());
|
||||
|
||||
const TOTAL_SLOTS = 4;
|
||||
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
||||
|
||||
const resolvedSlots = $derived((() => {
|
||||
const pins = store.settings.heroSlots ?? [null, null, null, null];
|
||||
const slots: HeroSlot[] = [];
|
||||
const first = continueReading[0];
|
||||
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
||||
let hi = 1;
|
||||
for (let i = 1; i < TOTAL_SLOTS; i++) {
|
||||
const pinId = pins[i];
|
||||
if (pinId != null) {
|
||||
const manga = libraryManga.find(m => m.id === pinId);
|
||||
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
|
||||
}
|
||||
const entry = continueReading[hi++];
|
||||
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
|
||||
}
|
||||
return slots;
|
||||
})());
|
||||
|
||||
let activeIdx = $state(0);
|
||||
|
||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
||||
|
||||
const heroManga = $derived(
|
||||
activeSlot?.kind === "pinned" ? activeSlot.manga :
|
||||
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
|
||||
);
|
||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||
const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null);
|
||||
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? "");
|
||||
|
||||
const heroThumbSrc = $derived(
|
||||
heroManga?.thumbnailUrl
|
||||
?? (activeSlot?.kind === "continue" ? activeSlot.entry?.thumbnailUrl : undefined)
|
||||
?? ""
|
||||
);
|
||||
|
||||
let heroThumb = $state("");
|
||||
$effect(() => {
|
||||
const path = heroThumbSrc;
|
||||
if (!path) { heroThumb = ""; return; }
|
||||
resolveImageUrl(path)
|
||||
.then(url => { heroThumb = url; })
|
||||
.catch(() => { heroThumb = ""; });
|
||||
});
|
||||
|
||||
const heroNewChapter = $derived(
|
||||
heroManga ? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
|
||||
);
|
||||
|
||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
||||
if (e.key === "ArrowRight") cycleNext();
|
||||
if (e.key === "ArrowLeft") cyclePrev();
|
||||
}
|
||||
|
||||
let heroChapters: Chapter[] = $state([]);
|
||||
let heroAllChapters: Chapter[] = $state([]);
|
||||
let loadingHeroChapters = $state(false);
|
||||
let heroChaptersFor: number | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const id = heroMangaId;
|
||||
void store.settings.mangaPrefs?.[id!];
|
||||
if (id) untrack(() => loadHeroChapters(id));
|
||||
});
|
||||
|
||||
async function loadHeroChapters(mangaId: number) {
|
||||
heroChaptersFor = mangaId;
|
||||
loadingHeroChapters = true;
|
||||
heroChapters = [];
|
||||
heroAllChapters = [];
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||
if (heroChaptersFor !== mangaId) return;
|
||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
heroAllChapters = all;
|
||||
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
|
||||
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
|
||||
const startIdx = Math.max(0, lastReadIdx);
|
||||
heroChapters = filtered.slice(startIdx, startIdx + 5);
|
||||
} catch { heroChapters = []; heroAllChapters = []; }
|
||||
finally { loadingHeroChapters = false; }
|
||||
}
|
||||
|
||||
let resuming = $state(false);
|
||||
|
||||
function liveMangaStub(): Manga {
|
||||
return heroManga ?? { id: heroMangaId!, title: heroTitle, thumbnailUrl: heroThumbSrc } as any;
|
||||
}
|
||||
|
||||
async function openChapter(chapter: Chapter) {
|
||||
if (!heroMangaId) return;
|
||||
resuming = true;
|
||||
try {
|
||||
let all = heroAllChapters;
|
||||
if (!all.length) {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
}
|
||||
if (all.length) {
|
||||
store.activeManga = liveMangaStub();
|
||||
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
||||
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
||||
if (target) openReader(target, list);
|
||||
}
|
||||
} catch { store.activeManga = liveMangaStub(); }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
|
||||
async function resumeActive() {
|
||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
||||
if (!heroEntry) return;
|
||||
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
||||
if (target && heroAllChapters.length) { await openChapter(target); return; }
|
||||
resuming = true;
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
||||
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
||||
if (ch) {
|
||||
store.activeManga = liveMangaStub();
|
||||
openReader(ch, list);
|
||||
}
|
||||
} catch { store.activeManga = liveMangaStub(); }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
|
||||
async function resumeEntry(entry: HistoryEntry) {
|
||||
const liveManga = libraryManga.find(m => m.id === entry.mangaId);
|
||||
const stub = liveManga ?? { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: liveManga?.thumbnailUrl ?? entry.thumbnailUrl } as any;
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
||||
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
||||
store.activeManga = stub;
|
||||
if (ch) openReader(ch, list);
|
||||
} catch { store.activeManga = stub; }
|
||||
}
|
||||
|
||||
let pickerOpen = $state(false);
|
||||
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null);
|
||||
|
||||
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true; }
|
||||
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
|
||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||
function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null); }
|
||||
|
||||
const recentHistory = $derived(store.history.slice(0, 6));
|
||||
const stats = $derived(store.readingStats);
|
||||
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
|
||||
const lastRefresh = $derived(store.lastLibraryRefresh);
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="body">
|
||||
|
||||
<div class="hero-shrink-guard">
|
||||
<HeroStage
|
||||
{resolvedSlots}
|
||||
bind:activeIdx
|
||||
{heroThumb}
|
||||
{heroTitle}
|
||||
{heroManga}
|
||||
{heroEntry}
|
||||
{heroMangaId}
|
||||
{heroChapters}
|
||||
{heroNewChapter}
|
||||
{loadingHeroChapters}
|
||||
{resuming}
|
||||
onresume={resumeActive}
|
||||
onopenchapter={openChapter}
|
||||
oncyclenext={cycleNext}
|
||||
oncycleprev={cyclePrev}
|
||||
ongotoslot={goToSlot}
|
||||
onopenpicker={openPicker}
|
||||
onunpin={unpinSlot}
|
||||
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="scroll-body">
|
||||
<div class="mid-row">
|
||||
<div class="mid-left">
|
||||
<ActivityFeed
|
||||
entries={recentHistory}
|
||||
{libraryManga}
|
||||
onresume={resumeEntry}
|
||||
onviewhistory={() => setNavPage("history")}
|
||||
onopenlibrary={() => setNavPage("library")}
|
||||
/>
|
||||
</div>
|
||||
<div class="mid-divider"></div>
|
||||
<div class="mid-right">
|
||||
<RecsRow
|
||||
{libraryManga}
|
||||
history={store.history}
|
||||
onopenrecommended={(m) => { store.previewManga = m; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-row">
|
||||
<div class="bottom-heatmap">
|
||||
<span class="bottom-label">Activity</span>
|
||||
<ActivityHeatmap dailyReadCounts={store.dailyReadCounts} />
|
||||
</div>
|
||||
<div class="bottom-divider"></div>
|
||||
<div class="bottom-stats">
|
||||
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pickerOpen && pickerSlotIndex !== null}
|
||||
<HeroSlotPicker
|
||||
slotIndex={pickerSlotIndex}
|
||||
{libraryManga}
|
||||
loading={loadingLibrary}
|
||||
onpin={pinManga}
|
||||
onclose={closePicker}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.4s ease both;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.hero-shrink-guard { flex-shrink: 0; }
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scroll-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
.mid-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1.4fr;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.mid-left {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mid-left :global(.section) { border-top: none; }
|
||||
.mid-divider { background: var(--border-dim); align-self: stretch; }
|
||||
.mid-right {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1fr;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
||||
.bottom-heatmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.bottom-stats {
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bottom-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,244 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, ArrowRight, Sparkle } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga } from "@types";
|
||||
import type { HistoryEntry } from "@store/state.svelte";
|
||||
import { fetchRecommendations, topGenres } from "../lib/recommendations";
|
||||
import type { RecommendedManga } from "../lib/recommendations";
|
||||
|
||||
let {
|
||||
libraryManga,
|
||||
history,
|
||||
onopenrecommended,
|
||||
}: {
|
||||
libraryManga: Manga[];
|
||||
history: HistoryEntry[];
|
||||
onopenrecommended: (m: Manga) => void;
|
||||
} = $props();
|
||||
|
||||
const CARD_MIN_WIDTH = 100;
|
||||
const GAP = 12;
|
||||
const ROWS = 2;
|
||||
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let containerWidth = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
containerWidth = entry.contentRect.width;
|
||||
});
|
||||
ro.observe(containerEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6);
|
||||
const visibleCount = $derived(cols * ROWS);
|
||||
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`);
|
||||
|
||||
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();
|
||||
const ctrl = new AbortController();
|
||||
_ctrl = ctrl;
|
||||
loading = true;
|
||||
fetchRecommendations(_history, _library, ctrl.signal)
|
||||
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false; } })
|
||||
.catch(() => { if (!ctrl.signal.aborted) loading = false; });
|
||||
});
|
||||
|
||||
const genres = $derived(topGenres(history, libraryManga));
|
||||
|
||||
let genreIdx = $state(0);
|
||||
|
||||
const activeGenre = $derived(genres[genreIdx] ?? null);
|
||||
|
||||
const visibleRecs = $derived(
|
||||
(activeGenre
|
||||
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
|
||||
: allRecs
|
||||
).slice(0, visibleCount)
|
||||
);
|
||||
|
||||
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length; }
|
||||
function next() { genreIdx = (genreIdx + 1) % genres.length; }
|
||||
</script>
|
||||
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
<span class="col-title">
|
||||
<Sparkle size={10} weight="bold" /> Recommended
|
||||
</span>
|
||||
{#if genres.length > 1}
|
||||
<div class="genre-switcher">
|
||||
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
|
||||
<span class="genre-label">{activeGenre}</span>
|
||||
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid-container" bind:this={containerEl}>
|
||||
{#if loading}
|
||||
<p class="empty-msg">Loading…</p>
|
||||
{:else if visibleRecs.length > 0}
|
||||
<div class="card-grid" style={gridStyle}>
|
||||
{#each visibleRecs as r (r.manga.id)}
|
||||
<button class="card" onclick={() => onopenrecommended(r.manga)}>
|
||||
<div class="card-cover-wrap">
|
||||
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
|
||||
<div class="card-gradient"></div>
|
||||
<div class="card-footer">
|
||||
<p class="card-title">{r.manga.title}</p>
|
||||
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(" · ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-msg">No recommendations found</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
|
||||
|
||||
.col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.genre-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.genre-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.nav-btn:hover { color: var(--accent-fg); }
|
||||
|
||||
.grid-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, auto);
|
||||
grid-auto-rows: 0;
|
||||
overflow: hidden;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
|
||||
|
||||
.card-cover-wrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
:global(.card-cover) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: filter 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.card-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--sp-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: rgba(255,255,255,0.92);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
|
||||
}
|
||||
.card-badge {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: rgba(255,255,255,0.45);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
|
||||
import { formatReadTime } from "../lib/homeHelpers";
|
||||
|
||||
let {
|
||||
stats,
|
||||
updateCount,
|
||||
}: {
|
||||
stats: {
|
||||
currentStreakDays: number;
|
||||
totalChaptersRead: number;
|
||||
totalMinutesRead: number;
|
||||
totalMangaRead: number;
|
||||
longestStreakDays: number;
|
||||
};
|
||||
updateCount: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
<span class="col-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="icon-wrap fire"><Fire size={15} weight="fill" /></div>
|
||||
<div class="body">
|
||||
<span class="val">{stats.currentStreakDays}</span>
|
||||
<span class="label">Day streak</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-wrap accent"><BookOpen size={15} weight="light" /></div>
|
||||
<div class="body">
|
||||
<span class="val">{stats.totalChaptersRead}</span>
|
||||
<span class="label">Chapters read</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-wrap neutral"><Clock size={15} weight="light" /></div>
|
||||
<div class="body">
|
||||
<span class="val">{formatReadTime(stats.totalMinutesRead)}</span>
|
||||
<span class="label">Read time</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-wrap neutral"><TrendUp size={15} weight="light" /></div>
|
||||
<div class="body">
|
||||
<span class="val">{stats.totalMangaRead}</span>
|
||||
<span class="label">Series started</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-wrap green"><Bell size={15} weight="light" /></div>
|
||||
<div class="body">
|
||||
<span class="val">{updateCount}</span>
|
||||
<span class="label">New updates</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon-wrap neutral"><CalendarBlank size={15} weight="light" /></div>
|
||||
<div class="body">
|
||||
<span class="val">{stats.longestStreakDays}d</span>
|
||||
<span class="label">Best streak</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.col { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
.col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-3);
|
||||
transition: border-color var(--t-fast);
|
||||
}
|
||||
.card:hover { border-color: var(--border-base); }
|
||||
|
||||
.icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fire { background: rgba(251, 146, 60, 0.15); color: #fb923c; }
|
||||
.accent { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
||||
.green { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
|
||||
.body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.val {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-lg, 1.05rem);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
.label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<string, { count: number; original: string }>();
|
||||
|
||||
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<number>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Manga[]> {
|
||||
const filter = {
|
||||
and: [
|
||||
buildTagFilter([genre], "OR", []),
|
||||
{ inLibrary: { equalTo: false } },
|
||||
],
|
||||
};
|
||||
|
||||
const localSeen = new Set<number>();
|
||||
const nodes: Manga[] = [];
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
let batch: Manga[];
|
||||
try {
|
||||
const d = await gql<Result>(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<RecommendedManga[]> {
|
||||
if (!history.length || !libraryManga.length) return [];
|
||||
|
||||
const genres = topGenres(history, libraryManga);
|
||||
if (!genres.length) return [];
|
||||
|
||||
const globalSeen = new Set<number>();
|
||||
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())
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -1,753 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { gql } from "@api/client";
|
||||
import {
|
||||
GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS,
|
||||
GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD,
|
||||
CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES,
|
||||
UPDATE_CATEGORY_ORDER, UPDATE_STOP, UPDATE_LIBRARY_MANGA, UPDATE_CATEGORY_MANGA,
|
||||
} from "@api";
|
||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
|
||||
import { sortLibrary } from "../lib/librarySort";
|
||||
import { startLibraryUpdate } from "../lib/libraryUpdater";
|
||||
import { createPaginator } from "@core/algorithms/paginate";
|
||||
import { longPress } from "@core/ui/touchscreen";
|
||||
import {
|
||||
store, setCategories, setLibraryUpdates, addToast,
|
||||
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
||||
} from "../store/libraryState.svelte";
|
||||
import { saveScroll, getScroll } from "@store/state.svelte";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
||||
import type { Manga, Category, Chapter } from "@types";
|
||||
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
||||
|
||||
import LibraryToolbar from "./LibraryToolbar.svelte";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import LibraryFilters from "./LibraryFilters.svelte";
|
||||
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
|
||||
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||
|
||||
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut, ArrowsClockwise } from "phosphor-svelte";
|
||||
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
const COMPLETED_NAME = "Completed";
|
||||
const CTX_FOLDER_CAP = 4;
|
||||
|
||||
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
|
||||
|
||||
let allManga: Manga[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let error: string|null = $state(null);
|
||||
let retryCount: number = $state(0);
|
||||
let search: string = $state("");
|
||||
let renderVisible: number = $state(store.settings.renderLimit ?? 48);
|
||||
let scrollEl: HTMLDivElement;
|
||||
let tabsEl = $state<HTMLDivElement>(null!);
|
||||
let containerWidth: number = $state(800);
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
||||
|
||||
let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 });
|
||||
|
||||
let selectedIds: Set<number> = $state(new Set());
|
||||
let selectMode: boolean = $state(false);
|
||||
let bulkWorking: boolean = $state(false);
|
||||
let bulkAutomateOpen: boolean = $state(false);
|
||||
|
||||
let sortPanelOpen: boolean = $state(false);
|
||||
let filterPanelOpen: boolean = $state(false);
|
||||
|
||||
let refreshing: boolean = $state(false);
|
||||
let refreshProgress = $state({ finished: 0, total: 0 });
|
||||
let cancelUpdate: (() => void) | null = null;
|
||||
let refreshDone: boolean = $state(false);
|
||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let refreshingMangaId: number | null = $state(null);
|
||||
let refreshingCatId: number | null = $state(null);
|
||||
|
||||
let activeDragKind: "tab" | null = $state(null);
|
||||
let dragInsertIdx: number = $state(-1);
|
||||
let dragTabId: string | null = $state(null);
|
||||
let dragOverTabId: string | null = $state(null);
|
||||
|
||||
const DT_TAB = "application/x-moku-tab";
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
|
||||
const tab = $derived(store.libraryFilter);
|
||||
const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode);
|
||||
const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
|
||||
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
|
||||
const tabFilters = $derived(store.settings.libraryTabFilters?.[tab] ?? {} as Partial<Record<LibraryContentFilter, boolean>>);
|
||||
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
||||
|
||||
const BUILTIN_TABS = ["library", "downloaded"] as const;
|
||||
|
||||
const completedCatId = $derived(
|
||||
store.categories.find(c => c.name === COMPLETED_NAME && c.id !== 0)?.id ?? null
|
||||
);
|
||||
|
||||
const allTabIds = $derived((() => {
|
||||
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
||||
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||
const known = new Set([...BUILTIN_TABS, ...catIds]);
|
||||
const eligible = pinned.filter(id => known.has(id));
|
||||
const ordered = [...eligible];
|
||||
const inOrder = new Set(ordered);
|
||||
for (const id of [...BUILTIN_TABS, ...catIds]) {
|
||||
if (!inOrder.has(id)) ordered.push(id);
|
||||
}
|
||||
return ordered;
|
||||
})());
|
||||
|
||||
const hiddenTabs = $derived(new Set(store.settings.hiddenLibraryTabs ?? []));
|
||||
|
||||
const visibleTabIds = $derived(allTabIds.filter(id => !hiddenTabs.has(id)));
|
||||
|
||||
const virtualTabIds = $derived(visibleTabIds.filter(id =>
|
||||
id === "library" || id === "downloaded" || (completedCatId !== null && id === String(completedCatId))
|
||||
));
|
||||
|
||||
const folderTabIds = $derived(visibleTabIds.filter(id =>
|
||||
id !== "library" && id !== "downloaded" && (completedCatId === null || id !== String(completedCatId))
|
||||
));
|
||||
|
||||
const visibleCategories = $derived((() => {
|
||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||||
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||
const cats = store.categories.filter(c => c.id !== 0 && !hiddenTabs.has(String(c.id)));
|
||||
const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; };
|
||||
return cats.sort((a, b) => {
|
||||
if (a.id === defaultId) return -1;
|
||||
if (b.id === defaultId) return 1;
|
||||
const pd = pinOrder(a.id) - pinOrder(b.id);
|
||||
return pd !== 0 ? pd : a.order - b.order;
|
||||
});
|
||||
})());
|
||||
|
||||
const categoryMangaMap = $derived((() => {
|
||||
const map = new Map<number, Manga[]>();
|
||||
for (const cat of store.categories) {
|
||||
map.set(cat.id, cat.mangas?.nodes ?? []);
|
||||
}
|
||||
return map;
|
||||
})());
|
||||
|
||||
const filtered = $derived((() => {
|
||||
let items: Manga[];
|
||||
|
||||
if (tab === "library") {
|
||||
items = (store.settings.libraryShowAllInSaved ?? true)
|
||||
? allManga.filter(m => m.inLibrary)
|
||||
: (categoryMangaMap.get(0) ?? []);
|
||||
|
||||
if ((store.settings.libraryShowAllInSaved ?? true) && (store.settings.libraryHideCompletedInSaved ?? false)) {
|
||||
const completedCat = store.categories.find(c => c.name === COMPLETED_NAME);
|
||||
if (completedCat) {
|
||||
const completedIds = new Set((categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id));
|
||||
items = items.filter(m => !completedIds.has(m.id));
|
||||
}
|
||||
}
|
||||
} else if (tab === "downloaded") {
|
||||
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
} else {
|
||||
items = categoryMangaMap.get(Number(tab)) ?? [];
|
||||
}
|
||||
|
||||
items = items.filter(m => !shouldHideNsfw(m, store.settings));
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||||
|
||||
if (tabStatus !== "ALL") {
|
||||
items = items.filter(m => {
|
||||
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
|
||||
return s === tabStatus;
|
||||
});
|
||||
}
|
||||
|
||||
const f = store.settings.libraryTabFilters?.[tab] ?? {};
|
||||
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
|
||||
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
|
||||
|
||||
const recentlyReadMap = new Map<number, number>();
|
||||
if (tabSortMode === "recentlyRead") {
|
||||
for (const h of store.history) {
|
||||
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
|
||||
}
|
||||
}
|
||||
|
||||
return sortLibrary(items, tabSortMode, tabSortDir, recentlyReadMap.size ? recentlyReadMap : undefined);
|
||||
})());
|
||||
|
||||
const { items: visibleManga, hasMore, remaining: remainingCount } = $derived(
|
||||
paginator.slice(filtered, renderVisible)
|
||||
);
|
||||
|
||||
const counts = $derived((() => {
|
||||
const m: Record<string, number> = {
|
||||
library: (store.settings.libraryShowAllInSaved ?? true)
|
||||
? allManga.filter(x => x.inLibrary).length
|
||||
: (categoryMangaMap.get(0) ?? []).length,
|
||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
||||
};
|
||||
for (const cat of visibleCategories) {
|
||||
m[String(cat.id)] = (categoryMangaMap.get(cat.id) ?? []).length;
|
||||
}
|
||||
return m;
|
||||
})());
|
||||
|
||||
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
||||
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
||||
let prevTab = $state(tab);
|
||||
$effect(() => {
|
||||
const nextTab = tab;
|
||||
if (scrollEl && nextTab !== prevTab) {
|
||||
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
|
||||
const saved = getScroll(`library:${nextTab}`);
|
||||
untrack(() => { scrollEl.scrollTo({ top: saved }); });
|
||||
prevTab = nextTab;
|
||||
} else if (scrollEl && nextTab === prevTab) {
|
||||
scrollEl.scrollTo({ top: 0 });
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
const f = tab;
|
||||
if (f === "library" || f === "downloaded") return;
|
||||
const id = Number(f);
|
||||
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
||||
});
|
||||
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
||||
$effect(() => { tab; counts; });
|
||||
|
||||
let prevChapterId: number | null = null;
|
||||
$effect(() => {
|
||||
const wasOpen = prevChapterId !== null;
|
||||
prevChapterId = store.activeChapter?.id ?? null;
|
||||
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
|
||||
});
|
||||
|
||||
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
||||
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
|
||||
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
||||
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
||||
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
||||
|
||||
let cardLongPressFired = false;
|
||||
|
||||
function rootLongPressAction(node: HTMLElement) {
|
||||
return longPress(node, {
|
||||
onLongPress(e) {
|
||||
if ((e.target as HTMLElement).closest("button, .card")) return;
|
||||
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cardLongPress(node: HTMLElement, m: Manga) {
|
||||
return longPress(node, {
|
||||
onLongPress(e) {
|
||||
cardLongPressFired = true;
|
||||
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onCardClick(e: MouseEvent, m: Manga) {
|
||||
if (cardLongPressFired) { cardLongPressFired = false; return; }
|
||||
if (selectMode) { toggleSelect(m.id); return; }
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
|
||||
store.activeManga = m;
|
||||
}
|
||||
|
||||
async function ensureCompletedCategory(cats: Category[]): Promise<Category[]> {
|
||||
if (cats.some(c => c.name === COMPLETED_NAME)) return cats;
|
||||
try {
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: COMPLETED_NAME });
|
||||
return [...cats, res.createCategory.category];
|
||||
} catch { return cats; }
|
||||
}
|
||||
|
||||
async function reloadCategories() {
|
||||
try {
|
||||
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||
const cats = await ensureCompletedCategory(d.categories.nodes);
|
||||
setCategories(cats);
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [nodes] = await Promise.all([
|
||||
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
|
||||
reloadCategories(),
|
||||
]);
|
||||
const mapped = nodes.map((m: any) => ({ ...m }));
|
||||
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
|
||||
error = null;
|
||||
await migrateCategorizedToLibrary();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateCategorizedToLibrary() {
|
||||
const allCatManga = store.categories.flatMap(c => c.mangas?.nodes ?? []);
|
||||
const orphanIds = [...new Set(allCatManga.filter(m => !m.inLibrary).map(m => m.id))];
|
||||
if (!orphanIds.length) return;
|
||||
await gql(UPDATE_MANGAS, { ids: orphanIds, inLibrary: true }).catch(console.error);
|
||||
allManga = allManga.map(m => orphanIds.includes(m.id) ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
|
||||
async function removeFromLibrary(manga: Manga) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||
allManga = allManga.filter(m => m.id !== manga.id);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
await reloadCategories();
|
||||
}
|
||||
|
||||
async function deleteAllDownloads(manga: Manga) {
|
||||
try {
|
||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||
const ids = data.chapters.nodes.filter(c => c.isDownloaded).map(c => c.id);
|
||||
if (!ids.length) return;
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||
await Promise.allSettled(ids.map(id => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, downloadCount: 0 } : m);
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function refreshManga(manga: Manga) {
|
||||
if (refreshingMangaId !== null) return;
|
||||
refreshingMangaId = manga.id;
|
||||
try {
|
||||
await gql(UPDATE_LIBRARY_MANGA, { id: manga.id });
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
await loadData();
|
||||
addToast({ kind: "success", title: "Manga refreshed", body: manga.title, duration: 2500 });
|
||||
} catch (e) { console.error(e); }
|
||||
finally { refreshingMangaId = null; }
|
||||
}
|
||||
|
||||
async function refreshCategory(catId: number) {
|
||||
if (refreshingCatId !== null || refreshing) return;
|
||||
refreshingCatId = catId;
|
||||
try {
|
||||
await gql(UPDATE_CATEGORY_MANGA, { categoryId: catId });
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
await loadData();
|
||||
const cat = store.categories.find(c => c.id === catId);
|
||||
addToast({ kind: "success", title: "Folder refreshed", body: cat?.name ?? "", duration: 2500 });
|
||||
} catch (e) { console.error(e); }
|
||||
finally { refreshingCatId = null; }
|
||||
}
|
||||
|
||||
function bumpCategoryFrecency(catId: number) {
|
||||
const prev = (store.settings as any).categoryFrecency ?? {};
|
||||
updateSettings({ categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } } as any);
|
||||
}
|
||||
|
||||
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
||||
setCategories(store.categories.map(c => {
|
||||
if (c.id !== cat.id || !c.mangas) return c;
|
||||
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
|
||||
return { ...c, mangas: { nodes } };
|
||||
}));
|
||||
if (!inCat) bumpCategoryFrecency(cat.id);
|
||||
try {
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
|
||||
if (!inCat && !manga.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
await reloadCategories();
|
||||
} catch (e) { console.error(e); await reloadCategories(); }
|
||||
}
|
||||
|
||||
async function createAndAssign(manga: Manga) {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
||||
const cat = res.createCategory.category;
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
||||
bumpCategoryFrecency(cat.id);
|
||||
if (!manga.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
await reloadCategories();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function bulkMoveToCategory(cat: Category) {
|
||||
bulkWorking = true;
|
||||
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
|
||||
finally { bulkWorking = false; exitSelectMode(); }
|
||||
}
|
||||
|
||||
async function bulkRemoveFromLibrary() {
|
||||
bulkWorking = true;
|
||||
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? removeFromLibrary(m) : Promise.resolve(); })); }
|
||||
finally { bulkWorking = false; exitSelectMode(); }
|
||||
}
|
||||
|
||||
function bulkAutomate() {
|
||||
if (selectedIds.size === 0) return;
|
||||
bulkAutomateOpen = true;
|
||||
}
|
||||
|
||||
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
|
||||
|
||||
async function openMangaFolder(m: Manga) {
|
||||
let base = store.settings.serverDownloadsPath?.trim();
|
||||
if (!base) { try { base = await invoke<string>("get_default_downloads_path"); } catch {} }
|
||||
if (!base) { addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" }); return; }
|
||||
const source = m.source?.displayName ?? m.source?.name ?? "";
|
||||
const path = source ? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}` : `${base}/mangas/${sanitize(m.title)}`;
|
||||
try { await invoke("open_path", { path }); }
|
||||
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
||||
}
|
||||
|
||||
async function openDownloadsFolder() {
|
||||
let path = store.settings.serverDownloadsPath?.trim();
|
||||
if (!path) { try { path = await invoke<string>("get_default_downloads_path"); } catch {} }
|
||||
if (!path) { addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" }); return; }
|
||||
try { await invoke("open_path", { path }); }
|
||||
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
||||
}
|
||||
|
||||
const SIDEBAR_W = 52;
|
||||
const TITLEBAR_H = 36;
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
if (selectMode) { toggleSelect(m.id); return; }
|
||||
e.preventDefault();
|
||||
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
const frecency: Record<number, number> = (store.settings as any).categoryFrecency ?? {};
|
||||
const sorted = [...visibleCategories].sort((a, b) => (frecency[b.id] ?? 0) - (frecency[a.id] ?? 0));
|
||||
const pinned = sorted.slice(0, CTX_FOLDER_CAP);
|
||||
const overflow = sorted.slice(CTX_FOLDER_CAP);
|
||||
|
||||
const makeCatEntry = (cat: Category): MenuEntry => {
|
||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
||||
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
|
||||
};
|
||||
|
||||
const pinnedEntries = pinned.map(makeCatEntry);
|
||||
const overflowChildren = overflow.map(makeCatEntry);
|
||||
|
||||
return [
|
||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||
{ label: refreshingMangaId === m.id ? "Refreshing…" : "Refresh manga", icon: ArrowsClockwise, disabled: refreshingMangaId !== null, onClick: () => refreshManga(m) },
|
||||
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
||||
{ separator: true },
|
||||
{ label: "Select", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
||||
...(pinnedEntries.length ? [{ separator: true } as MenuEntry, ...pinnedEntries] : []),
|
||||
...(overflowChildren.length ? [{ label: `More folders (${overflowChildren.length})`, icon: FolderSimple, onClick: () => {}, children: overflowChildren } as MenuEntry] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyCtx(): MenuEntry[] {
|
||||
return [{ label: "New folder", icon: FolderSimplePlus, onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
try { await gql(CREATE_CATEGORY, { name: name.trim() }); await reloadCategories(); }
|
||||
catch (e) { console.error(e); }
|
||||
}}];
|
||||
}
|
||||
|
||||
export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, store.categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
await reloadCategories();
|
||||
}
|
||||
|
||||
function showToast(newChapters: number, totalUpdated: number) {
|
||||
if (newChapters > 0) {
|
||||
addToast({ kind: "success", title: "Library updated", body: `${newChapters} new chapter${newChapters !== 1 ? "s" : ""} across ${totalUpdated} series` });
|
||||
} else {
|
||||
addToast({ kind: "info", title: "Already up to date", body: "No new chapters found" });
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelLibraryRefresh() {
|
||||
if (!refreshing) return;
|
||||
try { await gql(UPDATE_STOP); } catch (e) { console.error(e); }
|
||||
cancelUpdate?.();
|
||||
cancelUpdate = null;
|
||||
refreshing = false;
|
||||
refreshProgress = { finished: 0, total: 0 };
|
||||
}
|
||||
|
||||
async function startLibraryRefresh() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
refreshProgress = { finished: 0, total: 0 };
|
||||
|
||||
cancelUpdate = startLibraryUpdate({
|
||||
onProgress(p) { refreshProgress = p; },
|
||||
async onDone({ entries, totalUpdated, newChapters }) {
|
||||
refreshing = false; cancelUpdate = null;
|
||||
setLibraryUpdates(entries);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
await loadData();
|
||||
refreshDone = true;
|
||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
|
||||
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
||||
showToast(newChapters, totalUpdated);
|
||||
},
|
||||
onError() { refreshing = false; cancelUpdate = null; },
|
||||
});
|
||||
}
|
||||
|
||||
function onTabDragStart(e: DragEvent, id: string) {
|
||||
activeDragKind = "tab"; dragTabId = id;
|
||||
e.dataTransfer!.effectAllowed = "move";
|
||||
e.dataTransfer!.setData(DT_TAB, id);
|
||||
e.dataTransfer!.setData("text/plain", `tab:${id}`);
|
||||
}
|
||||
|
||||
function onTabDragOver(e: DragEvent, id: string, idx: number) {
|
||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
|
||||
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
||||
dragOverTabId = id;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
||||
}
|
||||
|
||||
function onTabDragLeave() { dragOverTabId = null; }
|
||||
|
||||
async function onTabDrop(e: DragEvent, dropId: string) {
|
||||
e.preventDefault(); dragOverTabId = null;
|
||||
const insertAt = dragInsertIdx;
|
||||
dragInsertIdx = -1;
|
||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
|
||||
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||
|
||||
const tabs = [...allTabIds];
|
||||
const fromIdx = tabs.indexOf(dragStrId);
|
||||
const dropIdx = tabs.indexOf(dropId);
|
||||
if (fromIdx < 0 || dropIdx < 0) return;
|
||||
|
||||
const visibleDrop = visibleTabIds[insertAt] ?? null;
|
||||
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
|
||||
|
||||
tabs.splice(fromIdx, 1);
|
||||
const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
|
||||
tabs.splice(adjustedDest, 0, dragStrId);
|
||||
|
||||
updateSettings({ libraryPinnedTabOrder: tabs });
|
||||
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
|
||||
const zeroCat = store.categories.filter(c => c.id === 0);
|
||||
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
|
||||
setCategories([...zeroCat, ...reordered]);
|
||||
const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
|
||||
if (!dragIsBuiltin) {
|
||||
const serverPos = catIds.indexOf(dragStrId) + 1;
|
||||
try {
|
||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
|
||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
||||
}
|
||||
}
|
||||
|
||||
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
|
||||
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
||||
ro.observe(scrollEl);
|
||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData());
|
||||
|
||||
const defaultId = store.settings.defaultLibraryCategoryId;
|
||||
if (defaultId && store.libraryFilter === "library") store.libraryFilter = String(defaultId);
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && (sortPanelOpen || filterPanelOpen)) { sortPanelOpen = false; filterPanelOpen = false; return; }
|
||||
if (e.key === "Escape" && selectMode) exitSelectMode();
|
||||
if ((e.key === "a" && (e.metaKey || e.ctrlKey)) && selectMode) { e.preventDefault(); selectAll(); }
|
||||
}
|
||||
|
||||
function onDocMouseDown(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (sortPanelOpen && !t.closest(".sort-panel-wrap, .sort-panel")) sortPanelOpen = false;
|
||||
if (filterPanelOpen && !t.closest(".filter-panel-wrap, .filter-panel")) filterPanelOpen = false;
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("mousedown", onDocMouseDown, true);
|
||||
|
||||
return () => {
|
||||
ro.disconnect(); unsub();
|
||||
cancelUpdate?.();
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("mousedown", onDocMouseDown, true);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root"
|
||||
role="presentation"
|
||||
bind:this={scrollEl}
|
||||
use:rootLongPressAction
|
||||
oncontextmenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
||||
}}
|
||||
>
|
||||
{#if store.settings.libraryBranches ?? true}
|
||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
||||
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
|
||||
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
|
||||
<path d="M270 220 C255 190 230 175 210 150"/>
|
||||
<path d="M270 220 C290 195 310 185 330 165"/>
|
||||
<path d="M310 400 C290 375 265 368 245 350"/>
|
||||
<path d="M310 400 C330 370 355 362 370 340"/>
|
||||
<path d="M210 150 C195 128 185 108 175 80"/>
|
||||
<path d="M210 150 C225 130 240 122 258 105"/>
|
||||
<path d="M245 350 C228 330 215 315 205 290"/>
|
||||
<path d="M175 80 C168 60 162 42 158 20"/>
|
||||
<path d="M175 80 C185 62 195 50 208 35"/>
|
||||
<path d="M205 290 C196 268 190 250 186 225"/>
|
||||
<path d="M258 105 C268 88 278 72 292 52"/>
|
||||
<path class="anim-branch" d="M186 225 C180 205 176 185 174 160"/>
|
||||
<path class="anim-branch" d="M292 52 C300 36 308 20 318 0"/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="center">
|
||||
<p class="error-msg">Could not reach Suwayomi</p>
|
||||
<p class="error-detail">Make sure the server is running, then retry.</p>
|
||||
<button class="retry-btn" onclick={() => retryCount++}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
<LibraryToolbar
|
||||
onclick={(e: MouseEvent) => { if (selectMode && !(e.target as HTMLElement).closest("button, input")) exitSelectMode(); }}
|
||||
{tab}
|
||||
{tabSortMode}
|
||||
{tabSortDir}
|
||||
{tabStatus}
|
||||
{tabFilters}
|
||||
{hasActiveFilters}
|
||||
{anims}
|
||||
{visibleCategories}
|
||||
{visibleTabIds}
|
||||
{virtualTabIds}
|
||||
{folderTabIds}
|
||||
{completedCatId}
|
||||
{counts}
|
||||
{search}
|
||||
{refreshing}
|
||||
{refreshProgress}
|
||||
{refreshDone}
|
||||
{refreshingCatId}
|
||||
{activeDragKind}
|
||||
{dragInsertIdx}
|
||||
{dragTabId}
|
||||
{dragOverTabId}
|
||||
{sortPanelOpen}
|
||||
{filterPanelOpen}
|
||||
bind:tabsEl
|
||||
onSearchChange={(v) => search = v}
|
||||
onTabChange={(f) => store.libraryFilter = f}
|
||||
onSortChange={(mode) => { setTabSort(tab, mode); sortPanelOpen = false; }}
|
||||
onSortDirToggle={() => toggleTabSortDir(tab)}
|
||||
onStatusChange={(s) => setTabStatus(tab, s)}
|
||||
onFilterToggle={(f) => toggleTabFilter(tab, f)}
|
||||
onFiltersClear={() => clearTabFilters(tab)}
|
||||
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
|
||||
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
|
||||
onRefresh={startLibraryRefresh}
|
||||
onCancelRefresh={cancelLibraryRefresh}
|
||||
onRefreshCategory={refreshCategory}
|
||||
onOpenDownloadsFolder={openDownloadsFolder}
|
||||
onTabDragStart={onTabDragStart}
|
||||
onTabDragOver={onTabDragOver}
|
||||
onTabDragLeave={onTabDragLeave}
|
||||
onTabDrop={onTabDrop}
|
||||
onTabDragEnd={onTabDragEnd}
|
||||
/>
|
||||
|
||||
{#if refreshing && refreshProgress.total > 0}
|
||||
{@const pct = Math.round((refreshProgress.finished / refreshProgress.total) * 100)}
|
||||
<div class="refresh-bar-wrap" aria-hidden="true">
|
||||
<div class="refresh-bar-fill" style="width:{pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<LibraryGrid
|
||||
{visibleManga}
|
||||
{filtered}
|
||||
{loading}
|
||||
{cols}
|
||||
{anims}
|
||||
{selectMode}
|
||||
{selectedIds}
|
||||
{hasMore}
|
||||
{remainingCount}
|
||||
renderLimit={store.settings.renderLimit ?? 48}
|
||||
cropCovers={store.settings.libraryCropCovers}
|
||||
statsAlways={store.settings.libraryStatsAlways ?? false}
|
||||
libraryFilter={tab}
|
||||
onCardClick={onCardClick}
|
||||
onCardContextMenu={openCtx}
|
||||
onCardLongPress={cardLongPress}
|
||||
onLoadMore={loadMore}
|
||||
onRetry={() => retryCount++}
|
||||
onExitSelectMode={exitSelectMode}
|
||||
onSelectAll={selectAll}
|
||||
onBulkMove={bulkMoveToCategory}
|
||||
onBulkRemove={bulkRemoveFromLibrary}
|
||||
onBulkAutomate={bulkAutomate}
|
||||
{bulkWorking}
|
||||
{visibleCategories}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
{#if emptyCtx}
|
||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
||||
{/if}
|
||||
{#if bulkAutomateOpen}
|
||||
<BulkAutomationPanel
|
||||
ids={selectedIds}
|
||||
onClose={() => { bulkAutomateOpen = false; exitSelectMode(); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
|
||||
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
|
||||
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
|
||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.refresh-bar-wrap { height: 2px; background: var(--border-dim); flex-shrink: 0; overflow: hidden; }
|
||||
.refresh-bar-fill { height: 100%; background: var(--accent); border-radius: 0 2px 2px 0; transition: width 0.6s ease; }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
|
||||
</style>
|
||||
@@ -1,113 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Check, Funnel } from "phosphor-svelte";
|
||||
import type { LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
|
||||
|
||||
interface Props {
|
||||
tabStatus: LibraryStatusFilter;
|
||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
||||
hasActiveFilters: boolean;
|
||||
filterPanelOpen: boolean;
|
||||
onStatusChange: (s: LibraryStatusFilter) => void;
|
||||
onFilterToggle: (f: LibraryContentFilter) => void;
|
||||
onFiltersClear: () => void;
|
||||
onFilterPanelToggle: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tabStatus, tabFilters, hasActiveFilters, filterPanelOpen,
|
||||
onStatusChange, onFilterToggle, onFiltersClear, onFilterPanelToggle,
|
||||
}: Props = $props();
|
||||
|
||||
const STATUS_LABELS: Record<LibraryStatusFilter, string> = {
|
||||
ALL: "All statuses", ONGOING: "Ongoing", COMPLETED: "Completed",
|
||||
CANCELLED: "Cancelled", HIATUS: "Hiatus", UNKNOWN: "Unknown",
|
||||
};
|
||||
|
||||
const ALL_STATUS_FILTERS: LibraryStatusFilter[] = [
|
||||
"ALL", "ONGOING", "COMPLETED", "CANCELLED", "HIATUS", "UNKNOWN",
|
||||
];
|
||||
|
||||
const CONTENT_FILTERS: [LibraryContentFilter, string][] = [
|
||||
["unread", "Unread"],
|
||||
["started", "Started"],
|
||||
["downloaded", "Downloaded"],
|
||||
["bookmarked", "Bookmarked"],
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="filter-panel-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:icon-btn-active={hasActiveFilters}
|
||||
title="Filter"
|
||||
onclick={onFilterPanelToggle}
|
||||
>
|
||||
<Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||
</button>
|
||||
|
||||
{#if filterPanelOpen}
|
||||
<div class="dropdown-panel filter-panel" role="menu">
|
||||
<div class="panel-header">
|
||||
<span class="panel-heading">Filter</span>
|
||||
{#if hasActiveFilters}
|
||||
<button class="panel-clear-btn" onclick={onFiltersClear}>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
|
||||
<p class="panel-label">Content</p>
|
||||
{#each CONTENT_FILTERS as [f, label]}
|
||||
<button
|
||||
class="panel-item panel-item-check"
|
||||
class:panel-item-active={tabFilters[f]}
|
||||
role="menuitem"
|
||||
onclick={() => onFilterToggle(f)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
|
||||
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<div class="panel-divider"></div>
|
||||
|
||||
<p class="panel-label">Status</p>
|
||||
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
|
||||
<button
|
||||
class="panel-item panel-item-check"
|
||||
class:panel-item-active={tabStatus === s}
|
||||
role="menuitem"
|
||||
onclick={() => onStatusChange(tabStatus === s ? "ALL" : s)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={tabStatus === s}>
|
||||
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{STATUS_LABELS[s]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filter-panel-wrap { position: relative; }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
||||
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
||||
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||
.panel-clear-btn:hover { color: var(--color-error); }
|
||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
||||
.panel-item { display: flex; align-items: center; justify-content: flex-start; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
||||
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||
.panel-item-active:hover { background: var(--accent-dim); }
|
||||
.panel-item-check { justify-content: flex-start; }
|
||||
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
|
||||
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,219 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { longPress } from "@core/ui/touchscreen";
|
||||
import type { Manga, Category } from "@types";
|
||||
|
||||
interface Props {
|
||||
visibleManga: Manga[];
|
||||
filtered: Manga[];
|
||||
loading: boolean;
|
||||
cols: number;
|
||||
anims: boolean;
|
||||
selectMode: boolean;
|
||||
selectedIds: Set<number>;
|
||||
hasMore: boolean;
|
||||
remainingCount: number;
|
||||
renderLimit: number;
|
||||
cropCovers: boolean;
|
||||
statsAlways: boolean;
|
||||
libraryFilter: string;
|
||||
bulkWorking: boolean;
|
||||
visibleCategories: Category[];
|
||||
onCardClick: (e: MouseEvent, m: Manga) => void;
|
||||
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
||||
onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
|
||||
onLoadMore: () => void;
|
||||
onRetry: () => void;
|
||||
onExitSelectMode: () => void;
|
||||
onSelectAll: () => void;
|
||||
onBulkMove: (cat: Category) => void;
|
||||
onBulkRemove: () => void;
|
||||
onBulkAutomate: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
||||
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
||||
bulkWorking, visibleCategories,
|
||||
onCardClick, onCardContextMenu, onCardLongPress,
|
||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
||||
}: Props = $props();
|
||||
|
||||
let bulkMoveOpen: boolean = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!bulkMoveOpen) return;
|
||||
function onOutside(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest(".bulk-move-wrap")) bulkMoveOpen = false;
|
||||
}
|
||||
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||
return () => document.removeEventListener("mousedown", onOutside, true);
|
||||
});
|
||||
|
||||
$effect(() => { if (!selectMode) bulkMoveOpen = false; });
|
||||
</script>
|
||||
|
||||
{#if selectMode}
|
||||
<div class="select-bar">
|
||||
<span class="sel-count">{selectedIds.size} selected</span>
|
||||
<button class="sel-text-btn" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
|
||||
|
||||
<div class="select-bar-right">
|
||||
{#if visibleCategories.length}
|
||||
<div class="bulk-move-wrap">
|
||||
<button
|
||||
class="sel-action-btn"
|
||||
disabled={selectedIds.size === 0 || bulkWorking}
|
||||
onclick={() => bulkMoveOpen = !bulkMoveOpen}
|
||||
>
|
||||
<Folder size={13} weight="bold" />
|
||||
Move
|
||||
</button>
|
||||
{#if bulkMoveOpen}
|
||||
<div class="bulk-folder-list">
|
||||
{#each visibleCategories as cat}
|
||||
<button class="bulk-folder-item" onclick={() => { onBulkMove(cat); bulkMoveOpen = false; }}>
|
||||
<Folder size={11} weight="bold" />
|
||||
{cat.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button class="sel-action-btn" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkAutomate}>
|
||||
<Robot size={13} weight="bold" />
|
||||
Automate
|
||||
</button>
|
||||
<button class="sel-action-btn sel-action-danger" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
|
||||
<Trash size={13} weight="bold" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content" role="presentation" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
|
||||
{#if loading}
|
||||
<div class="grid">
|
||||
{#each Array(12) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="center">
|
||||
{libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
||||
: libraryFilter === "downloaded" ? "No downloaded manga."
|
||||
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each visibleManga as m (m.id)}
|
||||
{@const isSelected = selectedIds.has(m.id)}
|
||||
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
|
||||
<button
|
||||
class="card"
|
||||
class:card-selected={isSelected}
|
||||
class:select-mode={selectMode}
|
||||
class:anims={anims}
|
||||
use:onCardLongPress={m}
|
||||
onclick={(e) => onCardClick(e, m)}
|
||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||
>
|
||||
<div class="cover-wrap" class:completed={isCompleted}>
|
||||
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||
<div class="overlay-badges">
|
||||
{#if isCompleted}
|
||||
<span class="badge badge-done">✓ Done</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if selectMode}
|
||||
<div class="select-overlay" aria-hidden="true">
|
||||
<div class="select-check" class:checked={isSelected}>
|
||||
{#if isSelected}
|
||||
<CheckSquare size={20} weight="fill" />
|
||||
{:else}
|
||||
<div class="select-check-empty"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="load-more-row">
|
||||
<button class="load-more-btn" onclick={onLoadMore}>
|
||||
Show {Math.min(remainingCount, renderLimit)} more
|
||||
<span class="load-more-count">({remainingCount} remaining)</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||||
.select-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; position: relative; z-index: 10; }
|
||||
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; position: relative; }
|
||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
.sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
||||
.sel-text-btn:hover { color: var(--text-primary); }
|
||||
.sel-action-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.sel-action-danger:hover:not(:disabled) { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent); background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent); }
|
||||
.bulk-move-wrap { position: relative; }
|
||||
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
|
||||
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
||||
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
|
||||
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card.anims:not(.select-mode):hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||
.card:not(.select-mode):hover .title { color: var(--text-primary); }
|
||||
.card.select-mode { cursor: default; }
|
||||
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
|
||||
.card.card-selected .title { color: var(--accent-fg); }
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); will-change: transform; }
|
||||
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
||||
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
||||
.card-info-overlay.instant { transition: none; }
|
||||
.card-info-overlay.always { opacity: 1; }
|
||||
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; }
|
||||
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
||||
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||
.select-overlay { position: absolute; inset: 0; z-index: 3; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
||||
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
||||
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
||||
.select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
|
||||
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
||||
.card.anims .title { transition: color var(--t-base); }
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||
.load-more-row { display: flex; justify-content: center; padding: var(--sp-5) 0 var(--sp-2); position: relative; z-index: 1; }
|
||||
.load-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 8px 20px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.load-more-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
|
||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,257 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||
} from "phosphor-svelte";
|
||||
import LibraryFilters from "./LibraryFilters.svelte";
|
||||
import type { Category } from "@types";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
|
||||
|
||||
interface Props {
|
||||
tab: string;
|
||||
tabSortMode: LibrarySortMode;
|
||||
tabSortDir: LibrarySortDir;
|
||||
tabStatus: LibraryStatusFilter;
|
||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
||||
hasActiveFilters: boolean;
|
||||
anims: boolean;
|
||||
visibleCategories: Category[];
|
||||
visibleTabIds: string[];
|
||||
virtualTabIds: string[];
|
||||
folderTabIds: string[];
|
||||
completedCatId: number | null;
|
||||
counts: Record<string, number>;
|
||||
search: string;
|
||||
activeDragKind: "tab" | null;
|
||||
dragInsertIdx: number;
|
||||
dragTabId: string | null;
|
||||
dragOverTabId: string | null;
|
||||
sortPanelOpen: boolean;
|
||||
filterPanelOpen: boolean;
|
||||
tabsEl: HTMLDivElement;
|
||||
onSearchChange: (v: string) => void;
|
||||
onTabChange: (f: string) => void;
|
||||
onSortChange: (mode: LibrarySortMode) => void;
|
||||
onSortDirToggle: () => void;
|
||||
onStatusChange: (s: LibraryStatusFilter) => void;
|
||||
onFilterToggle: (f: LibraryContentFilter) => void;
|
||||
onFiltersClear: () => void;
|
||||
onSortPanelToggle: () => void;
|
||||
onFilterPanelToggle: () => void;
|
||||
onOpenDownloadsFolder: () => void;
|
||||
onTabDragStart: (e: DragEvent, id: string) => void;
|
||||
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
||||
onTabDragLeave: () => void;
|
||||
onTabDrop: (e: DragEvent, id: string) => void;
|
||||
onTabDragEnd: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
|
||||
counts, search, refreshing, refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
|
||||
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
||||
tabsEl = $bindable(),
|
||||
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
||||
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
||||
onRefresh, onOpenDownloadsFolder,
|
||||
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
||||
}: Props = $props();
|
||||
|
||||
function onTabsWheel(e: WheelEvent) {
|
||||
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
|
||||
const idx = ids.indexOf(tab);
|
||||
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
|
||||
else if (e.deltaY < 0 && idx > 0) onTabChange(ids[idx - 1]);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
tab;
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||
if (!active) return;
|
||||
const pl = tabsEl.scrollLeft;
|
||||
const cw = tabsEl.clientWidth;
|
||||
const ol = active.offsetLeft;
|
||||
const ow = active.offsetWidth;
|
||||
if (ol < pl) tabsEl.scrollTo({ left: ol, behavior: "smooth" });
|
||||
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
|
||||
});
|
||||
|
||||
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
||||
az: "A–Z",
|
||||
unreadCount: "Unread chapters",
|
||||
totalChapters: "Total chapters",
|
||||
recentlyAdded: "Recently added",
|
||||
recentlyRead: "Recently read",
|
||||
latestFetched: "Latest fetched chapter",
|
||||
latestUploaded: "Latest uploaded chapter",
|
||||
};
|
||||
|
||||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
||||
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<span class="heading">Library</span>
|
||||
|
||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl} onwheel={onTabsWheel}>
|
||||
{#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}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={tab === id}
|
||||
class:tab-dragging={isDraggable && dragTabId === id}
|
||||
draggable={isDraggable}
|
||||
onclick={() => onTabChange(id)}
|
||||
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
|
||||
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
|
||||
ondragleave={isDraggable ? onTabDragLeave : undefined}
|
||||
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
|
||||
ondragend={isDraggable ? onTabDragEnd : undefined}
|
||||
>
|
||||
{#if id === "library"}<Books size={11} weight="bold" />
|
||||
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
||||
{:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
|
||||
{:else if cat}<Folder size={11} weight="bold" />
|
||||
{/if}
|
||||
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
|
||||
<span class="tab-count">{counts[id] ?? 0}</span>
|
||||
</button>
|
||||
{#if activeDragKind === "tab" && dragInsertIdx === idx + 1}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
|
||||
{#if refreshing}
|
||||
<button
|
||||
class="icon-btn refresh-btn icon-btn-active"
|
||||
title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
|
||||
onclick={onRefresh}
|
||||
>
|
||||
<ArrowsClockwise size={15} weight="bold" class="anim-spin" />
|
||||
{#if refreshProgress.total > 0}
|
||||
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="icon-btn refresh-btn"
|
||||
class:refresh-btn-done={refreshDone}
|
||||
title={refreshDone ? "Library updated" : "Check for updates"}
|
||||
onclick={onRefresh}
|
||||
>
|
||||
<ArrowsClockwise size={15} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
|
||||
<FolderSimple size={15} weight="bold" />
|
||||
</button>
|
||||
|
||||
<div class="sort-panel-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:icon-btn-active={tabSortMode !== "az" || tabSortDir !== "asc"}
|
||||
title="Sort"
|
||||
onclick={onSortPanelToggle}
|
||||
>
|
||||
<SortAscending size={15} weight="bold" />
|
||||
</button>
|
||||
{#if sortPanelOpen}
|
||||
<div class="dropdown-panel sort-panel anim-fade-in" role="menu">
|
||||
<div class="panel-header">
|
||||
<span class="panel-heading">Sort</span>
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Order by</p>
|
||||
{#each ALL_SORT_MODES as m}
|
||||
<button
|
||||
class="panel-item"
|
||||
class:panel-item-active={tabSortMode === m}
|
||||
role="menuitem"
|
||||
onclick={() => onSortChange(m)}
|
||||
>
|
||||
{SORT_LABELS[m]}
|
||||
{#if tabSortMode === m}
|
||||
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" class="sort-caret" />
|
||||
{:else}<CaretDown size={11} weight="bold" class="sort-caret" />{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="panel-item dir-toggle" role="menuitem" onclick={onSortDirToggle}>
|
||||
{tabSortDir === "asc" ? "Ascending" : "Descending"}
|
||||
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" />
|
||||
{:else}<CaretDown size={11} weight="bold" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<LibraryFilters
|
||||
{tabStatus}
|
||||
{tabFilters}
|
||||
{hasActiveFilters}
|
||||
{filterPanelOpen}
|
||||
{onStatusChange}
|
||||
{onFilterToggle}
|
||||
{onFiltersClear}
|
||||
{onFilterPanelToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||||
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
||||
.refresh-btn:disabled { cursor: default; }
|
||||
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
|
||||
.sort-panel-wrap { position: relative; }
|
||||
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
|
||||
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
||||
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
|
||||
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||
.panel-item-active:hover { background: var(--accent-dim); }
|
||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
||||
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
||||
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
|
||||
:global(.sort-caret) { flex-shrink: 0; }
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user