mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Fix: Discover V2 + Windows Update System (Testing)
This commit is contained in:
@@ -136,6 +136,8 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
|
||||
@@ -145,3 +147,10 @@ jobs:
|
||||
name: moku-windows-x64
|
||||
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload latest.json (updater endpoint)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-windows-latest-json
|
||||
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/latest.json
|
||||
retention-days: 7
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-FsZTHeBS9qQ9KYgiwDX1vam6uJXK8OjLe5U6Jfu33lc=";
|
||||
hash = "sha256-G82kmXm1prRpU9kqUyFHSABVt1fikMzvz78+w/gFKvQ=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"clsx": "^2.1.1",
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
|
||||
Generated
+10
@@ -11,6 +11,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.0.0
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: ^2.3.5
|
||||
version: 2.3.5
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -433,6 +436,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -1026,6 +1032,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/pug@2.0.10': {}
|
||||
|
||||
Generated
+787
-6
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,10 @@ tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-inner-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor"
|
||||
"core:window:allow-scale-factor",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,26 @@ pub enum SpawnError {
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
// ── Update types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single GitHub release returned to the frontend.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
/// Progress event emitted during download — matches what the frontend listens for.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct UpdateProgress {
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
|
||||
/// Java and many other tools do not accept this prefix and will fail silently.
|
||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
@@ -415,16 +435,113 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Update commands ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
|
||||
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
|
||||
#[tauri::command]
|
||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API returned {}", resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GhRelease {
|
||||
tag_name: String,
|
||||
name: Option<String>,
|
||||
body: Option<String>,
|
||||
published_at: Option<String>,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(releases
|
||||
.into_iter()
|
||||
.map(|r| ReleaseInfo {
|
||||
tag_name: r.tag_name.clone(),
|
||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||
body: r.body.unwrap_or_default(),
|
||||
published_at: r.published_at.unwrap_or_default(),
|
||||
html_url: r.html_url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Download and install the latest update using tauri-plugin-updater.
|
||||
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
|
||||
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
|
||||
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
|
||||
#[tauri::command]
|
||||
#[allow(unused_variables)]
|
||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
let updater = app.updater().map_err(|e| e.to_string())?;
|
||||
let update = updater.check().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(update) = update else {
|
||||
return Err("No update available from the updater endpoint.".into());
|
||||
};
|
||||
|
||||
let app_clone = app.clone();
|
||||
update
|
||||
.download_and_install(
|
||||
move |downloaded, total| {
|
||||
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded, total });
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the app after a successful update install.
|
||||
#[tauri::command]
|
||||
fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
// ── App entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_storage_info,
|
||||
spawn_server,
|
||||
kill_server,
|
||||
get_platform_ui_scale,
|
||||
list_releases,
|
||||
download_and_install_update,
|
||||
restart_app,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "RWR87cX5fjRNNiuoLs057dMl5bKeHjP3yrbTvGixYDTchASwR8Bsp1Wt",
|
||||
"endpoints": [
|
||||
"https://github.com/Youwes09/Moku/releases/download/v__VERSION__/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": [
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/jre/**/*"
|
||||
|
||||
+38
-1
@@ -2,9 +2,10 @@
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { gql } from "./lib/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import { store, addToast, setActiveDownloads } from "./store/state.svelte";
|
||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import Layout from "./components/layout/Layout.svelte";
|
||||
import Reader from "./components/reader/Reader.svelte";
|
||||
@@ -92,6 +93,33 @@
|
||||
return () => clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
// ── Auto-update check (runs once after app is ready) ─────────────────────────
|
||||
//
|
||||
// Fetches the GitHub releases list via the Rust command and compares the latest
|
||||
// tag against the installed version. On mismatch, shows a single non-blocking
|
||||
// info toast. No modal, no blocking UI.
|
||||
async function checkForUpdateSilently() {
|
||||
try {
|
||||
const [currentVersion, releases] = await Promise.all([
|
||||
getVersion(),
|
||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
||||
]);
|
||||
if (!releases.length) return;
|
||||
|
||||
const latestTag = releases[0].tag_name.replace(/^v/, "");
|
||||
if (latestTag !== currentVersion) {
|
||||
addToast({
|
||||
kind: "info",
|
||||
title: `Update available — v${latestTag}`,
|
||||
body: "Open Settings → About to install.",
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — no network, private repo rate-limit, etc.
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||
@@ -142,6 +170,15 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Run the update check once, 5 seconds after the app finishes loading.
|
||||
// The delay avoids adding to startup latency and ensures list_releases
|
||||
// doesn't compete with the server probe.
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
function handleRetry() { failed = false; notConfigured = false; serverProbeOk = false; }
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
||||
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
|
||||
import { store, addFolder, assignMangaToFolder, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||
const GRID_LIMIT = 100;
|
||||
const LOCAL_THRESHOLD = 20;
|
||||
const CONCURRENCY = 4;
|
||||
const BATCH_MS = 400;
|
||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||
const GRID_LIMIT = 200;
|
||||
const CONCURRENCY = 6;
|
||||
const PAGES_INIT = 3; // pages per source on All tab
|
||||
const PAGES_GENRE = 2; // pages per source on genre tabs
|
||||
|
||||
const EXPLORE_ALL_MANGA = `
|
||||
query ExploreAllManga {
|
||||
@@ -33,28 +33,20 @@
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Dedicated discover cache ───────────────────────────────────────────────
|
||||
// Completely isolated from main app cache — refresh only wipes this,
|
||||
// leaving library/chapter/source caches untouched.
|
||||
const discoverStore = new Map<string, Manga[]>();
|
||||
function dKey(srcId: string, type: string, tag: string) { return `${srcId}|${type}|${tag}`; }
|
||||
function clearDiscover() { discoverStore.clear(); }
|
||||
function dKey(srcId: string, type: string, genre: string, page: number) {
|
||||
return `${srcId}|${type}|${genre}:p${page}`;
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let allManga: Manga[] = $state([]);
|
||||
let allSources: Source[] = $state([]);
|
||||
let libraryIds: Set<number> = $state(new Set());
|
||||
let loadingLib = $state(true);
|
||||
let loadError = $state(false);
|
||||
let currentGenre = $state("All");
|
||||
let genreResults = $state(new Map<string, Manga[]>());
|
||||
let genreLoading = $state(false);
|
||||
let srcOffset = $state(0);
|
||||
// ── Local component state ─────────────────────────────────────────────────
|
||||
let allSources: Source[] = $state([]);
|
||||
let loadingLib = $state(true);
|
||||
let loadError = $state(false);
|
||||
let currentGenre = $state("All");
|
||||
let genreResults = $state(new Map<string, Manga[]>());
|
||||
let genreLoading = $state(false);
|
||||
let refreshing = $state(false);
|
||||
|
||||
let activeCtrl: AbortController | null = null;
|
||||
let batchTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let batchAccum = new Map<string, Manga[]>();
|
||||
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
|
||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||
@@ -65,15 +57,15 @@
|
||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
||||
}
|
||||
|
||||
function filterSource(mangas: Manga[]): Manga[] {
|
||||
return dedup(mangas.filter(m => !m.inLibrary && !libraryIds.has(m.id)));
|
||||
function filterOut(mangas: Manga[]): Manga[] {
|
||||
return dedup(mangas.filter(m => !m.inLibrary && !store.discoverLibraryIds.has(m.id)));
|
||||
}
|
||||
|
||||
function rotatedSources(): Source[] {
|
||||
const lang = store.settings.preferredExtensionLang || "en";
|
||||
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
|
||||
if (!srcs.length) return [];
|
||||
const off = srcOffset % srcs.length;
|
||||
const off = store.discoverSrcOffset % srcs.length;
|
||||
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
||||
}
|
||||
|
||||
@@ -88,80 +80,64 @@
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── Batch flush ───────────────────────────────────────────────────────────
|
||||
function startBatch() {
|
||||
if (batchTimer) return;
|
||||
batchTimer = setInterval(() => {
|
||||
if (!batchAccum.size) return;
|
||||
for (const [genre, incoming] of batchAccum) {
|
||||
const cur = genreResults.get(genre) ?? [];
|
||||
genreResults.set(genre, dedup([...cur, ...incoming]).slice(0, GRID_LIMIT));
|
||||
}
|
||||
batchAccum.clear();
|
||||
genreResults = new Map(genreResults);
|
||||
}, BATCH_MS);
|
||||
}
|
||||
|
||||
function flushBatch() {
|
||||
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
|
||||
if (!batchAccum.size) return;
|
||||
for (const [genre, incoming] of batchAccum) {
|
||||
const cur = genreResults.get(genre) ?? [];
|
||||
genreResults.set(genre, dedup([...cur, ...incoming]).slice(0, GRID_LIMIT));
|
||||
}
|
||||
batchAccum.clear();
|
||||
genreResults = new Map(genreResults);
|
||||
}
|
||||
|
||||
function accumulate(genre: string, mangas: Manga[]) {
|
||||
const filtered = filterSource(mangas);
|
||||
// Push results into the reactive grid immediately — no batch delay.
|
||||
function pushToGrid(genre: string, incoming: Manga[]) {
|
||||
const filtered = filterOut(incoming);
|
||||
if (!filtered.length) return;
|
||||
const existing = batchAccum.get(genre) ?? [];
|
||||
batchAccum.set(genre, [...existing, ...filtered]);
|
||||
const cur = genreResults.get(genre) ?? [];
|
||||
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
|
||||
genreResults = new Map(genreResults);
|
||||
}
|
||||
|
||||
// ── Source fan-out ────────────────────────────────────────────────────────
|
||||
async function fanOut(genre: string, ctrl: AbortController) {
|
||||
const srcs = rotatedSources();
|
||||
const srcs = rotatedSources();
|
||||
if (!srcs.length) return;
|
||||
|
||||
const isAll = genre === "All";
|
||||
const type = isAll ? "POPULAR" : "SEARCH";
|
||||
const query = isAll ? null : genre;
|
||||
|
||||
startBatch();
|
||||
const isAll = genre === "All";
|
||||
const type = isAll ? "POPULAR" : "SEARCH";
|
||||
const query = isAll ? null : genre;
|
||||
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
|
||||
|
||||
await runConcurrent(srcs, async src => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const key = dKey(src.id, type, genre);
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
let mangas: Manga[];
|
||||
if (discoverStore.has(key)) {
|
||||
mangas = discoverStore.get(key)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type, page: 1, query },
|
||||
ctrl.signal
|
||||
).then(d => d.fetchSourceManga).catch(() => null);
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
mangas = result.mangas;
|
||||
discoverStore.set(key, mangas);
|
||||
}
|
||||
const key = dKey(src.id, type, genre, page);
|
||||
let mangas: Manga[];
|
||||
let hasNextPage = false;
|
||||
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (store.discoverCache.has(key)) {
|
||||
// Cache hit — no network call needed
|
||||
mangas = store.discoverCache.get(key)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type, page, query },
|
||||
ctrl.signal
|
||||
).then(d => d.fetchSourceManga).catch(() => null);
|
||||
|
||||
if (isAll) {
|
||||
accumulate("All", mangas);
|
||||
} else {
|
||||
const matching = mangas.filter(m =>
|
||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
||||
);
|
||||
accumulate(genre, matching.length ? matching : mangas);
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
mangas = result.mangas;
|
||||
hasNextPage = result.hasNextPage;
|
||||
store.discoverCache.set(key, mangas);
|
||||
}
|
||||
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
if (isAll) {
|
||||
pushToGrid("All", mangas);
|
||||
} else {
|
||||
const matching = mangas.filter(m =>
|
||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
||||
);
|
||||
pushToGrid(genre, matching.length ? matching : mangas);
|
||||
}
|
||||
|
||||
// Stop paging early if source is exhausted
|
||||
if (!hasNextPage) return;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) flushBatch();
|
||||
}
|
||||
|
||||
// ── Tab switch ────────────────────────────────────────────────────────────
|
||||
@@ -169,13 +145,18 @@
|
||||
if (currentGenre === genre) return;
|
||||
|
||||
activeCtrl?.abort();
|
||||
flushBatch();
|
||||
currentGenre = genre;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
activeCtrl = ctrl;
|
||||
|
||||
if (genre === "All") {
|
||||
// Already have results from this session — show instantly, re-fan in background
|
||||
if ((genreResults.get("All") ?? []).length > 0) {
|
||||
genreLoading = false;
|
||||
fanOut("All", ctrl).catch(() => {});
|
||||
return;
|
||||
}
|
||||
genreResults.set("All", []);
|
||||
genreResults = new Map(genreResults);
|
||||
genreLoading = true;
|
||||
@@ -184,18 +165,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Genre tab: check local cache first, always fan out to sources too
|
||||
// Genre tab: serve cached local results instantly, always fan out too
|
||||
const localKey = `local|${genre}`;
|
||||
if (discoverStore.has(localKey)) {
|
||||
// Serve cached local results immediately
|
||||
genreResults.set(genre, discoverStore.get(localKey)!);
|
||||
if (store.discoverCache.has(localKey)) {
|
||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
||||
genreResults = new Map(genreResults);
|
||||
// Always fan out in background to get source results too
|
||||
fanOut(genre, ctrl).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch local library results then fan out
|
||||
genreLoading = true;
|
||||
try {
|
||||
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
||||
@@ -204,12 +182,11 @@
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const local = dedup(d.mangas.nodes);
|
||||
discoverStore.set(localKey, local);
|
||||
store.discoverCache.set(localKey, local);
|
||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
||||
genreResults = new Map(genreResults);
|
||||
genreLoading = false;
|
||||
|
||||
// Always fan out — show source results alongside library results
|
||||
fanOut(genre, ctrl).catch(() => {});
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
@@ -218,13 +195,9 @@
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────
|
||||
let refreshing = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
activeCtrl?.abort();
|
||||
flushBatch();
|
||||
clearDiscover();
|
||||
srcOffset++;
|
||||
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
|
||||
genreResults = new Map();
|
||||
refreshing = true;
|
||||
genreLoading = true;
|
||||
@@ -240,23 +213,29 @@
|
||||
loadingLib = true;
|
||||
loadError = false;
|
||||
|
||||
// Load library for filtering — don't show stuff already in library
|
||||
// Already have a session grid — show it immediately
|
||||
if ((genreResults.get("All") ?? []).length > 0) {
|
||||
loadingLib = false;
|
||||
}
|
||||
|
||||
// Refresh library ID set so newly-added manga get filtered out
|
||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||
).then(m => {
|
||||
allManga = dedupeMangaById(m);
|
||||
libraryIds = new Set(allManga.filter(x => x.inLibrary).map(x => x.id));
|
||||
store.discoverLibraryIds = new Set(
|
||||
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
|
||||
);
|
||||
}).catch(e => { console.error(e); loadError = true; })
|
||||
.finally(() => { loadingLib = false; });
|
||||
|
||||
// Load sources then kick off initial All tab fan-out
|
||||
// Load sources then kick off All tab fan-out (only if grid is empty)
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then(d => {
|
||||
allSources = d.sources.nodes;
|
||||
// Only trigger if still on All tab
|
||||
if (currentGenre === "All" || currentGenre === "") {
|
||||
const ctrl = new AbortController();
|
||||
activeCtrl = ctrl;
|
||||
if ((currentGenre === "All" || currentGenre === "") &&
|
||||
(genreResults.get("All") ?? []).length === 0) {
|
||||
const ctrl = new AbortController();
|
||||
activeCtrl = ctrl;
|
||||
genreLoading = true;
|
||||
fanOut("All", ctrl).then(() => {
|
||||
if (!ctrl.signal.aborted) genreLoading = false;
|
||||
@@ -266,7 +245,7 @@
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
onDestroy(() => { activeCtrl?.abort(); flushBatch(); });
|
||||
onDestroy(() => { activeCtrl?.abort(); });
|
||||
|
||||
loadAll();
|
||||
|
||||
@@ -284,7 +263,7 @@
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
libraryIds = new Set([...libraryIds, m.id]);
|
||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
||||
}).catch(console.error),
|
||||
},
|
||||
...(store.settings.folders.length > 0 ? [
|
||||
@@ -332,7 +311,7 @@
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
{#if isLoading}
|
||||
{#if isLoading && visibleGrid.length === 0}
|
||||
<div class="manga-grid">
|
||||
{#each Array(24) as _, i (i)}
|
||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
<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()} autofocus />
|
||||
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
|
||||
@@ -328,7 +328,6 @@
|
||||
.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); }
|
||||
.update-badge { font-family: var(--font-ui); font-size: var(--text-2xs); 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: 2px 6px; flex-shrink: 0; }
|
||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.row-actions { display: flex; 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); }
|
||||
@@ -346,3 +345,7 @@
|
||||
.variant-actions { flex-shrink: 0; }
|
||||
.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); }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let sourceManga: Manga[] = $state([]);
|
||||
let loadingInitial = true;
|
||||
let loadingMore = false;
|
||||
let visibleCount = PAGE_SIZE;
|
||||
let loadingInitial = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
|
||||
const nextPageMap = new Map<string, number>();
|
||||
|
||||
@@ -428,7 +428,9 @@
|
||||
</div>
|
||||
|
||||
{#if pickerOpen}
|
||||
<div class="picker-backdrop" onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}>
|
||||
<div class="picker-backdrop" role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
|
||||
onkeydown={(e) => { if (e.key === "Escape") closePicker(); }}>
|
||||
<div class="picker-modal">
|
||||
<div class="picker-header">
|
||||
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
@@ -36,8 +37,7 @@
|
||||
let sources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let selectedSource: Source | null = $state(null);
|
||||
const _initialTitle = manga.title;
|
||||
let query = $state(_initialTitle);
|
||||
let query = $state(untrack(() => manga.title));
|
||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let selectedMatch: Match | null = $state(null);
|
||||
@@ -220,9 +220,9 @@
|
||||
<div class="search-row">
|
||||
<div class="search-bar">
|
||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||
<input class="search-input" bind:value={query}
|
||||
<input class="search-input" bind:value={query}
|
||||
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…" autofocus />
|
||||
placeholder="Search title…" use:focusOnMount />
|
||||
</div>
|
||||
<button class="search-btn"
|
||||
onclick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
@@ -471,3 +471,7 @@
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
@@ -509,7 +509,7 @@
|
||||
<input
|
||||
bind:this={kw_inputEl}
|
||||
bind:value={kw_query}
|
||||
autofocus
|
||||
use:focusOnMount
|
||||
class="searchInput"
|
||||
placeholder="Search across sources…"
|
||||
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
|
||||
@@ -1822,15 +1822,6 @@
|
||||
|
||||
/* ── Source tab: lang filter + browse bar ──────────────────────────────── */
|
||||
|
||||
.langFilterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sourceBrowseBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1930,3 +1921,7 @@
|
||||
}
|
||||
.splitItemActive .langOptionDot { background: var(--accent-fg); }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
@@ -433,7 +433,7 @@
|
||||
{#if folderCreating}
|
||||
<div class="fp-create">
|
||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
|
||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
||||
<X size={12} weight="light" />
|
||||
@@ -452,7 +452,7 @@
|
||||
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
|
||||
{:else}
|
||||
<div class="jump-row">
|
||||
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
|
||||
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} use:focusOnMount
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") { jumpOpen = false; return; }
|
||||
if (e.key === "Enter") {
|
||||
@@ -501,7 +501,7 @@
|
||||
{:else}
|
||||
<div class="dl-range-row">
|
||||
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
|
||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focusOnMount />
|
||||
<span class="dl-range-sep">–</span>
|
||||
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
||||
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
||||
@@ -752,3 +752,7 @@
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; startGlobalIdx: number; }
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let sentinelEl: HTMLDivElement;
|
||||
let sentinelEl: HTMLDivElement = $state() as HTMLDivElement;
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { tick } from "svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||
import { gql } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
|
||||
@@ -102,7 +104,6 @@
|
||||
}
|
||||
|
||||
// ── Performance metrics ───────────────────────────────────────────────────────
|
||||
// Pulled from the session cache on demand — lightweight, no extra fetches.
|
||||
interface PerfSnapshot {
|
||||
cacheEntries: number;
|
||||
cacheKeys: string[];
|
||||
@@ -113,16 +114,12 @@
|
||||
let perfSnapshot: PerfSnapshot | null = $state(null);
|
||||
|
||||
function refreshPerfMetrics() {
|
||||
// cache.list() isn't exported, but we can probe known keys to build a snapshot
|
||||
const knownPrefixes = ["library", "sources", "popular", "genre:", "manga:", "chapters:", "page:", "pages:"];
|
||||
let entries = 0;
|
||||
let oldest: number | null = null;
|
||||
let newest: number | null = null;
|
||||
const foundKeys: string[] = [];
|
||||
|
||||
// We walk the cache via ageOf — non-zero means the key exists
|
||||
// For a real count we introspect via a set of likely keys
|
||||
// (The cache module doesn't expose an iterator, so we sample)
|
||||
const checkKey = (k: string) => {
|
||||
const age = cache.ageOf(k);
|
||||
if (age !== undefined) {
|
||||
@@ -197,32 +194,123 @@
|
||||
|
||||
let splashTriggered = $state(false);
|
||||
|
||||
let appVersion = $state("…");
|
||||
let latestVersion = $state<string | null>(null);
|
||||
let checkingUpdate = $state(false);
|
||||
let updateError = $state<string | null>(null);
|
||||
// ── About / Updater state ─────────────────────────────────────────────────────
|
||||
|
||||
interface ReleaseInfo {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
type UpdatePhase =
|
||||
| "idle"
|
||||
| "downloading"
|
||||
| "ready" // downloaded, awaiting restart
|
||||
| "error";
|
||||
|
||||
const IS_WINDOWS = navigator.userAgent.includes("Windows");
|
||||
|
||||
let appVersion = $state("…");
|
||||
let releases = $state<ReleaseInfo[]>([]);
|
||||
let releasesLoading = $state(false);
|
||||
let releasesError = $state<string | null>(null);
|
||||
let expandedTag = $state<string | null>(null);
|
||||
|
||||
// update install state
|
||||
let updatePhase = $state<UpdatePhase>("idle");
|
||||
let updateError = $state<string | null>(null);
|
||||
let dlBytes = $state(0);
|
||||
let dlTotal = $state<number | null>(null);
|
||||
let targetTag = $state<string | null>(null); // tag being installed
|
||||
|
||||
$effect(() => {
|
||||
if (tab === "about") {
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
||||
}
|
||||
if (tab !== "about") return;
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
||||
if (releases.length === 0 && !releasesLoading) loadReleases();
|
||||
});
|
||||
|
||||
async function checkForUpdate() {
|
||||
checkingUpdate = true; updateError = null; latestVersion = null;
|
||||
async function loadReleases() {
|
||||
releasesLoading = true; releasesError = null;
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/repos/Youwes09/Moku/releases/latest", {
|
||||
method: "GET",
|
||||
headers: { "User-Agent": "Moku" },
|
||||
});
|
||||
const data = await res.json() as { tag_name: string };
|
||||
latestVersion = data.tag_name.replace(/^v/, "");
|
||||
} catch (e) {
|
||||
updateError = "Could not reach GitHub";
|
||||
releases = await invoke<ReleaseInfo[]>("list_releases");
|
||||
} catch (e: any) {
|
||||
releasesError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
checkingUpdate = false;
|
||||
releasesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalise "v0.4.0" → "0.4.0" for comparison
|
||||
function stripV(v: string) { return v.replace(/^v/, ""); }
|
||||
|
||||
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// ── Download & install ────────────────────────────────────────────────────────
|
||||
|
||||
// Listen to progress events from Rust while this component is alive.
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
|
||||
$effect(() => {
|
||||
listen<{ downloaded: number; total: number | null }>("update-progress", (e) => {
|
||||
dlBytes = e.payload.downloaded;
|
||||
dlTotal = e.payload.total ?? null;
|
||||
}).then(fn => { unlistenProgress = fn; });
|
||||
return () => unlistenProgress?.();
|
||||
});
|
||||
|
||||
async function installUpdate(release: ReleaseInfo) {
|
||||
if (updatePhase === "downloading") return;
|
||||
|
||||
targetTag = release.tag_name;
|
||||
updatePhase = "downloading";
|
||||
updateError = null;
|
||||
dlBytes = 0;
|
||||
dlTotal = null;
|
||||
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
// Windows: Tauri updater downloads + runs passive NSIS installer
|
||||
await invoke("download_and_install_update");
|
||||
updatePhase = "ready";
|
||||
} else {
|
||||
// Linux / macOS: open GitHub release page
|
||||
await openUrl(release.html_url);
|
||||
updatePhase = "idle";
|
||||
targetTag = null;
|
||||
}
|
||||
} catch (e: any) {
|
||||
updateError = e instanceof Error ? e.message : String(e);
|
||||
updatePhase = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async function restartNow() {
|
||||
await invoke("restart_app");
|
||||
}
|
||||
|
||||
function cancelUpdate() {
|
||||
updatePhase = "idle";
|
||||
updateError = null;
|
||||
targetTag = null;
|
||||
dlBytes = 0;
|
||||
dlTotal = null;
|
||||
}
|
||||
|
||||
function fmtProgress(): string {
|
||||
if (dlTotal) {
|
||||
return `${fmtBytes(dlBytes)} / ${fmtBytes(dlTotal)} (${Math.round((dlBytes / dlTotal) * 100)}%)`;
|
||||
}
|
||||
return fmtBytes(dlBytes);
|
||||
}
|
||||
|
||||
function triggerSplash() {
|
||||
splashTriggered = true;
|
||||
setTimeout(() => splashTriggered = false, 200);
|
||||
@@ -716,6 +804,8 @@
|
||||
|
||||
{:else if tab === "about"}
|
||||
<div class="panel">
|
||||
|
||||
<!-- ── App identity ──────────────────────────────────────── -->
|
||||
<div class="section">
|
||||
<p class="section-title">Moku</p>
|
||||
<div class="about-block">
|
||||
@@ -723,40 +813,132 @@
|
||||
<p class="about-line" style="color:var(--text-faint);margin-top:var(--sp-2)">Built with Tauri + Svelte.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Current version + in-progress update bar ──────────── -->
|
||||
<div class="section">
|
||||
<p class="section-title">Version</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">Current version</span>
|
||||
<span class="toggle-label">Installed</span>
|
||||
<span class="toggle-desc">v{appVersion}</span>
|
||||
</div>
|
||||
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
|
||||
onclick={checkForUpdate} disabled={checkingUpdate}>
|
||||
{checkingUpdate ? "Checking…" : "Check for updates"}
|
||||
onclick={loadReleases} disabled={releasesLoading}>
|
||||
{releasesLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
{#if updateError}
|
||||
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:var(--color-error);padding:0 var(--sp-3) var(--sp-2)">{updateError}</p>
|
||||
{:else if latestVersion !== null}
|
||||
{#if latestVersion === appVersion}
|
||||
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:#22c55e;padding:0 var(--sp-3) var(--sp-2);letter-spacing:var(--tracking-wide)">✓ You are on the latest version</p>
|
||||
{:else}
|
||||
<div style="padding:0 var(--sp-3) var(--sp-2);display:flex;flex-direction:column;gap:var(--sp-1)">
|
||||
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:#fb923c;letter-spacing:var(--tracking-wide)">Update available — v{latestVersion}</p>
|
||||
<a href="https://github.com/Youwes09/Moku/releases/latest" target="_blank"
|
||||
style="font-family:var(--font-ui);font-size:var(--text-xs);color:var(--accent-fg);letter-spacing:var(--tracking-wide);text-decoration:none">
|
||||
Download on GitHub →
|
||||
</a>
|
||||
|
||||
<!-- active download progress -->
|
||||
{#if updatePhase === "downloading" && IS_WINDOWS}
|
||||
<div class="update-progress-wrap">
|
||||
<div class="update-progress-bar">
|
||||
<div class="update-progress-fill"
|
||||
style="width:{dlTotal ? Math.round((dlBytes / dlTotal) * 100) : 0}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="update-progress-row">
|
||||
<span class="update-progress-label">Downloading {targetTag ?? "update"}…</span>
|
||||
<span class="update-progress-val">{fmtProgress()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ready to restart -->
|
||||
{#if updatePhase === "ready"}
|
||||
<div class="update-ready-row">
|
||||
<span class="update-ready-label">
|
||||
{targetTag} downloaded — restart to finish installing.
|
||||
</span>
|
||||
<button class="update-action-btn primary" onclick={restartNow}>Restart now</button>
|
||||
<button class="kb-reset" onclick={cancelUpdate} title="Dismiss">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- error -->
|
||||
{#if updatePhase === "error"}
|
||||
<div class="update-error-row">
|
||||
<span style="color:var(--color-error);font-family:var(--font-ui);font-size:var(--text-xs)">{updateError}</span>
|
||||
<button class="kb-reset" onclick={cancelUpdate}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Release list ───────────────────────────────────────── -->
|
||||
<div class="section">
|
||||
<p class="section-title">Releases</p>
|
||||
|
||||
{#if releasesError}
|
||||
<p class="storage-loading" style="color:var(--color-error)">{releasesError}</p>
|
||||
{:else if releasesLoading}
|
||||
<p class="storage-loading">Fetching releases…</p>
|
||||
{:else if releases.length === 0}
|
||||
<p class="storage-loading">No releases found.</p>
|
||||
{:else}
|
||||
<div class="release-list">
|
||||
{#each releases as release}
|
||||
{@const isCurrent = isCurrentVersion(release.tag_name)}
|
||||
{@const isExpanded = expandedTag === release.tag_name}
|
||||
{@const isTarget = targetTag === release.tag_name}
|
||||
{@const isInstalling = isTarget && updatePhase === "downloading"}
|
||||
|
||||
<div class="release-row" class:current={isCurrent}>
|
||||
<!-- header: tag + date + action -->
|
||||
<div class="release-header">
|
||||
<div class="release-meta">
|
||||
<span class="release-tag">{release.tag_name}</span>
|
||||
{#if isCurrent}
|
||||
<span class="release-badge current-badge">installed</span>
|
||||
{/if}
|
||||
{#if release.published_at}
|
||||
<span class="release-date">{fmtDate(release.published_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="release-actions">
|
||||
<!-- changelog toggle -->
|
||||
{#if release.body.trim()}
|
||||
<button class="release-changelog-btn"
|
||||
onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
|
||||
{isExpanded ? "Hide" : "Changelog"}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- install / open -->
|
||||
{#if !isCurrent}
|
||||
{#if IS_WINDOWS}
|
||||
<button class="update-action-btn"
|
||||
class:primary={!isInstalling}
|
||||
disabled={updatePhase === "downloading"}
|
||||
onclick={() => installUpdate(release)}>
|
||||
{isInstalling ? "Downloading…" : "Install"}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="update-action-btn"
|
||||
onclick={() => installUpdate(release)}>
|
||||
Open on GitHub
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- expandable changelog -->
|
||||
{#if isExpanded && release.body.trim()}
|
||||
<div class="release-body">
|
||||
<pre class="release-body-pre">{release.body.trim()}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Links ─────────────────────────────────────────────── -->
|
||||
<div class="section">
|
||||
<p class="section-title">Links</p>
|
||||
<div class="about-block">
|
||||
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -943,4 +1125,80 @@
|
||||
.storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.storage-limit-input:focus { border-color: var(--border-strong); }
|
||||
.storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
|
||||
/* ── Release list ─────────────────────────────────────────────── */
|
||||
.release-list { display: flex; flex-direction: column; gap: 1px; padding: 0 var(--sp-1); }
|
||||
|
||||
.release-row {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
.release-row:hover { background: var(--bg-raised); }
|
||||
.release-row.current {
|
||||
border-color: var(--accent-dim);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.release-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-2) var(--sp-3); gap: var(--sp-3);
|
||||
}
|
||||
.release-meta { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }
|
||||
.release-tag { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.release-badge { font-family: var(--font-ui); font-size: 10px; padding: 1px 5px; border-radius: var(--radius-sm); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.current-badge { background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); }
|
||||
.release-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
|
||||
.release-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
|
||||
.release-changelog-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 2px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.release-changelog-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.update-action-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: none; color: var(--text-muted);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.update-action-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.update-action-btn.primary { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.update-action-btn.primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.update-action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.release-body { padding: 0 var(--sp-3) var(--sp-3); }
|
||||
.release-body-pre {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
|
||||
white-space: pre-wrap; word-break: break-word; margin: 0;
|
||||
max-height: 220px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Download progress bar ────────────────────────────────────── */
|
||||
.update-progress-wrap { padding: var(--sp-1) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.update-progress-bar { height: 4px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.update-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.update-progress-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.update-progress-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.update-progress-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
/* ── Ready-to-restart bar ─────────────────────────────────────── */
|
||||
.update-ready-row {
|
||||
display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--accent-muted); border-radius: var(--radius-md);
|
||||
margin: 0 var(--sp-3) var(--sp-2);
|
||||
}
|
||||
.update-ready-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); flex: 1; }
|
||||
.update-error-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||
</style>
|
||||
|
||||
@@ -247,6 +247,13 @@ class Store {
|
||||
activeChapter: Chapter | null = $state(null);
|
||||
activeChapterList: Chapter[] = $state([]);
|
||||
|
||||
// ── Discover session cache ────────────────────────────────────────────────
|
||||
// Survives navigation within a session but is never persisted to localStorage.
|
||||
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
|
||||
discoverCache: Map<string, Manga[]> = $state(new Map());
|
||||
discoverLibraryIds: Set<number> = $state(new Set());
|
||||
discoverSrcOffset: number = $state(0);
|
||||
|
||||
constructor() {
|
||||
$effect.root(() => {
|
||||
$effect(() => { persist({ storeVersion: STORE_VERSION }); });
|
||||
@@ -431,6 +438,12 @@ class Store {
|
||||
getMangaFolders(mangaId: number): Folder[] {
|
||||
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
||||
}
|
||||
|
||||
clearDiscoverCache() {
|
||||
this.discoverCache = new Map();
|
||||
this.discoverLibraryIds = new Set();
|
||||
this.discoverSrcOffset++;
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new Store();
|
||||
@@ -474,3 +487,4 @@ export function toggleFolderTab(id: string) { store
|
||||
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
|
||||
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
|
||||
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
|
||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||
|
||||
Reference in New Issue
Block a user