Compare commits

..

61 Commits

Author SHA1 Message Date
Youwes09 c025336a7e Fix: Download Notifications + Control & ContextMenu Icons (#38) 2026-04-21 11:41:02 -05:00
Youwes09 86c78689df Feat: Extension of Download Features, Batch Select, Error/Retry (#38) 2026-04-21 10:57:29 -05:00
Youwes09 2d3a4d0e57 Feat: History Page Revamp 2026-04-20 23:43:57 -05:00
Youwes09 1a5c63a607 Fix: QOL Animation on Extensions 2026-04-20 21:44:30 -05:00
Youwes09 3f7102556b Fix: Attempt at fixing Library Refresh 2026-04-20 21:29:53 -05:00
Youwes09 f5a66ab5d1 Fix: Local Source & QOL Animations 2026-04-20 20:59:42 -05:00
Youwes09 e41e8011be Fix: Dropdown in Settings 2026-04-20 11:35:41 -05:00
Youwes09 044c93a790 Fix: Zoom Values turning to NaN in Reader 2026-04-20 10:59:49 -05:00
Shozikan e49df4501f Chore: Update copyright year and owner in LICENSE file 2026-04-20 08:47:48 -05:00
Youwes09 4b97f4a6c9 Feat: Reworked ENTIRE Project for Readability 2026-04-20 00:19:22 -05:00
Youwes09 005680394e Feat: Re-did Layout & Sidebar 2026-04-17 20:43:18 -05:00
Youwes09 ecb4748414 Fix: Attempted to Patch Filesystem Issues (#32) 2026-04-16 23:14:39 -05:00
Youwes09 f0dc3446b2 Feat: QOL Animations P1 2026-04-16 22:40:22 -05:00
Youwes09 8507c34b21 Fix: MacOS Directory Scan (Patch 1) 2026-04-16 13:05:33 -05:00
Youwes09 78da5915df Fix: Optimizations for Reader 2026-04-16 11:12:08 -05:00
Youwes09 c0c486a53e Feat: Double-Tap for Reader Bar (#29) 2026-04-16 00:29:46 -05:00
Youwes09 236d6bcf08 Chore: Description for Notifications on ChapterRefresh 2026-04-16 00:19:36 -05:00
Youwes09 2b140ae022 Feat: Notifications on Source Install/Uninstall (#31) 2026-04-16 00:13:29 -05:00
Youwes09 38d407092f Chore: Descriptions for Settings 2026-04-16 00:06:04 -05:00
Shozikan 12191dfcdf Merge pull request #35 from kannachi323/dev
fix(ui): slow thumbnail loading
2026-04-15 23:53:58 -05:00
Youwes09 13a2f9ecb7 Chore: Flathub Support (Software-Level Fixed) 2026-04-15 23:53:24 -05:00
Youwes09 c573c54318 Chore: Flathub Support (Tinker V2) 2026-04-15 23:20:49 -05:00
Youwes09 ff5fcc4fc0 Chore: Flathub Support (Tinkering Around) 2026-04-15 18:24:46 -05:00
Youwes09 1aad4a1ff0 Fix: Removed Show-More for Preferential Loading using PR Methods 2026-04-15 17:30:14 -05:00
Matthew Chen 68a9331b6f fix(ui): slow thumbnail loading 2026-04-15 01:58:24 -07:00
Youwes09 64f63ceaa2 Chore: Discover Removal Finalized 2026-04-15 00:44:03 -05:00
Youwes09 6d835914ef Fix: Re-added Marker-Swatch CSS 2026-04-14 20:55:57 -05:00
Youwes09 10f5936dbd Fix: Attempt to fix Reader Page-Misfiring Bug & Optimize Loading (Auth Only) 2026-04-14 20:54:16 -05:00
Youwes09 5ddbfdbd6d Fix: Remove Discover Tab (Not Finished) 2026-04-14 11:22:20 -05:00
Youwes09 0ff148f720 Chore: Merge Discover into Search (WIP) 2026-04-14 11:09:53 -05:00
Youwes09 d98ca76036 Fix: Optimize SplashScreen when Off-Screen 2026-04-14 10:43:12 -05:00
Youwes09 35650481b0 Chore: Update Reader Picture 2026-04-14 00:19:49 -05:00
Youwes09 8b16537c35 Chore: Update README & Pictures 2026-04-14 00:13:48 -05:00
Youwes09 96639d2152 Chore: Swap Extension Icons 2026-04-13 21:31:24 -05:00
Youwes09 1c135a79ca Feat: Enforce & Block for Scanlators 2026-04-13 21:28:57 -05:00
Youwes09 6c11a9d53e Fix: Toaster Dismissal (#27) 2026-04-13 10:59:03 -05:00
Youwes09 5a2f88b806 Fix: Settings LocalHost Auth (#25) 2026-04-13 10:55:41 -05:00
Youwes09 75430305e6 Fix: Reader CSS & TitleBar Controls + WIP Feature 2026-04-13 09:50:07 -05:00
Youwes09 ea76b5fc26 Chore: Update Tags for 0.8.0 2026-04-13 00:10:12 -05:00
Youwes09 d5d9ff8b6e Chore: Language Filter on Extensions 2026-04-13 00:07:38 -05:00
Youwes09 7c9182eb4b Feat: Backup Feature & Settings Overhaul 2026-04-12 23:40:35 -05:00
Youwes09 4d6ebe8804 Fix: Infinite Scroll MAR Threshold & Pages Loaded (Bug #24) 2026-04-12 10:59:52 -05:00
Youwes09 49562c3f76 Feat: Open in File Explorer 2026-04-11 23:04:26 -05:00
Youwes09 4a299f60ac Chore: Library Changes 2026-04-11 19:29:04 -05:00
Youwes09 de397f2462 Feat: Library Manga Updates Display 2026-04-11 19:17:44 -05:00
Youwes09 af29cffdff Feat: Check for Updates (WIP) & Toaster Design Changes 2026-04-11 09:34:22 -05:00
Youwes09 f840ae6413 Feat: Continue Again (Bookmarking-based Resume) 2026-04-10 19:53:20 -05:00
Youwes09 6b8d4fc05f Fix: Reader TitleBar Controls 2026-04-10 19:37:50 -05:00
Youwes09 15079f7755 Feat: Reader Pan + Zoom 2026-04-10 19:30:51 -05:00
Youwes09 1a08d2415f Fix: RTL Keybinds Issue & Progress Bar (Untested) 2026-04-10 19:15:05 -05:00
Youwes09 7917491389 Feat: Default Library Toggle 2026-04-06 22:53:42 -05:00
Youwes09 0b6e9fbbbb Fix: Flatpak Binary Detection 2026-04-06 20:44:34 -05:00
Youwes09 023b23288b Chore: Update for 0.7.1 2026-04-05 20:02:27 -05:00
Youwes09 67a9f0b944 Fix: SplashScreen Scaling on Windows (WIP) 2026-04-06 00:39:16 -05:00
Youwes09 56392e2427 Fix: Caching Logic & Settings Warning for Auth 2026-04-05 11:54:46 -05:00
Youwes09 843e205072 Feat: Scanlator-based Filtering & Directory Changes 2026-04-05 11:36:43 -05:00
Youwes09 ee708d85d0 Fix: Persistent Security State 2026-04-05 00:59:27 -05:00
Youwes09 8005c82654 Fix: Update flake.nix Hash 2026-04-04 23:31:53 -05:00
Youwes09 d989b2d67e Fix: Tauri-Plugin-HTTP for Windows Auth Support (Major WIP) 2026-04-05 04:14:33 -05:00
Youwes09 6446a19b2d Fix: Auth Thumbnails on Windows (WIP) 2026-04-04 19:28:00 -05:00
Youwes09 5cd96abc0c Feat: Switch DRPC Plugins 2026-04-03 22:07:42 -05:00
225 changed files with 22526 additions and 15688 deletions
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright [2026] [@Youwes09]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+7 -7
View File
@@ -99,16 +99,16 @@ exec /usr/lib/moku/jre/bin/java \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
EOF
install -Dm644 packaging/dev.moku.app.desktop \
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
install -Dm644 src-tauri/icons/32x32.png \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
install -Dm644 src-tauri/icons/128x128.png \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
install -Dm644 src-tauri/icons/128x128@2x.png \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
install -Dm644 packaging/dev.moku.app.metainfo.xml \
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+7 -2
View File
@@ -21,7 +21,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
<div align="center">
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
@@ -37,13 +37,18 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
## Features
- **Library management** — organize manga into folders, track unread counts, filter by genre
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, AZ, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
- **Extension support** — install and manage Suwayomi extensions directly from the app
- **Download management** — queue and monitor chapter downloads with progress toasts
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
- **Auto-start server** — optionally launch Suwayomi in the background on startup
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
- **Auto-updates** — in-app update checker with silent background notifications
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
---
@@ -143,4 +148,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
## Disclaimer
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
+26 -19
View File
@@ -2,23 +2,15 @@ Major Revisions:
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
Minor Revisions:
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
- Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
- Adjustment in Settings for Theme Editor:
- Patch Color-Picker to Work Properly
- Moku Discord RPC
- Write a better library for Discord RPC & Tauri
- Integrate Download Directory Changes (Settings)
Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library
- Fix Library Build not Updating
- Check Auth System (Only Supports Basic-Auth)
- Fix Library-Refresh System (TESTING)
General/Misc Bugs:
- Fix Highlightable Elements
@@ -30,14 +22,29 @@ General/Misc Bugs:
In-Progress:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards
- Add Flathub Support (Pending Video)
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
- QOL Animations & Revamps
- Extensions QOL Animations
- Folders Slide
- Dropdown Formatting (Repositories, Etc)
- Extensions Revamps
- Fix Pill-Shaped Language Filter
- Fix ALL ALL EN Tag Issue
- Search QOL Animations
- Languages Dropdown Animations
- Search Revamps
- Custom Language Selector Modal
- Change Tab Selector to match Extensions & Library Folders (Design)
- Filter Genre should Filter Tags as well
- Tracking Revamp
- Completely Revamp Tracking
- Fix Search Folder Tabs (Right-Align)
Testing:
- Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
- Integrate Download Directory Changes (Settings)
- Fix Source Allow in Content (Doesn't even work)
Testing Bugs:
- Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN
- Fix Library Folders (Uneven Padding + Bleed into Other Folders); Appears Constraints are Off
-
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 MiB

After

Width:  |  Height:  |  Size: 7.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 KiB

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 940 KiB

+17 -5
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }:
let
version = "0.7.0";
version = "0.8.0";
pkgs = import inputs.nixpkgs {
inherit system;
@@ -71,7 +71,7 @@
inherit version;
src = frontendSrc;
fetcherVersion = 1;
hash = "sha256-ezlckHhfSIe/Hs30tbiN0a/EuvGxhO5L020aup23Ozg=";
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
};
buildPhase = "pnpm build";
@@ -177,7 +177,7 @@ EOF
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/dev.moku.app.yml"
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
echo " Bumping versions "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
@@ -195,7 +195,7 @@ EOF
echo "Done"
echo " Repacking frontend-dist.tar.gz "
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO/dist" .
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
echo "sha256: $FRONTEND_SHA"
@@ -226,7 +226,7 @@ EOF
--force-clean \
"$REPO/build-dir" \
"$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
rm -rf "$REPO/build-dir" "$REPO/repo"
echo "moku.flatpak created"
@@ -264,6 +264,15 @@ EOF
'';
};
tunnelScript = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
in
{
apps = {
@@ -272,6 +281,7 @@ EOF
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
};
packages = {
@@ -288,6 +298,7 @@ EOF
nodejs_22
pnpm
suwayomi-server
cloudflared
xdg-utils
];
shellHook = ''
@@ -301,6 +312,7 @@ EOF
echo " nix run .#bump -- <ver> bump versions only"
echo " nix run .#flatpak -- <ver> full flatpak build"
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
'';
};
@@ -1,4 +1,4 @@
app-id: dev.moku.app
app-id: io.github.Youwes09.Moku
runtime: org.gnome.Platform
runtime-version: '48'
sdk: org.gnome.Sdk
@@ -9,16 +9,22 @@ separate-locales: false
finish-args:
- --socket=wayland
- --socket=x11
- --socket=fallback-x11
- --share=ipc
- --device=dri
- --share=network
- --socket=session-bus
- --socket=system-bus
- --filesystem=home
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.freedesktop.portal.FileTransfer
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=com.canonical.AppMenu.Registrar
- --talk-name=com.canonical.indicator.application
- --filesystem=xdg-run/discord-ipc-0:ro
- --filesystem=xdg-data/moku:create
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-download
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
@@ -33,13 +39,10 @@ modules:
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
sources:
- type: file
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
dest-filename: jdk.tar.gz
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
# exits just that thread instead of killing the whole JVM. Official Suwayomi
# fix for headless environments. Source inlined to avoid upstream drift.
- name: catch-abort
buildsystem: simple
build-commands:
@@ -120,7 +123,6 @@ modules:
fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
@@ -138,8 +140,6 @@ modules:
export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
# that thread instead of crashing the whole JVM process.
export LD_PRELOAD="/app/lib/catch_abort.so"
exec /app/jre/bin/java \
@@ -171,17 +171,19 @@ modules:
- tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
sources:
- type: dir
path: .
- type: git
url: https://github.com/Youwes09/Moku.git
tag: v0.8.0
commit: c573c543187cbd1ca1455b25d6bce0fc62666341
- type: file
path: packaging/frontend-dist.tar.gz
sha256: 43b7274bdab884aacbc3dad6f0f7c043d8e3d82b7bf7398e1df9f516ed553152
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
- packaging/cargo-sources.json
- type: inline
dest: src-tauri/.cargo
+2
View File
@@ -11,11 +11,13 @@
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-http": "^2.5.8",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5",
"clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
"tauri-plugin-drpc": "^1.0.3"
},
"devDependencies": {
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>dev.moku.app</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>Moku</name>
<summary>Manga reader powered by Suwayomi</summary>
<description>
<p>
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
providing a clean native interface for browsing, reading, and managing your
manga library across hundreds of sources.
</p>
</description>
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
<url type="homepage">https://github.com/shozikan/Moku</url>
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
<provides>
<binary>moku</binary>
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.4.0" date="2025-03-22">
<description>
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
</description>
</release>
</releases>
</component>
@@ -2,7 +2,7 @@
Name=Moku
Comment=Manga reader powered by Suwayomi
Exec=moku
Icon=dev.moku.app
Icon=io.github.Youwes09.Moku
Terminal=false
Type=Application
Categories=Graphics;Viewer;
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.Youwes09.Moku</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>Moku</name>
<summary>Manga reader powered by Suwayomi</summary>
<description>
<p>
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
providing a clean native interface for browsing, reading, and managing your
manga library across hundreds of sources.
</p>
<p>
Features include library management, chapter tracking, extension support,
reading history, notifications, and Discord Rich Presence integration.
</p>
</description>
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
<url type="homepage">https://github.com/Youwes09/Moku</url>
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
<caption>Home screen showing your manga library</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
<caption>Built-in manga reader</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
<caption>Discover new manga across hundreds of sources</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
<caption>Download manager</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
<caption>Settings</caption>
</screenshot>
</screenshots>
<provides>
<binary>moku</binary>
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.8.0" date="2025-04-01">
<description>
<p>Latest release with improved stability and UI refinements.</p>
</description>
</release>
<release version="0.4.0" date="2025-03-22">
<description>
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
</description>
</release>
</releases>
</component>
+21
View File
@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.0.0
version: 2.10.1
'@tauri-apps/plugin-http':
specifier: ^2.5.8
version: 2.5.8
'@tauri-apps/plugin-os':
specifier: ^2.3.2
version: 2.3.2
@@ -26,6 +29,9 @@ importers:
svelte-spa-router:
specifier: ^4.0.1
version: 4.0.2
tauri-plugin-discord-rpc-api:
specifier: github:Youwes09/tauri-plugin-discord-rpc
version: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a
tauri-plugin-drpc:
specifier: ^1.0.3
version: 1.0.3(typescript@5.9.3)
@@ -442,6 +448,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-http@2.5.8':
resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==}
'@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
@@ -747,6 +756,10 @@ packages:
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
engines: {node: '>=18'}
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
resolution: {tarball: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a}
version: 0.1.0
tauri-plugin-drpc@1.0.3:
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
peerDependencies:
@@ -1046,6 +1059,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-http@2.5.8':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-os@2.3.2':
dependencies:
'@tauri-apps/api': 2.10.1
@@ -1372,6 +1389,10 @@ snapshots:
magic-string: 0.30.21
zimmerframe: 1.1.4
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
dependencies:
'@tauri-apps/api': 2.10.1
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
dependencies:
typescript: 5.9.3
+325 -155
View File
File diff suppressed because it is too large Load Diff
+21 -17
View File
@@ -1,6 +1,6 @@
[package]
name = "moku"
version = "0.7.0"
version = "0.8.0"
edition = "2021"
[lib]
@@ -15,22 +15,26 @@ path = "src/main.rs"
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "2.0", features = [] }
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"
sysinfo = "0.32"
dirs = "5"
tauri-plugin-os = "2.3.2"
tauri-plugin-drpc = "0.1.6"
tauri = { version = "2.0", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-http = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2.3.2"
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
walkdir = "2"
sysinfo = "0.32"
dirs = "5"
urlencoding = "2"
tokio = { version = "1", features = ["rt-multi-thread"] }
reqwest = { version = "0.12", features = ["blocking"] }
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
lto = true
opt-level = "s"
panic = "abort"
strip = true
+6 -6
View File
@@ -33,11 +33,11 @@
"process:allow-restart",
"http:default",
"http:allow-fetch",
"drpc:default",
"drpc:allow-is-running",
"drpc:allow-spawn-thread",
"drpc:allow-destroy-thread",
"drpc:allow-set-activity",
"drpc:allow-clear-activity"
"discord-rpc:default",
"discord-rpc:allow-connect",
"discord-rpc:allow-disconnect",
"discord-rpc:allow-set-activity",
"discord-rpc:allow-clear-activity",
"discord-rpc:allow-is-running"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "http-scope",
"description": "HTTP fetch scope",
"windows": ["main"],
"permissions": [
{
"identifier": "http:default",
"allow": [
{ "url": "http://*:*/*" },
{ "url": "https://*:*/*" },
{ "url": "http://*/*" },
{ "url": "https://*/*" }
]
}
]
}
+213 -220
View File
@@ -26,9 +26,6 @@ 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,
@@ -38,7 +35,6 @@ pub struct ReleaseInfo {
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 {
@@ -46,8 +42,6 @@ struct UpdateProgress {
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 {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
@@ -59,16 +53,9 @@ fn strip_unc(path: PathBuf) -> PathBuf {
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path);
return PathBuf::from(downloads_path.trim());
}
// Mirror Suwayomi-Server's own default: <data_dir>/Tachidesk/downloads
// Windows: %LOCALAPPDATA%\Tachidesk\downloads
// macOS: ~/Library/Application Support/Tachidesk/downloads
// Linux: $XDG_DATA_HOME/Tachidesk/downloads (~/.local/share/Tachidesk/downloads)
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk").join("downloads")
suwayomi_data_dir().join("downloads")
}
#[tauri::command]
@@ -108,34 +95,23 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
})
}
/// Returns the resolved default downloads path for the current platform.
/// This mirrors resolve_downloads_path("") so the frontend can display it.
#[tauri::command]
fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
/// Returns true if the given path exists and is a directory.
#[tauri::command]
fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
/// Creates a directory and all missing parent directories.
#[tauri::command]
fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
/// Moves all content from `src` into `dst`, then removes `src`.
/// Emits `migrate_progress` events: `{ done, total, current }`.
/// Only deletes the source tree after every file is confirmed copied.
#[tauri::command]
async fn migrate_downloads(
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
use tauri::Emitter;
use std::fs;
@@ -143,19 +119,16 @@ async fn migrate_downloads(
let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(()); // nothing to migrate
return Ok(());
}
// Count files first so the frontend can show accurate progress
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": 0u64, "total": total, "current": ""
}));
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
let mut done: u64 = 0;
@@ -172,23 +145,15 @@ async fn migrate_downloads(
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": done,
"total": total,
"current": rel.to_string_lossy()
"done": done, "total": total, "current": rel.to_string_lossy()
}));
}
}
// Only remove source after all files are confirmed copied
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
/// 1.251.5 on Windows displays with OS-level scaling applied.
/// The frontend multiplies this by the user's uiZoom preference to get the
/// final effective zoom applied to document.documentElement.
#[tauri::command]
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
@@ -210,8 +175,6 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
.creation_flags(CREATE_NO_WINDOW)
.status();
// Poll until no java.exe remains (up to ~3 s) so the installer can
// overwrite the JRE DLLs without hitting a sharing-violation error.
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
@@ -220,20 +183,15 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
if !still_running {
break;
}
if !still_running { break; }
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill")
.args(["-f", "tachidesk"])
.status();
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
}
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
@@ -311,7 +269,7 @@ fn suwayomi_data_dir() -> PathBuf {
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("dev.moku.app/tachidesk")
.join("io.github.Youwes09.Moku.app/tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
@@ -328,23 +286,14 @@ struct ServerInvocation {
working_dir: Option<PathBuf>,
}
/// Locate the `java` / `java.exe` binary inside a bundled JRE directory.
///
/// Expected layout (Windows and Linux):
/// <bundle_dir>/jre/bin/java[.exe]
///
/// On Windows `strip_unc` is applied so Java doesn't choke on `\\?\` prefixes.
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] checking path: {:?}", java));
do_log(log, &format!("[find_java] exists: {}", java.exists()));
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
if java.exists() { Some(java) } else { None }
}
@@ -360,12 +309,8 @@ fn resolve_server_binary(
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
do_log(log, &format!("[resolve] binary = {:?}", binary));
// ── 1. User-specified binary path ─────────────────────────────────────────
// Primary: honour the path as-is (doc-2 behaviour — trust the user).
// Fallback: if the path doesn't exist after stripping UNC, log a warning
// and continue so the bundled detection still has a chance.
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
@@ -376,62 +321,60 @@ fn resolve_server_binary(
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
// Fallback: path was set but file is missing — warn and keep trying.
do_log(log, "[resolve] WARNING: user-supplied path not found, falling through to bundled detection");
do_log(log, "[resolve] user path not found, falling through");
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name);
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(bin_dir.to_path_buf()),
});
}
}
}
}
// Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
#[cfg(not(target_os = "macos"))]
let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
stripped
};
// ── 2. Bundled JRE + JAR (Windows / Linux — specific layout) ─────────────
// Primary path from doc-2: binaries/suwayomi-bundle/bin/Suwayomi-Server.jar
#[cfg(not(target_os = "macos"))]
{
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
do_log(log, &format!("[resolve] jar = {:?}", jar));
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
match find_java_in_bundle(&bundle_dir, log) {
Some(java) => {
do_log(log, &format!("[resolve] java found: {:?}", java));
if jar.exists() {
do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
}
do_log(log, "[resolve] java found but jar MISSING — falling through");
}
None => {
do_log(log, "[resolve] java NOT found in bundle — falling through");
Some(java) if jar.exists() => {
do_log(log, "[resolve] using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
}
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
}
}
// ── 2b. Bundled launcher scripts / native sidecars (Windows / Linux) ──────
// Fallback for older bundle layouts that ship a wrapper script instead of a
// bare JRE + JAR. Also scans for any *.jar in resource_dir as a last resort.
#[cfg(not(target_os = "macos"))]
{
// Named launcher scripts.
let script_candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &script_candidates {
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
if p.exists() {
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
@@ -440,106 +383,151 @@ fn resolve_server_binary(
}
}
// Generic JRE at resource_dir root + any *.jar alongside it.
do_log(log, "[resolve] no named sidecar found, trying generic JRE + any jar in resource_dir");
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir)
.ok()
.and_then(|mut rd| {
rd.find(|e| {
e.as_ref()
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
.unwrap_or(false)
})
.and_then(|e| e.ok())
.map(|e| e.path())
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
.and_then(|e| e.ok())
.map(|e| e.path())
});
do_log(log, &format!("[resolve] generic jar candidate: {:?}", jar));
if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] using generic JRE java={:?} jar={:?}", java, jar_path));
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
});
}
do_log(log, "[resolve] generic JRE found but no .jar — falling through");
}
}
// ── 3. macOS app bundle — MacOS/ then Resources/ ──────────────────────────
#[cfg(target_os = "macos")]
{
// Root of Moku.app/Contents/ — scan every subdirectory level by level.
let resource_dir = app.path().resource_dir().unwrap_or_default();
let macos_dir = resource_dir
.parent()
.map(|p| p.join("MacOS"))
.unwrap_or_default();
let contents_dir = resource_dir
.parent() // Moku.app/Contents/
.unwrap_or(&resource_dir)
.to_path_buf();
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
// Tauri strips the target triple when installing externalBin sidecars into
// Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
// Triple-suffixed names are kept as a belt-and-suspenders fallback for
// dev / flat layouts.
let candidates = [
"suwayomi-server",
// Native-binary names we recognise (most specific first so arch-specific
// names win over the generic "suwayomi-server" if both somehow exist).
const NATIVE_NAMES: &[&str] = &[
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
for search_dir in &[&macos_dir, &resource_dir] {
for name in &candidates {
let p = search_dir.join(name);
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
if p.exists() {
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: None,
});
// Collect every directory inside Contents/, grouped by depth so we
// search shallower levels first (BFS order via WalkDir min/max_depth).
// We go up to depth 8 which is more than enough for any real bundle.
let mut found_binary: Option<ServerInvocation> = None;
let mut found_java: Option<(PathBuf, PathBuf)> = None; // (java_exe, jar)
'outer: for depth in 0u8..=8 {
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
.min_depth(depth as usize)
.max_depth(depth as usize)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect();
for dir in &entries {
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
// 1. Look for a native server binary in this directory.
for name in NATIVE_NAMES {
let p = dir.join(name);
if p.exists() {
do_log(log, &format!("[resolve] found native binary: {:?}", p));
found_binary = Some(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(dir.clone()),
});
break 'outer;
}
}
// 2. Look for a JRE java binary paired with a .jar in the same
// or sibling directories. We record the first hit and keep
// scanning natives; if no native is ever found we fall back
// to this.
if found_java.is_none() {
let java_exe = dir.join("bin").join("java");
if java_exe.exists() {
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
// Search upward from the JRE dir for a .jar file.
let mut search = dir.as_path();
'jar: for _ in 0..5 {
if let Ok(rd) = std::fs::read_dir(search) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
// Also look in a sibling `bin/` directory.
let bin_sibling = search.join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
match search.parent() {
Some(p) => search = p,
None => break,
}
}
}
}
}
}
if let Some(inv) = found_binary {
return Ok(inv);
}
if let Some((java, jar)) = found_java {
let working_dir = jar.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir,
});
}
do_log(log, "[resolve] macOS scan found nothing in bundle");
}
// ── 4. PATH fallback ──────────────────────────────────────────────────────
// Use `where` on Windows, `which` everywhere else.
do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let found = std::process::Command::new("where")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
#[cfg(not(target_os = "windows"))]
let found = std::process::Command::new("which")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
if found {
do_log(log, &format!("[resolve] using PATH binary: {}", name));
return Ok(ServerInvocation {
bin: name.to_string(),
args: vec![],
working_dir: None,
});
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
}
}
do_log(log, "[resolve] FAILED — no binary found anywhere");
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
@@ -555,50 +543,30 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
}
let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
do_log(&mut log, "");
do_log(&mut log, "========================================");
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
seed_server_conf(&data_dir);
do_log(&mut log, "[spawn_server] server.conf seeded");
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
Ok(i) => i,
Err(e) => {
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
return Err(e);
}
};
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
e
})?;
let bin_display = invocation.bin.clone();
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
invocation.args.insert(0, rootdir_flag);
}
invocation.args.insert(0, rootdir_flag);
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let working_dir = invocation.working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display));
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
let cmd = app.shell()
.command(&invocation.bin)
@@ -606,17 +574,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
.args(&invocation.args)
.current_dir(&working_dir);
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
match cmd.spawn() {
Ok((_rx, child)) => {
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
@@ -628,10 +592,6 @@ 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;
@@ -663,22 +623,15 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, 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())
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> {
@@ -693,7 +646,7 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), 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());
return Err("No update available.".into());
};
let app_clone = app.clone();
@@ -711,18 +664,56 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
}
}
/// Restart the app after a successful update install.
#[tauri::command]
fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
// ── App entry point ───────────────────────────────────────────────────────────
#[tauri::command]
fn open_path(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let p = strip_unc(std::path::PathBuf::from(path.trim()));
std::process::Command::new("explorer")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("xdg-open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
app.dialog()
.file()
.set_title("Choose Downloads Folder")
.blocking_pick_folder()
.map(|p| p.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_drpc::init())
.plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
@@ -741,6 +732,8 @@ pub fn run() {
list_releases,
download_and_install_update,
restart_app,
open_path,
pick_downloads_folder,
])
.setup(|_app| Ok(()))
.on_window_event(|window, event| {
@@ -750,4 +743,4 @@ pub fn run() {
})
.run(tauri::generate_context!())
.expect("error while running moku");
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Moku",
"version": "0.7.0",
"identifier": "dev.moku.app",
"version": "0.8.0",
"identifier": "io.github.Youwes09.Moku.app",
"build": {
"frontendDist": "../dist",
"beforeBuildCommand": "pnpm build"
+94 -397
View File
@@ -1,59 +1,32 @@
<script lang="ts">
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 { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "./lib/client";
import logoUrl from "./assets/moku-icon-splash.svg";
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import Layout from "./components/layout/Layout.svelte";
import Reader from "./components/reader/Reader.svelte";
import Settings from "./components/settings/Settings.svelte";
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
import TitleBar from "./components/layout/TitleBar.svelte";
import Toaster from "./components/layout/Toaster.svelte";
import SplashScreen from "./components/layout/SplashScreen.svelte";
import MangaPreview from "./components/shared/MangaPreview.svelte";
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, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { boot, 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";
let themeStyleEl: HTMLStyleElement | null = null;
const win = getCurrentWindow();
void platform();
$effect(() => {
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");
});
let appReady = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null);
@@ -68,220 +41,16 @@
themeEditorEditId = null;
}
const MAX_ATTEMPTS = 10;
const win = getCurrentWindow();
let serverProbeOk = $state(false);
let appReady = $state(false);
let failed = $state(false);
let notConfigured = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let loginRequired = $state(false);
let loginUser = $state(store.settings.serverAuthUser ?? "");
let loginPass = $state("");
let loginError = $state<string | null>(null);
let loginBusy = $state(false);
let unsupportedMode = $state(false);
let platformScale = $state(1.0);
let _appliedZoom = -1;
let _vhRafId: number | null = null;
function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.0;
if (uiZoom === _appliedZoom) return;
_appliedZoom = uiZoom;
const pct = uiZoom * 100;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${pct}%`;
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
}
let prevQueue: DownloadQueueItem[] = [];
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let pollInterval: ReturnType<typeof setInterval>;
let unlistenDownload: (() => void) | undefined;
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
for (const item of prev) {
if (item.state !== "DOWNLOADING") continue;
if (!next.some(q => q.chapter.id === item.chapter.id)) {
const manga = item.chapter.manga;
addToast({ kind: "success", title: "Chapter downloaded",
body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
duration: 4000 });
}
}
}
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueue, next);
prevQueue = next;
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
})));
}
function resetIdle() {
if (idleTimer) clearTimeout(idleTimer);
if (idle) return;
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
idleTimer = setTimeout(() => idle = true, ms);
}
const idleEvents = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
$effect(() => { void store.settings.theme; applyTheme(); });
$effect(() => { void store.settings.uiZoom; applyZoom(); });
$effect(() => mountZoomKey());
$effect(() => {
if (!appReady) return;
idleEvents.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
resetIdle();
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
});
$effect(() => {
void store.settings.uiZoom;
applyZoom();
});
$effect(() => {
if (!appReady) return;
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
poll();
pollInterval = setInterval(poll, 2000);
return () => clearInterval(pollInterval);
});
async function checkForUpdateSilently() {
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 parse = (tag: string): number[] =>
tag.replace(/^v/, "").split(".").map(Number);
const 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;
};
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
if (isNewer) {
addToast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
let cancelProbe = false;
function startProbe() {
cancelProbe = false;
failed = false;
loginRequired = false;
let tries = 0;
async function probe() {
if (cancelProbe) return;
tries++;
const result = await probeServer();
if (cancelProbe) return;
if (result === "ok") {
serverProbeOk = true;
loginRequired = false;
return;
}
if (result === "auth_required") {
serverProbeOk = true;
loginRequired = true;
return;
}
if (result === "unsupported_mode") {
serverProbeOk = true;
unsupportedMode = true;
return;
}
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
setTimeout(probe, 750);
}
setTimeout(probe, 800);
}
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true;
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom();
store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen();
});
const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor;
applyZoom();
});
if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") {
notConfigured = true;
} else {
console.warn("Could not start server:", err);
}
});
}
startProbe();
type P = { chapterId: number; mangaId: number; progress: number }[];
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
return () => {
cancelProbe = true;
unlistenResize();
unlistenScale();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer);
if (pollInterval) clearInterval(pollInterval);
unlistenDownload?.();
delete (window as any).__mokuShowSplash;
};
return mountIdleDetection(
() => { idle = true; },
() => { if (idle) idle = false; },
);
});
$effect(() => {
@@ -300,139 +69,90 @@
});
$effect(() => {
if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle();
}
if (!store.activeChapter && store.settings.discordRpc) setIdle();
});
function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
if (e.key === "=" || e.key === "+") {
e.preventDefault();
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
} else if (e.key === "-") {
e.preventDefault();
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
} else if (e.key === "0") {
e.preventDefault();
store.settings.uiZoom = 1.0;
}
}
$effect(() => {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
const next = downloadStore.queue.slice();
downloadStore.detectTransitions(next);
});
async function handleLogin() {
if (!loginUser.trim() || !loginPass.trim()) {
loginError = "Username and password are required";
return;
}
loginBusy = true;
loginError = null;
try {
await loginBasic(loginUser.trim(), loginPass.trim());
loginRequired = false;
loginPass = "";
loginError = null;
appReady = true;
} catch (e: any) {
loginError = e?.message ?? "Login failed";
} finally {
loginBusy = false;
}
}
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();
});
if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true;
else console.warn("Could not start server:", err);
});
}
function handleRetry() {
failed = false;
notConfigured = false;
serverProbeOk = false;
loginRequired = false;
unsupportedMode = false;
startProbe();
}
function handleBypass() {
cancelProbe = true;
serverProbeOk = true;
loginRequired = false;
unsupportedMode = false;
appReady = true;
}
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
"download-progress",
e => setActiveDownloads(e.payload),
);
await downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => {
stopProbe();
clearInterval(dlInterval);
unlistenResize();
unlistenScale();
unlistenDownload();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
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 && !loginRequired && !unsupportedMode}
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
failed={boot.failed} notConfigured={boot.notConfigured}
showCards={store.settings.splashCards ?? true}
onReady={() => { appReady = true; }}
onRetry={handleRetry}
onBypass={handleBypass} />
{:else if unsupportedMode}
onRetry={retryBoot}
onBypass={() => bypassBoot(() => { appReady = true; })} />
{:else if boot.unsupportedMode || boot.loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<div class="auth-overlay">
<div class="auth-card">
<img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p>
<span class="auth-mode-badge auth-mode-badge--warn">{
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
}</span>
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
<p class="auth-body">
<strong>{
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
</p>
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
</div>
</div>
{:else if loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<div class="auth-overlay">
<div class="auth-card">
<img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p>
<span class="auth-mode-badge">Basic Auth</span>
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
{#if loginError}
<p class="auth-error">{loginError}</p>
{/if}
<div class="auth-fields">
<input class="auth-input" type="text" placeholder="Username"
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
<input class="auth-input" type="password" placeholder="Password"
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
</div>
<button class="auth-btn" onclick={handleLogin}
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
{loginBusy ? "Signing in…" : "Sign in"}
</button>
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
</div>
</div>
<AuthGate onReady={() => { appReady = true; }} />
{:else}
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; }} />
{/if}
<div id="app-shell" class="root">
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; resetIdle(); }} />
{/if}
{#if !store.activeChapter}<TitleBar />{/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}
/>
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
{/if}
<MangaPreview />
<Toaster />
@@ -440,29 +160,6 @@
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; }
/* Auth overlay — floats above the SplashScreen */
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
.auth-input:disabled { opacity: 0.5; }
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
.auth-btn:disabled { opacity: 0.35; cursor: default; }
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
</style>
+19 -40
View File
@@ -1,5 +1,5 @@
import { store } from "../store/state.svelte";
import { fetchAuthenticated } from "./auth";
import { store } from "@store/state.svelte";
import { fetchAuthenticated } from "../core/auth";
const DEFAULT_URL = "http://127.0.0.1:4567";
@@ -8,31 +8,16 @@ function getServerUrl(): string {
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
}
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function thumbUrl(path: string): string {
export function plainThumbUrl(path: string): string {
if (!path) return "";
if (path.startsWith("http")) return path;
const base = getServerUrl();
const mode = store.settings.serverAuthMode;
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
if (user && pass) {
const url = new URL(`${base}${path}`);
url.username = user;
url.password = pass;
return url.toString();
}
}
return `${base}${path}`;
return `${getServerUrl()}${path}`;
}
export const thumbUrl = plainThumbUrl;
interface GQLResponse<T> {
data: T;
data: T;
errors?: { message: string }[];
}
@@ -48,14 +33,13 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
}
async function fetchWithRetry(
url: string,
init: RequestInit,
url: string,
init: RequestInit,
signal?: AbortSignal,
retries = 3,
delayMs = 300,
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 {
@@ -64,8 +48,7 @@ async function fetchWithRetry(
return res;
} catch (e: any) {
if (e?.authRequired) throw e;
const isAbort = e?.name === "AbortError" || signal?.aborted;
if (isAbort) throw new DOMException("Aborted", "AbortError");
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (i === retries - 1) throw e;
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
}
@@ -74,23 +57,19 @@ async function fetchWithRetry(
}
export async function gql<T>(
query: string,
query: string,
variables?: Record<string, unknown>,
signal?: AbortSignal,
signal?: AbortSignal,
): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
}, signal);
const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
signal,
);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
+11
View File
@@ -0,0 +1,11 @@
export * from "./client";
export * from "./queries/manga";
export * from "./queries/chapters";
export * from "./queries/downloads";
export * from "./queries/extensions";
export * from "./queries/tracking";
export * from "./mutations/manga";
export * from "./mutations/chapters";
export * from "./mutations/downloads";
export * from "./mutations/extensions";
export * from "./mutations/tracking";
+48
View File
@@ -0,0 +1,48 @@
export const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) {
fetchChapters(input: { mangaId: $mangaId }) {
chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead scanlator
}
}
}
`;
export const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
}
`;
export const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
chapter { id isRead }
}
}
`;
export const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
chapters { id isRead }
}
}
`;
export const UPDATE_CHAPTERS_PROGRESS = `
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
chapters { id isRead isBookmarked lastPageRead }
}
}
`;
export const DELETE_DOWNLOADED_CHAPTERS = `
mutation DeleteDownloadedChapters($ids: [Int!]!) {
deleteDownloadedChapters(input: { ids: $ids }) {
chapters { id isDownloaded }
}
}
`;
+99
View File
@@ -0,0 +1,99 @@
const QUEUE_FRAGMENT = `
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
`;
export const ENQUEUE_DOWNLOAD = `
mutation EnqueueDownload($chapterId: Int!) {
enqueueChapterDownload(input: { id: $chapterId }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
enqueueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_DOWNLOAD = `
mutation DequeueDownload($chapterId: Int!) {
dequeueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
dequeueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const REORDER_DOWNLOAD = `
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const START_DOWNLOADER = `
mutation StartDownloader {
startDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const STOP_DOWNLOADER = `
mutation StopDownloader {
stopDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const CLEAR_DOWNLOADER = `
mutation ClearDownloader {
clearDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas { id title thumbnailUrl inLibrary }
hasNextPage
}
}
`;
export const SET_DOWNLOADS_PATH = `
mutation SetDownloadsPath($path: String!) {
setSettings(input: { settings: { downloadsPath: $path } }) {
settings { downloadsPath }
}
}
`;
export const SET_LOCAL_SOURCE_PATH = `
mutation SetLocalSourcePath($path: String!) {
setSettings(input: { settings: { localSourcePath: $path } }) {
settings { localSourcePath }
}
}
`;
+89
View File
@@ -0,0 +1,89 @@
export const FETCH_EXTENSIONS = `
mutation FetchExtensions {
fetchExtensions(input: {}) {
extensions {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extension { apkName pkgName name isInstalled hasUpdate }
}
}
`;
export const INSTALL_EXTERNAL_EXTENSION = `
mutation InstallExternalExtension($url: String!) {
installExternalExtension(input: { extensionUrl: $url }) {
extension { apkName pkgName name isInstalled }
}
}
`;
export const SET_EXTENSION_REPOS = `
mutation SetExtensionRepos($repos: [String!]!) {
setSettings(input: { settings: { extensionRepos: $repos } }) {
settings { extensionRepos }
}
}
`;
export const SET_SERVER_AUTH = `
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
settings { authMode authUsername }
}
}
`;
export const SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
}
}
`;
export const SET_FLARESOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings {
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
}
`;
+5
View File
@@ -0,0 +1,5 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
+91
View File
@@ -0,0 +1,91 @@
export const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
manga {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
}
`;
export const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
manga { id inLibrary }
}
}
`;
export const UPDATE_MANGAS = `
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
mangas { id inLibrary }
}
}
`;
export const UPDATE_MANGA_CATEGORIES = `
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
manga { id }
}
}
`;
export const CREATE_CATEGORY = `
mutation CreateCategory($name: String!) {
createCategory(input: { name: $name }) {
category { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_CATEGORY = `
mutation UpdateCategory($id: Int!, $name: String) {
updateCategory(input: { id: $id, patch: { name: $name } }) {
category { id name order }
}
}
`;
export const DELETE_CATEGORY = `
mutation DeleteCategory($id: Int!) {
deleteCategory(input: { categoryId: $id }) {
category { id }
}
}
`;
export const UPDATE_CATEGORY_ORDER = `
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
updateCategoryOrder(input: { id: $id, position: $position }) {
categories { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const CREATE_BACKUP = `
mutation CreateBackup {
createBackup(input: {}) { url }
}
`;
export const RESTORE_BACKUP = `
mutation RestoreBackup($backup: Upload!) {
restoreBackup(input: { backup: $backup }) {
id
status { mangaProgress state totalManga }
}
}
`;
+450
View File
@@ -0,0 +1,450 @@
# Mutations
## Manga (`mutations/manga.ts`)
### `FETCH_MANGA`
Fetches and refreshes manga metadata from its source.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
---
### `UPDATE_MANGA`
Updates a single manga's library membership.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
| `inLibrary` | `Boolean` | Add/remove from library |
---
### `UPDATE_MANGAS`
Bulk-updates library membership for multiple manga.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Manga IDs |
| `inLibrary` | `Boolean` | Add/remove from library |
---
### `UPDATE_MANGA_CATEGORIES`
Adds or removes a manga from categories.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
| `addTo` | `[Int!]!` | Category IDs to add to |
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
---
### `CREATE_CATEGORY`
Creates a new manga category.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `name` | `String!` | Category name |
---
### `UPDATE_CATEGORY`
Updates a category's name.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `name` | `String` | New name |
---
### `DELETE_CATEGORY`
Deletes a category by ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
---
### `UPDATE_CATEGORY_ORDER`
Moves a category to a new position.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `position` | `Int!` | New position index |
---
### `UPDATE_LIBRARY`
Triggers a library-wide metadata refresh and returns job status.
**Variables:** none
---
### `CREATE_BACKUP`
Creates a backup and returns its download URL.
**Variables:** none
---
### `RESTORE_BACKUP`
Restores a backup from an uploaded file and returns restore job status.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
---
## Chapters (`mutations/chapters.ts`)
### `FETCH_CHAPTERS`
Fetches/refreshes the chapter list for a manga from its source.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
### `FETCH_CHAPTER_PAGES`
Fetches the page URLs for a specific chapter.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `MARK_CHAPTER_READ`
Marks a single chapter as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Chapter ID |
| `isRead` | `Boolean!` | Read state |
---
### `MARK_CHAPTERS_READ`
Bulk-marks multiple chapters as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean!` | Read state |
---
### `UPDATE_CHAPTERS_PROGRESS`
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean` | Read state |
| `isBookmarked` | `Boolean` | Bookmark state |
| `lastPageRead` | `Int` | Last page index read |
---
### `DELETE_DOWNLOADED_CHAPTERS`
Deletes downloaded chapter files for the given chapter IDs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
---
## Downloads (`mutations/downloads.ts`)
### `ENQUEUE_DOWNLOAD`
Adds a single chapter to the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `ENQUEUE_CHAPTERS_DOWNLOAD`
Adds multiple chapters to the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterIds` | `[Int!]!` | Chapter IDs |
---
### `DEQUEUE_DOWNLOAD`
Removes a chapter from the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `START_DOWNLOADER`
Starts the downloader and returns the current queue state.
**Variables:** none
---
### `STOP_DOWNLOADER`
Stops the downloader and returns the current queue state.
**Variables:** none
---
### `CLEAR_DOWNLOADER`
Clears all items from the download queue.
**Variables:** none
---
### `FETCH_SOURCE_MANGA`
Fetches manga from a source (browse/search), with pagination and optional filters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `source` | `LongString!` | Source ID |
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
| `page` | `Int!` | Page number |
| `query` | `String` | Search query |
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
---
### `SET_DOWNLOADS_PATH`
Sets the downloads directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
---
### `SET_LOCAL_SOURCE_PATH`
Sets the local source directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
---
## Extensions (`mutations/extensions.ts`)
### `FETCH_EXTENSIONS`
Fetches the latest extension list from configured repos.
**Variables:** none
---
### `UPDATE_EXTENSION`
Installs, uninstalls, or updates an extension.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `String!` | Extension package name |
| `install` | `Boolean` | Install the extension |
| `uninstall` | `Boolean` | Uninstall the extension |
| `update` | `Boolean` | Update the extension |
---
### `INSTALL_EXTERNAL_EXTENSION`
Installs an extension from an external APK URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `url` | `String!` | APK download URL |
---
### `SET_EXTENSION_REPOS`
Sets the list of extension repository URLs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `repos` | `[String!]!` | Repository URLs |
---
### `SET_SERVER_AUTH`
Configures server authentication mode and credentials.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `authMode` | `AuthMode!` | Auth mode |
| `authUsername` | `String!` | Username |
| `authPassword` | `String!` | Password |
---
### `SET_SOCKS_PROXY`
Configures SOCKS proxy settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
| `socksProxyHost` | `String!` | Proxy host |
| `socksProxyPort` | `String!` | Proxy port |
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
| `socksProxyUsername` | `String!` | Proxy username |
| `socksProxyPassword` | `String!` | Proxy password |
---
### `SET_FLARESOLVERR`
Configures FlareSolverr integration settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
| `flareSolverrSessionName` | `String!` | Session name |
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
---
## Tracking (`mutations/tracking.ts`)
### `BIND_TRACK`
Binds a manga to a remote tracker entry.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
| `trackerId` | `Int!` | Tracker ID |
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
---
### `UPDATE_TRACK`
Updates tracking progress, status, score, and dates for a track record.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
| `status` | `Int` | Reading status |
| `lastChapterRead` | `Float` | Last chapter read |
| `scoreString` | `String` | Score in tracker's format |
| `startDate` | `LongString` | Start date |
| `finishDate` | `LongString` | Finish date |
| `private` | `Boolean` | Mark as private |
---
### `UNBIND_TRACK`
Unbinds a manga from a tracker record.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
---
### `FETCH_TRACK`
Refreshes a track record from the remote tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
---
### `LOGIN_TRACKER_OAUTH`
Initiates OAuth login for a tracker using a callback URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `callbackUrl` | `String!` | OAuth callback URL |
---
### `LOGIN_TRACKER_CREDENTIALS`
Logs into a tracker using username and password.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `LOGOUT_TRACKER`
Logs out of a tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
---
### `LOGIN_USER`
Authenticates a user and returns access and refresh tokens.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `REFRESH_TOKEN`
Refreshes the current access token.
**Variables:** none
+80
View File
@@ -0,0 +1,80 @@
const TRACK_RECORD_FRAGMENT = `
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private
`;
export const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { ${TRACK_RECORD_FRAGMENT} }
}
}
`;
export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private
}
}
}
`;
export const UNBIND_TRACK = `
mutation UnbindTrack($recordId: Int!) {
unbindTrack(input: { recordId: $recordId }) {
trackRecord { id }
}
}
`;
export const FETCH_TRACK = `
mutation FetchTrack($recordId: Int!) {
fetchTrack(input: { recordId: $recordId }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate
}
}
}
`;
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGIN_USER = `
mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken refreshToken
}
}
`;
export const REFRESH_TOKEN = `
mutation RefreshToken {
refreshToken { accessToken }
}
`;
+22
View File
@@ -0,0 +1,22 @@
export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
nodes {
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 scanlator
}
}
}
`;
+14
View File
@@ -0,0 +1,14 @@
export const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`;
+43
View File
@@ -0,0 +1,43 @@
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 }
}
}
`;
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
}
}
`;
+5
View File
@@ -0,0 +1,5 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
+96
View File
@@ -0,0 +1,96 @@
export const GET_LIBRARY = `
query GetLibrary {
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount
description status author artist genre
source { id name displayName }
chapters { totalCount }
}
}
}
`;
export const GET_ALL_MANGA = `
query GetAllManga {
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount }
}
}
`;
export const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
`;
export const GET_CATEGORIES = `
query GetCategories {
categories {
nodes {
id name order default includeInUpdate includeInDownload
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
}
}
}
}
`;
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
query GetDownloadedChaptersPages {
chapters(condition: { isDownloaded: true }) {
nodes { pageCount }
}
}
`;
export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath {
settings { downloadsPath localSourcePath }
}
`;
export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus {
libraryUpdateStatus {
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
mangaUpdates {
status
manga { id title thumbnailUrl unreadCount }
}
}
}
`;
export const GET_RESTORE_STATUS = `
query GetRestoreStatus($id: String!) {
restoreStatus(id: $id) { mangaProgress state totalManga }
}
`;
export const VALIDATE_BACKUP = `
query ValidateBackup($backup: Upload!) {
validateBackup(input: { backup: $backup }) {
missingSources { id name }
missingTrackers { name }
}
}
`;
export const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
pageInfo { hasNextPage }
totalCount
}
}
`;
+171
View File
@@ -0,0 +1,171 @@
# Queries
## Manga (`queries/manga.ts`)
### `GET_LIBRARY`
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
**Variables:** none
---
### `GET_ALL_MANGA`
Fetches all manga (library and non-library) with minimal fields.
**Variables:** none
---
### `GET_MANGA`
Fetches a single manga by ID with full metadata and source info.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
---
### `GET_CATEGORIES`
Fetches all categories with their order, settings, and the manga assigned to each.
**Variables:** none
---
### `GET_DOWNLOADED_CHAPTERS_PAGES`
Fetches page counts for all downloaded chapters.
**Variables:** none
---
### `GET_DOWNLOADS_PATH`
Fetches the configured downloads path and local source path from settings.
**Variables:** none
---
### `LIBRARY_UPDATE_STATUS`
Fetches the current library update job status, including progress and any manga with new chapters.
**Variables:** none
---
### `GET_RESTORE_STATUS`
Fetches the status of a backup restore operation by its job ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `String!` | Restore job ID |
---
### `VALIDATE_BACKUP`
Validates a backup file and returns any missing sources or trackers.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
---
## Chapters (`queries/chapters.ts`)
### `GET_CHAPTERS`
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
## Downloads (`queries/downloads.ts`)
### `GET_DOWNLOAD_STATUS`
Fetches the current downloader state and full queue with chapter and manga info.
**Variables:** none
---
## Extensions (`queries/extensions.ts`)
### `GET_EXTENSIONS`
Fetches all extensions with install status, update availability, and metadata.
**Variables:** none
---
### `GET_SOURCES`
Fetches all available sources with language and NSFW flags.
**Variables:** none
---
### `GET_SETTINGS`
Fetches extension repository settings.
**Variables:** none
---
### `GET_SERVER_SECURITY`
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
**Variables:** none
---
## Tracking (`queries/tracking.ts`)
### `GET_TRACKERS`
Fetches all trackers with login status, supported scores, statuses, and auth info.
**Variables:** none
---
### `GET_MANGA_TRACK_RECORDS`
Fetches all tracking records for a specific manga across all trackers.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
### `SEARCH_TRACKER`
Searches a tracker for manga by query string.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `query` | `String!` | Search query |
---
### `GET_ALL_TRACKER_RECORDS`
Fetches all trackers and their full track records, including associated manga info.
**Variables:** none
---
### `GET_TRACKER_RECORDS`
Fetches track records for a specific tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
+69
View File
@@ -0,0 +1,69 @@
export const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn authUrl supportsPrivateTracking scores
statuses { value name }
}
}
}
`;
export const GET_MANGA_TRACK_RECORDS = `
query GetMangaTrackRecords($mangaId: Int!) {
manga(id: $mangaId) {
trackRecords {
nodes {
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private
}
}
}
}
`;
export const SEARCH_TRACKER = `
query SearchTracker($trackerId: Int!, $query: String!) {
searchTracker(input: { trackerId: $trackerId, query: $query }) {
trackSearches {
id trackerId remoteId title coverUrl summary
publishingStatus publishingType startDate totalChapters trackingUrl
}
}
}
`;
export const GET_ALL_TRACKER_RECORDS = `
query GetAllTrackerRecords {
trackers {
nodes {
id name icon isLoggedIn scores
statuses { value name }
trackRecords {
nodes {
id trackerId title status displayScore lastChapterRead
totalChapters remoteUrl private
manga { id title thumbnailUrl inLibrary }
}
}
}
}
}
`;
export const GET_TRACKER_RECORDS = `
query GetTrackerRecords($trackerId: Int!) {
trackers(condition: { id: $trackerId }) {
nodes {
id name
statuses { value name }
trackRecords {
nodes {
id title status displayScore lastChapterRead totalChapters remoteUrl
manga { id title thumbnailUrl }
}
}
}
}
}
`;
-48
View File
@@ -1,48 +0,0 @@
<script lang="ts">
import { store } from "../../store/state.svelte";
import Sidebar from "./Sidebar.svelte";
import Home from "../pages/Home.svelte";
import Library from "../pages/Library.svelte";
import SeriesDetail from "../pages/SeriesDetail.svelte";
import RecentActivity from "./RecentActivity.svelte";
import Search from "../pages/Search.svelte";
import Discover from "../pages/Discover.svelte";
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
import Downloads from "../pages/Downloads.svelte";
import Extensions from "../pages/Extensions.svelte";
import Tracking from "../pages/Tracking.svelte";
</script>
<div class="root">
<Sidebar />
<main class="main">
{#if store.activeManga}
<SeriesDetail />
{:else if store.navPage === "home"}
<Home />
{:else if store.navPage === "library"}
<Library />
{:else if store.navPage === "search"}
<Search />
{:else if store.navPage === "history"}
<RecentActivity />
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
<GenreDrillPage />
{:else if store.navPage === "explore" || store.navPage === "sources"}
<Discover />
{:else if store.navPage === "downloads"}
<Downloads />
{:else if store.navPage === "extensions"}
<Extensions />
{:else if store.navPage === "tracking"}
<Tracking />
{:else}
<Home />
{/if}
</main>
</div>
<style>
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
</style>
-323
View File
@@ -1,323 +0,0 @@
<script lang="ts">
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import { thumbUrl } from "../../lib/client";
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
let search = $state("");
let confirmClear = $state(false);
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" });
}
function dayLabel(ts: number): string {
const d = new Date(ts), now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
function formatReadTime(m: number): string {
if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
const SESSION_GAP_MS = 30 * 60 * 1000;
interface Session {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
latestChapterId: number;
latestChapterName: string;
latestPageNumber: number;
firstChapterName: string;
chapterCount: number;
readAt: number;
}
function buildSessions(entries: HistoryEntry[]): Session[] {
if (!entries.length) return [];
const sessions: Session[] = [];
let i = 0;
while (i < entries.length) {
const anchor = entries[i];
const group: HistoryEntry[] = [anchor];
let j = i + 1;
while (j < entries.length) {
const next = entries[j];
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
group.push(next); j++;
} else break;
}
const latest = group[0], oldest = group[group.length - 1];
sessions.push({
mangaId: latest.mangaId,
mangaTitle: latest.mangaTitle,
thumbnailUrl: latest.thumbnailUrl,
latestChapterId: latest.chapterId,
latestChapterName: latest.chapterName,
latestPageNumber: latest.pageNumber,
firstChapterName: oldest.chapterName,
chapterCount: group.length,
readAt: latest.readAt,
});
i = j;
}
return sessions;
}
const filtered = $derived(search.trim()
? store.history.filter((e) =>
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
e.chapterName.toLowerCase().includes(search.toLowerCase())
)
: store.history);
const sessions = $derived(buildSessions(filtered));
const groups = $derived.by(() => {
const map = new Map<string, Session[]>();
for (const s of sessions) {
const l = dayLabel(s.readAt);
if (!map.has(l)) map.set(l, []);
map.get(l)!.push(s);
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
});
// Resume: navigate to the manga's SeriesDetail (which will pick up from
// activeChapterList once chapters load). We can't hold a stale chapter list
// here — SeriesDetail fetches fresh chapters itself.
function resume(session: Session) {
setActiveManga({
id: session.mangaId,
title: session.mangaTitle,
thumbnailUrl: session.thumbnailUrl,
inLibrary: false,
} as any);
}
function handleClear() {
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
clearHistory(); confirmClear = false;
}
</script>
<div class="root">
<div class="header">
<span class="heading">History</span>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search history…" bind:value={search} />
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
</div>
{#if store.history.length > 0}
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
title={confirmClear ? "Click again to confirm" : "Clear history"}>
<Trash size={14} weight="light" />
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
</button>
{/if}
</div>
</div>
{#if store.readingStats.totalChaptersRead > 0}
<div class="stats-bar">
<div class="stat-group">
<Fire size={13} weight="fill" class="stat-fire" />
<span class="stat-val accent">{store.readingStats.currentStreakDays}</span>
<span class="stat-label">day streak</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
<span class="stat-label">chapters</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<Clock size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
<span class="stat-label">read time</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
<span class="stat-label">series</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<span class="stat-val muted">{store.readingStats.longestStreakDays}d</span>
<span class="stat-label">best streak</span>
</div>
<span class="stats-note">Stats are preserved when you clear the feed</span>
</div>
{/if}
{#if store.history.length === 0}
<div class="empty">
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
<p class="empty-text">No reading history yet</p>
<p class="empty-hint">Chapters you read will appear here</p>
</div>
{:else if sessions.length === 0}
<div class="empty">
<Books size={28} weight="light" class="empty-icon" />
<p class="empty-text">No results for "{search}"</p>
</div>
{:else}
<div class="timeline">
{#each groups as { label, items }}
<div class="day-group">
<div class="day-label-row">
<span class="day-label">{label}</span>
<div class="day-line"></div>
</div>
<div class="session-list">
{#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => resume(session)}>
<div class="thumb-wrap">
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span>
{/if}
</div>
<div class="session-info">
<span class="session-title">{session.mangaTitle}</span>
<span class="session-chapter">
{#if session.chapterCount > 1}
{session.firstChapterName}
<span class="ch-arrow"></span>
{session.latestChapterName}
{:else}
{session.latestChapterName}
{#if session.latestPageNumber > 1}
<span class="ch-page">p.{session.latestPageNumber}</span>
{/if}
{/if}
</span>
</div>
<span class="session-time">{timeAgo(session.readAt)}</span>
<div class="play-pill">
<Play size={10} weight="fill" /> Resume
</div>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</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-right { display: flex; align-items: center; gap: var(--sp-2); }
.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 26px; 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); }
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.search-clear:hover { color: var(--text-muted); }
.clear-btn {
display: flex; align-items: center; gap: 5px;
height: 28px; padding: 0 var(--sp-2); border-radius: var(--radius-md);
color: var(--text-faint); background: none; border: 1px solid transparent;
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.clear-label { font-size: var(--text-2xs); }
.stats-bar {
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
background: var(--bg-raised); flex-shrink: 0;
}
.stat-group { display: flex; align-items: center; gap: 5px; }
.stat-sep { width: 1px; height: 14px; background: var(--border-dim); flex-shrink: 0; }
:global(.stat-fire) { color: #f97316; }
:global(.stat-icon-neutral) { color: var(--text-faint); }
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.stat-val.accent { color: var(--accent-fg); }
.stat-val.muted { color: var(--text-faint); }
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
.day-group { margin-bottom: var(--sp-5); }
.day-label-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
.day-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
.day-line { flex: 1; height: 1px; background: var(--border-dim); }
.session-list { display: flex; flex-direction: column; gap: 2px; }
.session-row {
display: flex; align-items: center; gap: var(--sp-3);
width: 100%; padding: 8px 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);
}
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
.thumb-wrap { position: relative; flex-shrink: 0; }
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.session-count {
position: absolute; bottom: -4px; right: -6px;
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
}
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
.play-pill {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim);
padding: 3px 8px; border-radius: var(--radius-full);
opacity: 0; transform: translateX(4px);
transition: opacity var(--t-base), transform var(--t-base);
}
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
:global(.empty-icon) { color: var(--text-faint); }
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
-70
View File
@@ -1,70 +0,0 @@
<script lang="ts">
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
import type { NavPage } from "../../store/state.svelte";
const TABS: { id: NavPage; label: string; icon: any }[] = [
{ id: "home", label: "Home", icon: House },
{ id: "library", label: "Library", icon: Books },
{ id: "search", label: "Search", icon: MagnifyingGlass },
{ id: "history", label: "History", icon: ClockCounterClockwise },
{ id: "explore", label: "Discover", icon: Compass },
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
];
function navigate(id: NavPage) {
store.navPage = id;
store.activeManga = null;
store.genreFilter = "";
if (id !== "explore") store.activeSource = null;
}
function goHome() {
store.navPage = "home";
store.activeSource = null;
store.activeManga = null;
store.libraryFilter = "library";
store.genreFilter = "";
}
</script>
<aside class="root">
<button class="logo" onclick={goHome} title="Home" aria-label="Go to Home">
<div class="logo-icon"></div>
</button>
<nav class="nav">
{#each TABS as tab}
<button class="tab" class:active={store.navPage === tab.id}
title={tab.label} onclick={() => navigate(tab.id)}>
<tab.icon size={18} weight="light" />
</button>
{/each}
</nav>
<div class="bottom">
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
<GearSix size={18} weight="light" />
</button>
</div>
</aside>
<style>
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
.logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
.logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); }
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
.nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.nav::-webkit-scrollbar { display: none; }
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
.bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
</style>
-144
View File
@@ -1,144 +0,0 @@
<script lang="ts">
import { store, dismissToast } from "../../store/state.svelte";
import type { Toast } from "../../store/state.svelte";
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function schedule(t: Toast) {
if (timers.has(t.id)) return;
const dur = t.duration ?? 3500;
if (dur === 0) return;
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
}
$effect(() => {
store.toasts.forEach(schedule);
return () => timers.forEach(clearTimeout);
});
const icons: Record<Toast["kind"], string> = {
success: "M20 6L9 17l-5-5",
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
};
</script>
{#if store.toasts.length}
<div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)}
<button
class="toast toast-{t.kind}"
role="alert"
onclick={() => dismissToast(t.id)}
>
<div class="accent-bar"></div>
<span class="icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} />
</svg>
</span>
<div class="body">
<p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if}
</div>
</button>
{/each}
</div>
{/if}
<style>
.toaster {
position: fixed;
bottom: var(--sp-5);
right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: 300px;
}
.toast {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 10px var(--sp-3) 10px 0;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
pointer-events: all;
min-width: 200px;
overflow: hidden;
cursor: pointer;
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 0.15s ease, transform 0.15s ease;
font-family: inherit;
font-size: inherit;
color: inherit;
text-align: left;
}
.toast:hover { opacity: 0.85; transform: translateX(-2px); }
.toast:active { transform: translateX(0) scale(0.98); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(16px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
.accent-bar {
width: 3px;
align-self: stretch;
flex-shrink: 0;
border-radius: 0 2px 2px 0;
margin-right: 2px;
}
.toast-success .accent-bar { background: var(--accent-fg); }
.toast-error .accent-bar { background: var(--color-error); }
.toast-info .accent-bar { background: var(--text-faint); }
.toast-download .accent-bar { background: var(--accent-fg); }
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success .icon { color: var(--accent-fg); }
.toast-error .icon { color: var(--color-error); }
.toast-info .icon { color: var(--text-muted); }
.toast-download .icon { color: var(--accent-fg); }
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: var(--text-xs);
font-family: var(--font-ui);
color: var(--text-secondary);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
line-height: 1.3;
}
.sub {
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;
}
</style>
-415
View File
@@ -1,415 +0,0 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source, Category } 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 = 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 {
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
}
}
`;
const MANGAS_BY_GENRE = `
query MangasByGenre($genre: String!, $first: Int) {
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
}
}
`;
function dKey(srcId: string, type: string, genre: string, page: number) {
return `${srcId}|${type}|${genre}:p${page}`;
}
// ── 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 ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
// ── Helpers ───────────────────────────────────────────────────────────────
function dedup(items: Manga[]): Manga[] {
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
}
function filterOut(mangas: Manga[]): Manga[] {
return dedup(mangas.filter(m => {
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
if (shouldHideNsfw(m, store.settings)) return false;
return true;
}));
}
function rotatedSources(): Source[] {
const lang = store.settings.preferredExtensionLang || "en";
const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
const srcs = dedupeSources(eligible, lang);
if (!srcs.length) return [];
const off = store.discoverSrcOffset % srcs.length;
return [...srcs.slice(off), ...srcs.slice(0, off)];
}
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
let i = 0;
const worker = async () => {
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));
}
// 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 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();
if (!srcs.length) return;
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 => {
for (let page = 1; page <= maxPages; page++) {
if (ctrl.signal.aborted) return;
const key = dKey(src.id, type, genre, page);
let mangas: Manga[];
let hasNextPage = false;
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 (!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);
}
// ── Tab switch ────────────────────────────────────────────────────────────
async function switchGenre(genre: string) {
if (currentGenre === genre) return;
activeCtrl?.abort();
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;
await fanOut("All", ctrl);
if (!ctrl.signal.aborted) genreLoading = false;
return;
}
// Genre tab: serve cached local results instantly, always fan out too
const localKey = `local|${genre}`;
if (store.discoverCache.has(localKey)) {
genreResults.set(genre, store.discoverCache.get(localKey)!);
genreResults = new Map(genreResults);
fanOut(genre, ctrl).catch(() => {});
return;
}
genreLoading = true;
try {
const d = await gql<{ mangas: { nodes: Manga[] } }>(
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
);
if (ctrl.signal.aborted) return;
const local = dedup(
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
);
store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT));
genreResults = new Map(genreResults);
genreLoading = false;
fanOut(genre, ctrl).catch(() => {});
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
if (!ctrl.signal.aborted) genreLoading = false;
}
}
// ── Refresh ───────────────────────────────────────────────────────────────
async function refresh() {
activeCtrl?.abort();
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
genreResults = new Map();
refreshing = true;
genreLoading = true;
const genre = currentGenre;
currentGenre = "";
await new Promise(r => setTimeout(r, 20));
await switchGenre(genre);
refreshing = false;
}
// ── Initial load ──────────────────────────────────────────────────────────
function loadAll() {
loadingLib = true;
loadError = false;
// 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 => {
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 All tab fan-out (only if grid is empty)
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => {
allSources = d.sources.nodes;
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;
}).catch(() => {});
}
})
.catch(console.error);
}
onDestroy(() => { activeCtrl?.abort(); });
loadAll();
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
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(() => {
cache.clear(CACHE_KEYS.LIBRARY);
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
}).catch(console.error),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map(cat => ({
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 n = prompt("Folder name:");
if (!n?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.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);
}
},
},
];
}
</script>
{#if store.activeSource}
<SourceBrowse />
{:else}
<div class="root">
<div class="header">
<span class="heading">Discover</span>
<div class="tab-strip">
{#each GENRE_TABS as tab (tab)}
<button
class="genre-tab"
class:active={currentGenre === tab}
onclick={() => switchGenre(tab)}
>
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
{tab}
</button>
{/each}
</div>
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
<ArrowsClockwise size={13} weight="bold" />
</button>
</div>
<div class="body">
{#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>
{/each}
</div>
{:else if loadError && visibleGrid.length === 0}
<div class="empty">
<span>Could not reach Suwayomi</span>
<button class="retry-btn" onclick={loadAll}>Retry</button>
</div>
{:else if visibleGrid.length === 0}
<div class="empty"><span>Nothing found for "{currentGenre}"</span></div>
{:else}
<div class="manga-grid">
{#each visibleGrid as m (m.id)}
<button
class="manga-card"
onclick={() => setPreviewManga(m)}
oncontextmenu={(e) => openCtx(e, m)}
>
<div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
<div class="cover-gradient"></div>
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
<div class="card-footer">
<p class="card-title">{m.title}</p>
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{#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-4); flex-shrink: 0; padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); overflow-x: auto; scrollbar-width: none; }
.header::-webkit-scrollbar { display: none; }
.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; }
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.genre-tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.refresh-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; margin-left: auto; transition: color var(--t-base), background var(--t-base); }
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.manga-card:hover .card-title { color: #fff; }
.manga-card:hover { will-change: transform; }
.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 8px rgba(0,0,0,0.25); }
.cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
.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); transition: color var(--t-base); }
.card-source { 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; }
.card-skeleton { padding: 0; }
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.skeleton { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); padding: var(--sp-10) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
.retry-btn { 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-btn.spinning { opacity: 0.5; cursor: default; }
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
-181
View File
@@ -1,181 +0,0 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types";
let status: DownloadStatus | null = $state(null);
let loading = $state(true);
let togglingPlay = $state(false);
let clearing = $state(false);
let dequeueing = $state(new Set<number>());
let interval: ReturnType<typeof setInterval>;
function applyStatus(ds: DownloadStatus) {
status = ds;
setActiveDownloads(ds.queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
})));
}
async function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => loading = false);
}
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
async function togglePlay() {
if (togglingPlay) return;
togglingPlay = true;
const wasRunning = status?.state === "STARTED";
if (status) status = { ...status, state: wasRunning ? "STOPPED" : "STARTED" };
try {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) { console.error(e); poll(); }
finally { togglingPlay = false; }
}
async function clear() {
if (clearing) return;
clearing = true;
if (status) status = { ...status, queue: [] };
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
applyStatus(d.clearDownloader.downloadStatus);
} catch (e) { console.error(e); poll(); }
finally { clearing = false; }
}
async function dequeue(chapterId: number) {
if (dequeueing.has(chapterId)) return;
dequeueing = new Set(dequeueing).add(chapterId);
if (status) status = { ...status, queue: status.queue.filter((i) => i.chapter.id !== chapterId) };
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); poll(); }
catch (e) { console.error(e); poll(); }
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
}
let queue = $derived(status?.queue ?? []);
const isRunning = $derived(status?.state === "STARTED");
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else if isRunning}<Pause size={14} weight="fill" />
{:else}<Play size={14} weight="fill" />{/if}
</button>
<button class="icon-btn" class:loading={clearing} onclick={clear}
disabled={clearing || queue.length === 0} title="Clear queue">
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<Trash size={14} weight="regular" />{/if}
</button>
</div>
</div>
<div class="content">
<div class="status-bar">
<div class="status-dot" class:active={isRunning}></div>
<span class="status-text">
{togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"}
</span>
<span class="status-count">{queue.length} queued</span>
</div>
{#if loading}
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></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)}
{@const isActive = i === 0 && isRunning}
{@const pages = item.chapter.pageCount ?? 0}
{@const done = Math.round(item.progress * pages)}
{@const manga = item.chapter.manga}
{@const isRemoving = dequeueing.has(item.chapter.id)}
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#if manga?.thumbnailUrl}
<div class="thumb">
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" />
</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}
<span class="pages-label">{isActive ? `${done} / ${pages} pages` : `${pages} pages`}</span>
{/if}
{#if isActive}
<div class="progress-wrap">
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
</div>
{/if}
</div>
<div class="row-right">
<span class="state-label">{item.state}</span>
{#if !isActive}
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div><!-- .content -->
</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); }
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) 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: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.status-bar { 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); }
.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; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
.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); }
.row.row-active { border-color: var(--accent-dim); }
.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); }
.thumb-img { width: 100%; height: 100%; object-fit: cover; }
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; 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; }
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.progress-wrap { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; margin-top: 4px; }
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
.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; }
.remove-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.remove-btn:disabled { opacity: 0.5; cursor: default; }
.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); }
</style>
-351
View File
@@ -1,351 +0,0 @@
<script lang="ts">
import { untrack } from "svelte";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
import { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types";
type Filter = "installed" | "available" | "updates" | "all";
type Panel = null | "apk" | "repos";
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
let extensions: Extension[] = $state([]);
let loading = $state(true);
let refreshing = $state(false);
let filter: Filter = $state("installed");
let search = $state("");
let working = $state(new Set<string>());
let expanded = $state(new Set<string>());
let panel: Panel = $state(null);
let externalUrl = $state("");
let installing = $state(false);
let installError: string|null = $state(null);
let installSuccess = $state(false);
let repos: string[] = $state([]);
let reposLoading = $state(false);
let newRepoUrl = $state("");
let repoError: string|null = $state(null);
let savingRepos = $state(false);
async function load() {
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
.then((d) => extensions = d.extensions.nodes).catch(console.error);
}
async function fetchFromRepo() {
refreshing = true;
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
.then((d) => extensions = d.fetchExtensions.extensions).catch(console.error)
.finally(() => refreshing = false);
}
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[]) {
savingRepos = true;
try { const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated }); repos = d.setSettings.settings.extensionRepos; }
catch (e: any) { repoError = e instanceof Error ? e.message : "Failed to save"; } finally { savingRepos = false; }
}
function addRepo() {
const url = newRepoUrl.trim();
if (!url) return;
if (!url.startsWith("http://") && !url.startsWith("https://")) { repoError = "URL must start with http:// or https://"; return; }
if (repos.includes(url)) { repoError = "Repo already added"; return; }
repoError = null; newRepoUrl = "";
saveRepos([...repos, url]);
}
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
async function mutate(fn: () => Promise<unknown>, pkgName: string) {
working = new Set(working).add(pkgName);
await fn().catch(console.error);
await load();
working.delete(pkgName); working = new Set(working);
}
async function installExternal() {
const url = externalUrl.trim();
if (!url) return;
if (!url.startsWith("http://") && !url.startsWith("https://")) { installError = "URL must start with http:// or https://"; return; }
if (!url.endsWith(".apk")) { installError = "URL must point to an .apk file"; return; }
installing = true; installError = null; installSuccess = false;
try {
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
installSuccess = true; externalUrl = "";
await load();
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
} catch (e: any) { installError = e instanceof Error ? e.message : "Install failed"; }
finally { installing = false; }
}
function openPanel(p: Panel) {
panel = panel === p ? null : p;
installError = null; installSuccess = false; externalUrl = "";
repoError = null; newRepoUrl = "";
if (p === "repos") loadRepos();
}
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
const filtered = $derived(extensions.filter((e) => {
const q = search.toLowerCase();
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
return matchSearch && matchFilter;
}));
const groups = $derived.by(() => {
const map = new Map<string, Extension[]>();
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
const preferredLang = store.settings.preferredExtensionLang;
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) };
});
});
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
const FILTERS: { id: Filter; label: string }[] = [
{ id: "installed", label: "Installed" },
{ id: "available", label: "Available" },
{ id: "updates", label: "Updates" },
{ id: "all", label: "All" },
];
function toggleExpand(base: string) {
const next = new Set(expanded);
next.has(base) ? next.delete(base) : next.add(base);
expanded = next;
}
</script>
<div class="root">
<div class="header">
<h1 class="heading">Extensions</h1>
<div class="tabs">
{#each FILTERS as f}
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
{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" bind:value={search} />
</div>
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
<GitBranch size={14} weight="light" />
</button>
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
<Plus size={14} weight="light" />
</button>
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
</div>
</div>
{#if panel === "apk"}
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Install from APK URL</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</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">
<div class="panel-header">
<span class="panel-title">Extension Repositories</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</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" style="margin-top:var(--sp-2)">
<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 if groups.length === 0}
<div class="empty">No extensions found.</div>
{:else}
<div class="list">
{#each groups as { base, primary, variants }}
{@const isExpanded = expanded.has(base)}
{@const hasVariants = variants.length > 0}
<div class="group">
<div class="row">
<img src={thumbUrl(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> 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={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
</div>
{:else if primary.isInstalled}
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
{:else}
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
{/if}
{#if hasVariants}
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
<span class="expand-count">{variants.length + 1}</span>
</button>
{/if}
</div>
{#if isExpanded && hasVariants}
<div class="variants">
{#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={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
{:else if v.isInstalled}
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
{:else}
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<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-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; }
.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; }
.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); }
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
.panel-header { display: flex; align-items: center; justify-content: space-between; }
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.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-raised); 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.1); }
.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-3); }
.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; }
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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); }
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
.tab { 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); }
.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); }
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
.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); }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.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); }
.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); }
.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); animation: fadeIn 0.1s ease both; }
.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 { 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>
-646
View File
@@ -1,646 +0,0 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types";
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" });
}
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`;
}
function focusEl(node: HTMLElement) { node.focus(); }
let libraryManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true);
let completedCategory: Category | null = $state(null);
onMount(() => {
loadLibrary();
});
function loadLibrary() {
const libraryP = cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
);
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
.catch(() => null);
Promise.all([libraryP, categoriesP])
.then(([m, completed]) => {
libraryManga = m;
completedCategory = completed;
fetchExtraCompleted(m, completed);
})
.catch(console.error)
.finally(() => loadingLibrary = false);
}
// Re-fetch library and reset hero chapters whenever the reader closes,
// so the hero reflects the latest-read chapter immediately.
$effect(() => {
const sessionId = store.readerSessionId;
if (sessionId === 0) return; // skip initial mount — onMount handles that
cache.clear(CACHE_KEYS.LIBRARY);
loadingLibrary = true;
heroChapters = [];
heroAllChapters = [];
heroChaptersFor = null;
loadLibrary();
});
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? [];
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
if (!missingIds.length) return;
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
if (valid.length) extraManga = valid;
}
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 heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : "");
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
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(heroEntry?.mangaId ?? heroManga?.id ?? 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();
}
onMount(() => {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
let heroStageH = $state(300);
let heroChapters: Chapter[] = $state([]);
let heroAllChapters: Chapter[] = $state([]);
let loadingHeroChapters = $state(false);
let heroChaptersFor: number | null = null;
$effect(() => {
const id = heroMangaId;
if (id && id !== heroChaptersFor) 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 lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
const startIdx = Math.max(0, lastReadIdx);
heroChapters = all.slice(startIdx, startIdx + 5);
} catch { heroChapters = []; heroAllChapters = []; }
finally { loadingHeroChapters = false; }
}
let resuming = $state(false);
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) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
store.activeManga = manga;
openReader(chapter, all);
}
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
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 chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
if (ch) {
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
openReader(ch, chapters);
}
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; }
}
async function resumeEntry(entry: HistoryEntry) {
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
if (ch) {
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
openReader(ch, chapters);
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
}
let pickerOpen = $state(false);
let pickerSlotIndex: 1|2|3|null = $state(null);
let pickerSearch = $state("");
const pickerResults = $derived(pickerSearch.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(pickerSearch.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20));
function openPicker(i: 1|2|3) { pickerSlotIndex = i; pickerOpen = true; pickerSearch = ""; }
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 completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []);
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 7) : []);
const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats);
function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
e.stopPropagation();
}
</script>
<div class="root">
<div class="body">
<div class="hero-section">
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
<div class="hero-scrim"></div>
<button class="hero-cover-col" onclick={resumeActive} 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={18} 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={() => openPicker(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}
{#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={resumeActive} 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={() => unpinSlot(activeSlot.slotIndex as 1|2|3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => openPicker(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={cyclePrev} 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={() => goToSlot(i)} aria-label="Slot {i + 1}"></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={cycleNext} 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={() => openChapter(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={() => { if (heroManga) store.activeManga = heroManga; }}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if recentHistory.length > 0}
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
{/if}
</div>
<div class="activity-list">
{#if recentHistory.length > 0}
{#each recentHistory as entry (entry.chapterId)}
<button class="activity-row" onclick={() => resumeEntry(entry)}>
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" />
<div class="activity-info">
<span class="activity-title">{entry.mangaTitle}</span>
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
</div>
<span class="activity-time">{timeAgo(entry.readAt)}</span>
<span class="activity-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="activity-placeholder">
{#each Array(5) as _, i}
<div class="activity-row activity-row-sk">
<div class="sk-thumb"></div>
<div class="activity-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="activity-placeholder-overlay">
<button class="activity-placeholder-cta" onclick={() => setNavPage("library")}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<div class="bottom-row">
<div class="bottom-col">
<div class="bottom-section-hd">
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
{#if completedManga.length > 0}
<button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
{/if}
</div>
{#if completedManga.length > 0}
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
{#each completedManga as m (m.id)}
<button class="mini-card" onclick={() => store.previewManga = m}>
<div class="mini-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
<div class="mini-gradient"></div>
<div class="mini-footer">
<p class="mini-card-title">{m.title}</p>
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="bottom-empty">Finish a manga to see it here</p>
{/if}
</div>
<div class="bottom-divider"></div>
<div class="bottom-col">
<div class="bottom-section-hd">
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
</div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-icon-wrap stat-fire"><Fire size={16} weight="fill" /></div><div class="stat-body"><span class="stat-val">{stats.currentStreakDays}</span><span class="stat-label">Day streak</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-green"><CheckCircle size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{completedIds.length}</span><span class="stat-label">Completed</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
</div>
</div>
</div>
</div>
</div>
{#if pickerOpen}
<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>
<button class="picker-close" onclick={closePicker}><XIcon size={13} weight="light" /></button>
</div>
<div class="picker-search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
</div>
<div class="picker-list">
{#if loadingLibrary}
<p class="picker-empty">Loading…</p>
{:else if pickerResults.length === 0}
<p class="picker-empty">No results</p>
{:else}
{#each pickerResults as m (m.id)}
<button class="picker-row" onclick={() => pinManga(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" />
<div class="picker-info">
<span class="picker-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s 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: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); 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-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
.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-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
.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-medium); 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 10px rgba(0,0,0,0.5); }
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); 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.58); letter-spacing: var(--tracking-wide); }
.hero-prog-page { color: rgba(255,255,255,0.38); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -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.5); flex-shrink: 0; }
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
.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 16px; border-radius: var(--radius-full); 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.15); }
.hero-cta:disabled { opacity: 0.55; 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-full); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.13); color: rgba(255,255,255,0.52); cursor: pointer; transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
.hero-cta-ghost:hover { background: rgba(255,255,255,0.13); 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-2); border-top: 1px solid rgba(255,255,255,0.08); }
.hero-nav-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.12); color: rgba(255,255,255,0.6); cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
.hero-nav-btn:hover { background: rgba(255,255,255,0.2); 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.22); border: none; cursor: pointer; padding: 0; transition: background var(--t-base), transform var(--t-base); }
.hero-dot:hover { background: rgba(255,255,255,0.5); }
.hero-dot.active { background: #fff; transform: scale(1.35); }
.hero-dot.pinned { background: rgba(168,132,232,0.55); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter { font-family: var(--font-ui); font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); margin-left: auto; }
.hero-chapters { position: relative; z-index: 2; width: clamp(180px, 32%, 240px); 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-xs); color: rgba(255,255,255,0.4); 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.08); flex-shrink: 0; }
.hero-chapters-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.25); 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.35); 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.75); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-row-read .ch-name { color: rgba(255,255,255,0.35); }
.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.28); letter-spacing: var(--tracking-wide); }
.ch-read { color: rgba(255,255,255,0.2); }
: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); }
.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.3); 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); }
.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-sm); 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); }
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.activity-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); }
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.activity-row:hover .activity-play { opacity: 1; }
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-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; }
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-4); padding-bottom: var(--sp-5); }
.bottom-col:first-child { padding-right: var(--sp-4); }
.bottom-col:last-child { padding-left: var(--sp-4); }
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
.mini-row::-webkit-scrollbar { display: none; }
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; }
.mini-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 12px rgba(0,0,0,0.35); }
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.mini-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); }
.mini-card-source { 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; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.stat-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) var(--sp-3); }
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-sm); flex-shrink: 0; }
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.stat-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; }
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.activity-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); }
.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); }
.activity-placeholder { position: relative; }
.activity-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%); }
.activity-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.14); color: rgba(255,255,255,0.65); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
.activity-placeholder-cta:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.9); }
.picker-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); 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); }
.picker-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; }
.picker-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; }
.picker-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.picker-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; }
.picker-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.picker-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; }
.picker-search { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
.picker-search::placeholder { color: var(--text-faint); }
.picker-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.picker-list::-webkit-scrollbar { display: none; }
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
.picker-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); }
.picker-row:hover { background: var(--bg-raised); }
.picker-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; }
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picker-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) } }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-637
View File
@@ -1,637 +0,0 @@
<script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import {
GET_ALL_TRACKER_RECORDS,
UPDATE_TRACK,
UNBIND_TRACK,
FETCH_TRACK,
} from "../../lib/queries";
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Tracker, TrackRecord } from "../../lib/types";
// ── Types ──────────────────────────────────────────────────────────────────
interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] };
}
interface FlatRecord extends TrackRecord {
tracker: Tracker;
}
// ── State ──────────────────────────────────────────────────────────────────
let trackers: TrackerWithRecords[] = $state([]);
let loading: boolean = $state(true);
let error: string | null = $state(null);
// Filter/view state
let activeTrackerId: number | "all" = $state("all");
let statusFilter: number | "all" = $state("all");
let searchQuery: string = $state("");
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
// Mutation state
let updatingId: number | null = $state(null);
let syncingId: number | null = $state(null);
// Chapter editing: recordId → draft value
let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0);
// ── Load ───────────────────────────────────────────────────────────────────
async function load() {
loading = true; error = null;
try {
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
trackers = res.trackers.nodes;
} catch (e: any) {
error = e?.message ?? "Failed to load tracking data";
} finally {
loading = false;
}
}
$effect(() => { load(); });
// ── Derived data ───────────────────────────────────────────────────────────
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
const allRecords: FlatRecord[] = $derived(
loggedInTrackers.flatMap(t =>
t.trackRecords.nodes.map(r => ({
...r,
trackerId: r.trackerId ?? t.id, // fallback in case field is missing
tracker: t as Tracker,
}))
)
);
const totalCount = $derived(allRecords.length);
// Status options across active tracker
const statusOptions = $derived.by(() => {
if (activeTrackerId === "all") {
// Merge all statuses, dedupe by value+name
const seen = new Map<string, { value: number; name: string }>();
for (const t of loggedInTrackers) {
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
}
return [...seen.values()];
}
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
});
const filtered = $derived.by(() => {
let list = activeTrackerId === "all"
? allRecords
: allRecords.filter(r => Number(r.trackerId) === Number(activeTrackerId));
if (statusFilter !== "all")
list = list.filter(r => Number(r.status) === Number(statusFilter));
if (searchQuery.trim())
list = list.filter(r =>
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.manga?.title?.toLowerCase().includes(searchQuery.toLowerCase())
);
return [...list].sort((a, b) => {
if (sortBy === "title") return a.title.localeCompare(b.title);
if (sortBy === "status") return a.status - b.status;
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
if (sortBy === "progress") {
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
return bp - ap;
}
return 0;
});
});
// ── Mutations ──────────────────────────────────────────────────────────────
async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, status }
);
patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
updatingId = null;
}
}
async function updateScore(record: FlatRecord, scoreString: string) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, scoreString }
);
patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
updatingId = null;
}
}
async function syncRecord(record: FlatRecord) {
syncingId = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
FETCH_TRACK, { recordId: record.id }
);
patchRecord(record.trackerId, res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally {
syncingId = null;
}
}
async function unbind(record: FlatRecord) {
updatingId = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
trackers = trackers.map(t =>
t.id !== record.trackerId ? t : {
...t,
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) }
}
);
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
} catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally {
updatingId = null;
}
}
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
trackers = trackers.map(t =>
t.id !== trackerId ? t : {
...t,
trackRecords: {
nodes: t.trackRecords.nodes.map(r => r.id === updated.id ? { ...r, ...updated } : r)
}
}
);
}
function openManga(record: FlatRecord) {
if (!record.manga) return;
setActiveManga(record.manga as any);
setNavPage("library");
}
function openChapterEditor(record: FlatRecord) {
editingChapter = record.id;
chapterDraft = record.lastChapterRead;
}
function cancelChapterEditor() {
editingChapter = null;
}
async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
);
patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
updatingId = null;
}
}
</script>
<div class="page">
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<div class="header">
<div class="header-top">
<h1 class="heading">Tracking</h1>
<div class="header-actions">
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
</button>
</div>
</div>
<!-- Tracker filter tabs -->
{#if !loading && loggedInTrackers.length > 0}
<div class="tracker-tabs">
<button
class="tracker-tab"
class:tab-active={activeTrackerId === "all"}
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
>
All
<span class="tab-count">{totalCount}</span>
</button>
{#each loggedInTrackers as t}
{@const count = t.trackRecords.nodes.length}
<button
class="tracker-tab"
class:tab-active={activeTrackerId === t.id}
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
>
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" />
{t.name}
<span class="tab-count">{count}</span>
</button>
{/each}
</div>
<!-- Filter + sort bar -->
<div class="filter-bar">
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" class="search-ico" />
<input
class="filter-search"
placeholder="Search titles…"
bind:value={searchQuery}
/>
</div>
<div class="filter-right">
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<select class="filter-select" bind:value={statusFilter}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
statusFilter = v === "all" ? "all" : parseInt(v);
}}>
<option value="all">All statuses</option>
{#each statusOptions as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="filter-select" bind:value={sortBy}>
<option value="title">Sort: Title</option>
<option value="status">Sort: Status</option>
<option value="score">Sort: Score</option>
<option value="progress">Sort: Progress</option>
</select>
</div>
</div>
{/if}
</div>
<!-- ── Body ────────────────────────────────────────────────────────────── -->
<div class="page-body">
{#if loading}
<div class="state-center">
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="state-label">Loading tracking data…</span>
</div>
{:else if error}
<div class="state-center">
<p class="state-error">{error}</p>
<button class="retry-btn" onclick={load}>Retry</button>
</div>
{:else if loggedInTrackers.length === 0}
<div class="state-center">
<p class="state-text">No trackers connected.</p>
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p>
</div>
{:else if filtered.length === 0}
<div class="state-center">
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p>
{#if searchQuery || statusFilter !== "all"}
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
{/if}
</div>
{:else}
<div class="records-list">
{#each filtered as record (record.tracker.id + ":" + record.id)}
{@const tracker = record.tracker}
{@const isBusy = updatingId === record.id}
{@const isSyncing = syncingId === record.id}
{@const progress = record.totalChapters > 0
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
: null}
<div class="record-card" class:record-busy={isBusy}>
<!-- Cover -->
<div class="record-cover-wrap" role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
{#if record.manga?.thumbnailUrl}
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" />
{:else}
<div class="record-cover record-cover-empty"></div>
{/if}
<!-- Tracker badge -->
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
</div>
<!-- Info -->
<div class="record-body">
<div class="record-top">
<div class="record-titles" role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="record-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="record-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="record-header-actions">
{#if activeTrackerId === "all"}
<span class="record-tracker-label">
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
{record.tracker.name}
</span>
{/if}
{#if isSyncing}
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else}
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={12} weight="light" />
</button>
{/if}
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}">
<ArrowSquareOut size={12} weight="light" />
</a>
{/if}
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}>
<X size={12} weight="bold" />
</button>
</div>
</div>
<!-- Controls row -->
<div class="record-controls">
<select
class="record-select"
value={record.status}
disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
>
{#each (tracker.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select
class="record-select record-select-score"
value={record.displayScore}
disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
>
{#each (tracker.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
{#if record.private}
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
{/if}
</div>
<!-- Progress / Chapter editor -->
{#if editingChapter === record.id}
<div class="chapter-editor">
<div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span>
<div class="chapter-input-wrap">
<input
type="number"
class="chapter-input"
min="0"
max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
use:focusEl
/>
{#if record.totalChapters > 0}
<span class="chapter-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
{/if}
<div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
</div>
</div>
{:else if progress !== null}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<div class="progress-track">
<div class="progress-fill" style="width:{progress}%"></div>
</div>
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
<span class="progress-edit-hint"></span>
</div>
{:else}
<div class="record-progress clickable no-total" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter"
>
<span class="progress-label">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"}
</span>
<span class="progress-edit-hint"></span>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
/* ── Header ─────────────────────────────────────────────────────────────── */
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
.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; align-items: center; gap: var(--sp-2); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
.tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; }
.tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab {
display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: none;
border-bottom: 2px solid transparent;
border-radius: 0; cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base);
margin-bottom: -1px;
}
.tracker-tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
.tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-count { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
/* ── Filter bar ─────────────────────────────────────────────────────────── */
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; }
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.filter-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 24px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 7px center;
transition: border-color var(--t-base), color var(--t-base);
}
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* ── Body ───────────────────────────────────────────────────────────────── */
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
/* ── States ─────────────────────────────────────────────────────────────── */
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* ── Records list ───────────────────────────────────────────────────────── */
.records-list { display: flex; flex-direction: column; gap: 2px; }
.record-card {
display: flex; align-items: flex-start; gap: var(--sp-4);
padding: var(--sp-3) var(--sp-3);
border-radius: var(--radius-md);
background: none;
transition: background var(--t-fast), opacity var(--t-base);
}
.record-card:hover { background: var(--bg-raised); }
.record-busy { opacity: 0.4; pointer-events: none; }
/* Cover */
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
.record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); }
.record-cover-empty { background: var(--bg-overlay); }
.record-cover-wrap:hover .record-cover { opacity: 0.75; }
.record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); }
/* Body */
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; }
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
.record-titles:hover .record-title { color: var(--accent-fg); }
.record-local-title { 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; }
.record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
.record-tracker-label { 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); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
.record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
/* Controls */
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 22px 3px 7px; border-radius: var(--radius-sm);
border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
.record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 86px; }
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
/* Progress */
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
.progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
.record-progress.clickable:hover { background: var(--bg-overlay); }
.record-progress.clickable:hover .progress-label { color: var(--text-muted); }
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); }
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
/* Chapter editor */
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
.chapter-input:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
<script module>
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-575
View File
@@ -1,575 +0,0 @@
<script lang="ts">
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
import {
store, updateSettings, saveCustomTheme, deleteCustomTheme,
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
} from "../../store/state.svelte";
interface Props {
editingId?: string | null;
onClose: () => void;
}
let { editingId = $bindable(null), onClose }: Props = $props();
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{
label: "Backgrounds",
tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"],
},
{
label: "Borders",
tokens: ["border-dim", "border-base", "border-strong", "border-focus"],
},
{
label: "Text",
tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"],
},
{
label: "Accent",
tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"],
},
{
label: "Semantic",
tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"],
},
];
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
"bg-void": "Void (deepest bg)",
"bg-base": "Base",
"bg-surface": "Surface",
"bg-raised": "Raised",
"bg-overlay": "Overlay",
"bg-subtle": "Subtle",
"border-dim": "Dim border",
"border-base": "Base border",
"border-strong": "Strong border",
"border-focus": "Focus ring",
"text-primary": "Primary text",
"text-secondary": "Secondary text",
"text-muted": "Muted text",
"text-faint": "Faint text",
"text-disabled": "Disabled text",
"accent": "Accent",
"accent-dim": "Accent dim",
"accent-muted": "Accent muted",
"accent-fg": "Accent foreground",
"accent-bright": "Accent bright",
"color-error": "Error",
"color-error-bg": "Error background",
"color-success": "Success",
"color-info": "Info",
"color-info-bg": "Info background",
};
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = store.settings.customThemes.find(t => t.id === editingId);
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
}
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
}
const initial = loadInitial();
let themeName: string = $state(initial.name);
let tokens: ThemeTokens = $state(initial.tokens);
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
saveCustomTheme(theme);
updateSettings({ theme: id });
editingId = id;
saveStatus = "saved";
setTimeout(() => (saveStatus = "idle"), 1800);
}
function handleDelete() {
if (!editingId) { onClose(); return; }
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
deleteCustomTheme(editingId);
onClose();
}
function handleExport() {
const data: CustomTheme = {
id: editingId ?? "custom:export",
name: themeName.trim() || "Untitled Theme",
tokens: { ...tokens },
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleImport() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.onchange = async () => {
const file = inp.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
if (typeof data.name === "string") themeName = data.name;
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
importError = null;
} catch (e: any) {
importError = e.message ?? "Could not parse theme file";
setTimeout(() => (importError = null), 3000);
}
};
inp.click();
}
function resetToDefaults() {
tokens = { ...DEFAULT_THEME_TOKENS };
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div
class="te-shell"
role="dialog"
aria-label="Theme editor"
tabindex="0"
onclick={(e) => e.stopPropagation()}
>
<header class="te-header">
<div class="te-header-left">
<button class="te-icon-btn" onclick={onClose} title="Close editor">
<ArrowLeft size={14} weight="bold" />
</button>
<input
bind:value={themeName}
class="te-name-input"
placeholder="Theme name"
maxlength={40}
spellcheck={false}
/>
</div>
<div class="te-header-actions">
{#if importError}
<span class="te-import-err">{importError}</span>
{/if}
<button class="te-action-btn" onclick={handleImport} title="Import from JSON">
<UploadSimple size={13} />
<span>Import</span>
</button>
<button class="te-action-btn" onclick={handleExport} title="Export as JSON">
<DownloadSimple size={13} />
<span>Export</span>
</button>
<button class="te-action-btn te-ghost" onclick={resetToDefaults} title="Reset all to dark defaults">
Reset
</button>
{#if editingId}
<button class="te-action-btn te-danger" onclick={handleDelete} title="Delete theme">
<Trash size={13} />
</button>
{/if}
<button class="te-save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
<FloppyDisk size={13} />
<span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
</button>
<button class="te-icon-btn" onclick={onClose} title="Close">
<X size={14} weight="bold" />
</button>
</div>
</header>
<div class="te-body">
<aside class="te-preview-pane">
<div class="te-pane-label">Live Preview</div>
<div class="te-preview-ui" style={toCssVars(tokens)}>
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots">
<span></span><span></span><span></span>
</div>
<div class="prv-win-title">Moku</div>
</div>
<div class="prv-content">
<div class="prv-row">
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
</div>
<div class="prv-grid">
{#each Array(6) as _, i}
<div class="prv-card" class:active-card={i === 0}>
<div class="prv-cover"></div>
<div class="prv-card-line"></div>
</div>
{/each}
</div>
<div class="prv-reader">
<div class="prv-page"></div>
</div>
<div class="prv-toast">
<div class="prv-toast-dot"></div>
<div class="prv-toast-lines">
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
</div>
</div>
</div>
</div>
</div>
<div class="te-swatches" style={toCssVars(tokens)}>
{#each [
["bg-base","bg-base"],["bg-surface","bg-surface"],
["accent","accent"],["accent-fg","accent-fg"],
["text-primary","text-primary"],["text-muted","text-muted"],
["color-error","color-error"],
] as [varName, label]}
<div
class="te-swatch"
style="background: var(--{varName})"
title={label}
></div>
{/each}
</div>
</aside>
<div class="te-editor-pane">
{#each TOKEN_GROUPS as group}
<div class="te-group">
<div class="te-group-label">{group.label}</div>
<div class="te-token-list">
{#each group.tokens as token}
<div class="te-token-row">
<label class="te-color-swatch" style="background: {tokens[token]}" title="Pick colour">
<input
type="color"
class="te-color-picker"
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0,7)}
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
/>
</label>
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
<span class="te-token-key">{token}</span>
<input
type="text"
class="te-hex-input"
value={tokens[token]}
spellcheck={false}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
}}
onblur={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
(e.target as HTMLInputElement).value = tokens[token];
}
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
.te-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.72);
z-index: 200;
display: flex; align-items: center; justify-content: center;
animation: teBackdropIn 0.14s ease both;
}
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
.te-shell {
width: calc(100% - 48px);
max-width: 1100px;
height: calc(100% - 48px);
max-height: 760px;
display: flex; flex-direction: column;
background: var(--bg-base);
border: 1px solid var(--border-base);
border-radius: 10px;
animation: teShellIn 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
overflow: hidden;
}
@keyframes teShellIn {
from { transform: translateY(10px) scale(0.99); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
.te-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 0 16px; height: 46px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-surface);
flex-shrink: 0;
}
.te-header-left {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
}
.te-icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 5px;
color: var(--text-muted);
transition: color 0.1s, background 0.1s;
flex-shrink: 0;
}
.te-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.te-name-input {
flex: 1; min-width: 0;
background: none; border: none; outline: none;
font-family: var(--font-sans); font-size: 13px; font-weight: 500;
color: var(--text-primary);
border-bottom: 1px solid transparent;
padding: 3px 0;
transition: border-color 0.12s;
}
.te-name-input:focus { border-color: var(--border-focus); }
.te-name-input::placeholder { color: var(--text-faint); }
.te-header-actions {
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.te-import-err {
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em;
color: var(--color-error); flex-shrink: 0;
}
.te-action-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
padding: 4px 10px; border-radius: 4px;
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.te-action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.te-ghost { border-color: transparent; }
.te-ghost:hover { border-color: var(--border-dim); }
.te-danger { color: var(--color-error); border-color: transparent; }
.te-danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
.te-save-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
padding: 5px 14px; border-radius: 4px;
border: 1px solid var(--accent-dim);
background: var(--accent-muted); color: var(--accent-fg);
cursor: pointer; flex-shrink: 0;
transition: filter 0.1s, background 0.12s;
}
.te-save-btn:hover { filter: brightness(1.12); }
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
.te-preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
background: var(--bg-void);
display: flex; flex-direction: column;
padding: 16px; gap: 12px;
}
.te-pane-label {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-faint);
flex-shrink: 0;
}
.te-preview-ui {
flex: 1; min-height: 0;
border-radius: 8px; overflow: hidden;
border: 1px solid var(--border-base);
display: flex; background: var(--bg-void);
}
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
align-items: center; padding: 12px 0; gap: 9px;
}
.prv-sb-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--text-faint); opacity: 0.4;
transition: background 0.15s, opacity 0.15s;
}
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.prv-titlebar {
height: 26px; flex-shrink: 0;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
display: flex; align-items: center; padding: 0 8px; gap: 7px;
}
.prv-win-dots { display: flex; gap: 4px; }
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.prv-win-title { font-family: var(--font-ui); font-size: 9px; letter-spacing: 0.1em; color: var(--text-faint); }
.prv-content {
flex: 1; overflow: hidden;
padding: 8px; display: flex; flex-direction: column; gap: 7px;
background: var(--bg-base);
}
.prv-row { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.prv-bar { height: 3px; border-radius: 2px; }
.prv-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; flex-shrink: 0;
}
.prv-card {
border-radius: 4px; border: 1px solid var(--border-dim);
background: var(--bg-raised); overflow: hidden;
transition: border-color 0.15s;
}
.prv-card.active-card { border-color: var(--accent); }
.prv-cover { height: 34px; background: var(--bg-overlay); }
.prv-card-line { height: 3px; margin: 4px 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
.prv-reader {
flex: 1; min-height: 0;
border-radius: 4px; border: 1px solid var(--border-dim);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
}
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
.prv-toast {
flex-shrink: 0;
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border-radius: 5px;
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
}
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
.te-swatch {
width: 22px; height: 22px; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.07);
flex-shrink: 0; cursor: default;
}
.te-editor-pane {
flex: 1; overflow-y: auto;
padding: 16px 20px;
display: flex; flex-direction: column; gap: 22px;
}
.te-editor-pane::-webkit-scrollbar { width: 4px; }
.te-editor-pane::-webkit-scrollbar-track { background: transparent; }
.te-editor-pane::-webkit-scrollbar-thumb {
background: var(--border-strong); border-radius: 9999px;
}
.te-group { display: flex; flex-direction: column; gap: 2px; }
.te-group-label {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-faint);
padding-bottom: 7px; margin-bottom: 4px;
border-bottom: 1px solid var(--border-dim);
}
.te-token-list { display: flex; flex-direction: column; gap: 1px; }
.te-token-row {
display: flex; align-items: center; gap: 10px;
padding: 5px 8px; border-radius: 5px;
transition: background 0.1s;
}
.te-token-row:hover { background: var(--bg-raised); }
.te-color-swatch {
width: 36px; height: 18px; border-radius: 5px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
cursor: pointer;
position: relative;
overflow: hidden;
display: block;
}
.te-color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
.te-color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0;
cursor: pointer;
padding: 0; border: none;
}
.te-token-name {
flex: 1; font-size: 12px; color: var(--text-secondary);
}
.te-token-key {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.05em; color: var(--text-faint);
flex-shrink: 0; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 160px;
}
.te-hex-input {
width: 82px; flex-shrink: 0;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: 3px; padding: 3px 7px;
outline: none;
transition: border-color 0.1s, color 0.1s;
}
.te-hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
</style>
-560
View File
@@ -1,560 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types";
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let allCategories: Category[] = $state([]);
let mangaCategories: Category[] = $state([]);
let catsLoading: boolean = $state(false);
let queueingAll = $state(false);
let fetchError: string|null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
let linkPickerOpen = $state(false);
let linkSearch = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
const linkPickerResults = $derived.by(() => {
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
async function openLinkPicker() {
linkPickerOpen = true; linkSearch = "";
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
function handleLink(other: Manga) {
if (!store.previewManga) return;
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
else linkManga(store.previewManga.id, other.id);
}
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort(); chapterAbort?.abort();
setPreviewManga(null);
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
const displayManga = $derived(manga ?? store.previewManga);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.isRead).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
const continueChapter = $derived.by(() => {
if (!chapters.length) return null;
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
const firstUnread = chapters.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
return { ch: chapters[0], label: "Read again" };
});
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
manga = store.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
(async (): Promise<Manga> => {
const key = CACHE_KEYS.MANGA(id);
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
try {
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
return d.fetchManga.manga;
} catch (e: any) {
if (e?.name === "AbortError") throw e;
const local = await gql<{ manga: Manga }>(GET_MANGA, { id }, dCtrl.signal).then((d) => d.manga);
if (local) return local;
throw new Error("Could not load manga details");
}
})().then((fullManga) => {
if (dCtrl.signal.aborted) return;
if (!cache.has(CACHE_KEYS.MANGA(id))) cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
manga = fullManga; loadingDetail = false;
}).catch((e) => {
if (e?.name === "AbortError") return;
manga = store.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, cCtrl.signal)
.then(async (d) => {
if (cCtrl.signal.aborted) return;
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (nodes.length === 0) {
try {
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal);
if (!cCtrl.signal.aborted) nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) { if (e?.name === "AbortError") return; }
}
if (!cCtrl.signal.aborted) {
chapters = nodes;
if (nodes.length > 0) checkAndMarkCompleted(id, nodes);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
}
async function toggleLibrary() {
if (!manga) return;
togglingLib = true;
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
cache.clear(CACHE_KEYS.MANGA(manga.id));
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
cache.clear(CACHE_KEYS.LIBRARY);
togglingLib = false;
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
}
async function downloadAll() {
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
if (!ids.length) return;
queueingAll = true;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
queueingAll = false;
}
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
setNavPage("library");
close();
}
function loadCategories(mangaId: number) {
catsLoading = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => {
allCategories = d.categories.nodes.filter(c => c.id !== 0);
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
// Sync local mangaCategories state after the mutation
if (chaps.length) {
const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed");
if (completed) {
const inCompleted = mangaCategories.some(c => c.id === completed.id);
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
}
}
}
async function toggleCategory(cat: Category) {
if (!store.previewManga) return;
const mangaId = store.previewManga.id;
const inCat = mangaCategories.some(c => c.id === cat.id);
await gql(UPDATE_MANGA_CATEGORIES, {
mangaId,
addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [],
}).catch(console.error);
mangaCategories = inCat
? mangaCategories.filter(c => c.id !== cat.id)
: [...mangaCategories, cat];
}
async function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !store.previewManga) return;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
const cat = res.createCategory.category;
allCategories = [...allCategories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
newFolderName = ""; creatingFolder = false;
}
function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
}
$effect(() => {
if (folderOpen) {
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => document.removeEventListener("mousedown", handleFolderOutside);
}
});
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if store.previewManga}
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Manga preview">
<div class="cover-col">
<div class="cover-wrap">
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
{#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if}
</div>
<div class="cover-actions">
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
</button>
<button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span>
</button>
<div class="folder-wrap" bind:this={folderRef}>
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
</button>
{#if folderOpen}
<div class="folder-menu">
{#if catsLoading}
<p class="folder-empty">Loading…</p>
{:else if allCategories.length === 0 && !creatingFolder}
<p class="folder-empty">No folders yet</p>
{/if}
{#each allCategories as cat}
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{cat.name}
</button>
{/each}
<div class="folder-divider"></div>
{#if creatingFolder}
<div class="folder-create-row">
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
use:focusAction />
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
</div>
{:else}
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
</button>
</div>
</div>
<div class="content">
<div class="content-header">
<div class="title-block">
<h2 class="title">{displayManga?.title}</h2>
{#if loadingDetail}
<div class="sk-byline"></div>
{:else if displayManga?.author || displayManga?.artist}
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
{/if}
</div>
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body">
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
{#if loadingDetail}
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
{:else}
<div class="badges">
{#if statusLabel}<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>{/if}
{#if displayManga?.source}<span class="badge">{displayManga.source.displayName}</span>{/if}
{#if inLibrary}<span class="badge badge-accent">In Library</span>{/if}
{#if !loadingChapters && unreadCount > 0}<span class="badge badge-unread">{unreadCount} unread</span>{/if}
{#if !loadingChapters && bookmarkCount > 0}<span class="badge">{bookmarkCount} bookmarked</span>{/if}
</div>
{/if}
<div class="chapter-box">
{#if loadingChapters}
<div class="chapter-loading">
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="chapter-loading-label">Loading chapters…</span>
</div>
{:else if totalCount > 0}
<div class="chapter-meta">
<span class="chapter-label">
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
</span>
{#if unreadCount > 0}
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
{/if}
</div>
{#if readCount > 0}
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
<Play size={12} weight="fill" />{continueChapter.label}
</button>
{/if}
{:else if !loadingDetail}
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
{/if}
</div>
{#if loadingDetail}
<div class="sk-desc">
<div class="sk-line" style="width:100%"></div>
<div class="sk-line" style="width:88%"></div>
<div class="sk-line" style="width:70%"></div>
</div>
{:else if displayManga?.description}
<div class="desc-block">
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
{#if displayManga.description.length > 220}
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
</button>
{/if}
</div>
{/if}
{#if !loadingDetail && displayManga?.genre?.length}
<div class="genres">
{#each displayManga.genre as g}
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
{/each}
</div>
{/if}
{#if !loadingDetail}
<div class="meta-table">
<div class="meta-grid">
<div class="meta-col">
<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
<div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
</div>
<div class="meta-col">
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{#if linkPickerOpen}
<div class="link-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
<div class="link-modal">
<div class="link-header">
<span class="link-title">Link as same series</span>
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div>
<p class="link-hint">
Mark two manga as the same series so duplicates are merged in search and discover.
Click a linked entry again to unlink.
</p>
<div class="link-search-wrap">
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusAction />
</div>
<div class="link-list">
{#if loadingLinkList}
<p class="link-empty">Loading…</p>
{:else if linkPickerResults.length === 0}
<p class="link-empty">No results</p>
{:else}
{#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
<div class="link-info">
<span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
</div>
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
{/if}
<script module>
function focusAction(node: HTMLElement) { node.focus(); }
</script>
<style>
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
.cover-wrap { position: relative; width: 100%; }
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.folder-wrap { position: relative; width: 100%; }
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
.folder-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folder-item.folder-item-on { color: var(--accent-fg); }
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
.folder-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; min-width: 0; }
.folder-input:focus { border-color: var(--border-focus); }
.folder-ok { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base); }
.folder-ok:disabled { opacity: 0.4; cursor: default; }
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folder-new { padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; width: 100%; transition: color var(--t-fast); }
.folder-new:hover { color: var(--accent-fg); }
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.content-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: none; }
.content-body::-webkit-scrollbar { display: none; }
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.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); background: var(--bg-raised); color: var(--text-faint); }
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.dl-all-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.read-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-4); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; align-self: flex-start; transition: filter var(--t-base); }
.read-btn:hover { filter: brightness(1.1); }
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.desc-toggle { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; align-self: flex-start; transition: color var(--t-base); }
.desc-toggle:hover { color: var(--accent-fg); }
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre-tag { 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: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
.meta-col { display: flex; flex-direction: column; }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; }
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; 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; }
.link-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; }
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
.link-search:focus { border-color: var(--border-strong); }
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.link-list::-webkit-scrollbar { display: none; }
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
.link-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); }
.link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
@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
View File
@@ -0,0 +1 @@
export * from "./selectPortal";
+40
View File
@@ -0,0 +1,40 @@
import type { Attachment } from "svelte/attachments";
/**
* {@attach selectPortal(triggerEl)}
*
* Moves the decorated element to <body> and positions it below `triggerEl`.
* The element stays reactive Svelte still owns its DOM, we just re-parent it.
*
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
* the outside-click guard in Settings.svelte can exclude it from dismissal.
*/
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
return (menuEl: HTMLElement) => {
// Position & move to body
function position() {
const r = triggerEl.getBoundingClientRect();
menuEl.style.position = "fixed";
menuEl.style.top = `${r.bottom + 4}px`;
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
// clamp to viewport left edge
const left = parseFloat(menuEl.style.left);
if (left < 8) menuEl.style.left = "8px";
}
document.body.appendChild(menuEl);
triggerEl.__selectMenuEl = menuEl;
position();
// Reposition on scroll / resize while open
window.addEventListener("scroll", position, true);
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, true);
window.removeEventListener("resize", position);
triggerEl.__selectMenuEl = null;
menuEl.remove();
};
};
}
+50
View File
@@ -0,0 +1,50 @@
import type { Manga, Source } from "@types";
import type { Settings } from "@types";
import { shouldHideSource } from "@core/util";
// ── Source deduplication ──────────────────────────────────────────────────────
/**
* Deduplicates sources by name, preferring `preferredLang` when multiple
* sources share a name. The local source (id "0") is always excluded.
*
* When `applyHide` is true, sources that fail the NSFW/block check are
* also removed used in fan-out and cache-build paths where only
* user-visible sources should be queried.
*/
export function dedupeSourcesByLang(
sources: Source[],
preferredLang: string,
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
applyHide = false,
): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
}
// ── Manga predicate filters ───────────────────────────────────────────────────
/**
* Generic predicate pipeline composes multiple boolean predicates into one.
* All predicates must return true for an item to pass.
*
* Usage:
* const keep = buildFilter<Manga>(
* m => !shouldHideNsfw(m, settings),
* m => m.inLibrary,
* );
* const filtered = items.filter(keep);
*/
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item));
}
+5
View File
@@ -0,0 +1,5 @@
export * from './sort';
export * from './filter';
export * from './paginate';
export * from './search';
export * from './queue';
+29
View File
@@ -0,0 +1,29 @@
export interface PaginationState {
visible: number;
}
export interface PaginationResult<T> {
items: T[];
hasMore: boolean;
remaining: number;
}
export function createPaginator<T>(pageSize: number) {
return {
slice(all: T[], visible: number): PaginationResult<T> {
return {
items: all.slice(0, visible),
hasMore: all.length > visible,
remaining: Math.max(0, all.length - visible),
};
},
nextVisible(current: number): number {
return current + pageSize;
},
reset(): number {
return pageSize;
},
};
}
+29
View File
@@ -0,0 +1,29 @@
export interface AsyncQueue<T> {
enqueue(item: T): void;
drain(): void;
clear(): void;
size(): number;
}
export function createAsyncQueue<T>(
worker: (item: T) => Promise<void>,
concurrency = 1,
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
function next() {
while (active < concurrency && queue.length > 0) {
const item = queue.shift()!;
active++;
worker(item).finally(() => { active--; next(); });
}
}
return {
enqueue(item) { queue.push(item); next(); },
drain() { next(); },
clear() { queue.length = 0; },
size() { return queue.length; },
};
}
+33
View File
@@ -0,0 +1,33 @@
export interface SearchResult<T> {
item: T;
score: number;
}
export function searchItems<T>(
items: T[],
query: string,
getField: (item: T) => string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
}
export function searchWithScore<T>(
items: T[],
query: string,
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
return items
.map(item => {
const field = getField(item).toLowerCase();
if (!field.includes(q)) return null;
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
return { item, score };
})
.filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score);
}
+32
View File
@@ -0,0 +1,32 @@
export type SortDir = "asc" | "desc";
export interface SortField<T> {
key: string;
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
}
export interface SortConfig<T> {
fields: SortField<T>[];
defaultField: string;
defaultDir: SortDir;
}
export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
}
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
return {
sort(items, field, dir, context) {
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
if (!f) return [...items];
const d = dir ?? config.defaultDir;
return [...items].sort((a, b) => {
const cmp = f.comparator(a, b, context);
return d === "asc" ? cmp : -cmp;
});
},
};
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Runs an async task over every item in `items`, with at most `concurrency`
* tasks in-flight at once. Respects the provided AbortSignal each worker
* exits early if the signal fires. Errors thrown by individual tasks are
* swallowed so one failure does not cancel the whole batch.
*/
export async function runConcurrent<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;
}
+25
View File
@@ -0,0 +1,25 @@
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; },
};
}
+31
View File
@@ -0,0 +1,31 @@
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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
+16 -44
View File
@@ -1,4 +1,4 @@
import { store, updateSettings } from "../store/state.svelte";
import { store, updateSettings } from "@store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
@@ -16,38 +16,25 @@ function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
}
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit {
return {
...init,
credentials: "include",
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
};
}
export async function fetchAuthenticated(
url: string,
init: RequestInit,
signal?: AbortSignal,
): Promise<Response> {
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? "";
const headers = user && pass ? basicHeader(user, pass) : {};
return fetch(url, buildRequestInit({ ...init, signal }, headers));
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, {
...init, signal, credentials: "omit",
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
});
}
return fetch(url, { ...init, signal });
return fetch(url, { ...init, signal, credentials: "omit" });
}
export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST",
method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(5000),
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
@@ -61,39 +48,25 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
try {
const headers: Record<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));
}
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000),
method: "POST", credentials: "omit", headers,
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok) {
if (mode === "SIMPLE_LOGIN" || mode === "UI_LOGIN") {
updateSettings({ serverAuthMode: "NONE" });
}
return "ok";
}
if (res.ok) return "ok";
if (res.status === 401) {
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
if (/basic/i.test(wwwAuth)) {
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
return "auth_required";
}
if (/bearer/i.test(wwwAuth)) {
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
} else if (mode === "NONE") {
@@ -101,7 +74,6 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
}
return "unsupported_mode";
}
return "unreachable";
} catch { return "unreachable"; }
}
+104
View File
@@ -0,0 +1,104 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "@store/state.svelte";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
async function doFetch(url: string): Promise<string> {
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
if (!res.ok) throw new Error(`${res.status}`);
const blobUrl = URL.createObjectURL(await res.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(() => { inflight.delete(entry.url); 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 }); });
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 clearBlobCache(): void {
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
}
+4
View File
@@ -0,0 +1,4 @@
export * from './memoryCache';
export * from './pageCache';
export * from './imageCache';
export * from './queryCache';
View File
+79
View File
@@ -0,0 +1,79 @@
import { gql, plainThumbUrl } from "@api/client";
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const preloadedUrls = new Set<string>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url);
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
return resolvedUrlCache.get(url)!;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<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 => plainThumbUrl(p));
if (useBlob) {
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
}
pageCache.set(chapterId, urls);
return urls;
})
).finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p);
}
const base = inflight.get(chapterId)!;
if (!signal) return base;
return new Promise((resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<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 (preloadedUrls.has(url)) return;
preloadedUrls.add(url);
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
inflight.delete(chapterId);
} else {
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
preloadedUrls.clear();
aspectCache.clear();
}
}
+161
View File
@@ -0,0 +1,161 @@
interface Entry<T> {
promise: Promise<T>;
fetchedAt: number;
}
const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>();
const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); }
function registerGroups(key: string, group?: string | string[]) {
if (!group) return;
for (const tag of Array.isArray(group) ? group : [group]) {
if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key);
}
}
export const cache = {
get<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise<T> {
const existing = store.get(key) as Entry<T> | undefined;
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
const promise = fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() });
registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {});
return promise;
},
set<T>(key: string, value: T, group?: string | string[]) {
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
registerGroups(key, group);
notify(key);
},
update<T>(key: string, fn: (prev: T) => T) {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return;
const next = existing.promise.then(fn);
store.set(key, { promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {});
},
has(key: string): boolean { return store.has(key); },
ageOf(key: string): number | undefined {
const e = store.get(key);
return e ? Date.now() - e.fetchedAt : undefined;
},
clear(key: string) { store.delete(key); notify(key); },
clearGroup(tag: string) {
const keys = groups.get(tag);
if (!keys) return;
for (const key of keys) { store.delete(key); notify(key); }
groups.delete(tag);
},
clearAll() {
const allKeys = [...store.keys()];
store.clear(); groups.clear();
allKeys.forEach(notify);
},
subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set());
subs.get(key)!.add(cb);
return () => subs.get(key)?.delete(cb);
},
};
export const CACHE_GROUPS = {
LIBRARY: "g:library",
SOURCES: "g:sources",
} as const;
export const CACHE_KEYS = {
LIBRARY: "library",
ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories",
SEARCH: "search_all_manga",
SOURCES: "sources",
POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`,
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `pages:${sourceId}:${type}:${q}`;
},
sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `page:${sourceId}:${type}:${page}:${q}`;
},
} as const;
const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const p = fetcher().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
const _pageSets = new Map<string, Set<number>>();
export interface PageSet {
add(page: number): void;
pages(): Set<number>;
next(): number;
clear(): void;
}
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return {
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); },
pages() { return new Set(_pageSets.get(key) ?? []); },
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
clear() { _pageSets.delete(key); },
};
}
const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap {
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
catch { return {}; }
}
function saveFrecency(map: FrecencyMap) {
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
}
export function recordSourceAccess(sourceId: string) {
if (!sourceId || sourceId === "0") return;
const map = loadFrecency();
map[sourceId] = (map[sourceId] ?? 0) + 1;
saveFrecency(map);
}
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
const map = loadFrecency();
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 }));
if (withScore.some(x => x.score > 0)) {
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
}
return sources.slice(0, MAX_FRECENCY_SOURCES);
}
+47
View File
@@ -0,0 +1,47 @@
export interface Keybinds {
turnPageRight: string;
turnPageLeft: string;
firstPage: string;
lastPage: string;
turnChapterRight: string;
turnChapterLeft: string;
exitReader: string;
toggleReadingDirection: string;
togglePageStyle: string;
toggleFullscreen: string;
openSettings: string;
toggleBookmark: string;
toggleMarker: string;
}
export const DEFAULT_KEYBINDS: Keybinds = {
turnPageRight: "ArrowRight",
turnPageLeft: "ArrowLeft",
firstPage: "ctrl+ArrowLeft",
lastPage: "ctrl+ArrowRight",
turnChapterRight: "]",
turnChapterLeft: "[",
exitReader: "Backspace",
toggleReadingDirection: "d",
togglePageStyle: "q",
toggleFullscreen: "f",
openSettings: "o",
toggleBookmark: "m",
toggleMarker: "n",
};
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
turnPageRight: "Turn page right (→)",
turnPageLeft: "Turn page left (←)",
firstPage: "Jump to first page",
lastPage: "Jump to last page",
turnChapterRight: "Turn chapter right (→)",
turnChapterLeft: "Turn chapter left (←)",
exitReader: "Exit reader",
toggleReadingDirection: "Toggle reading direction",
togglePageStyle: "Toggle page style",
toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings",
toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker",
};
+3
View File
@@ -0,0 +1,3 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
export type { Keybinds } from "./defaultBinds";
+25
View File
@@ -0,0 +1,25 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
export function eventToKeybind(e: KeyboardEvent): string {
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl");
if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift");
if (e.metaKey) parts.push("meta");
parts.push(e.key);
return parts.join("+");
}
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
return eventToKeybind(e) === bind;
}
export async function toggleFullscreen(): Promise<void> {
try {
const win = getCurrentWindow();
await win.setFullscreen(!await win.isFullscreen());
} catch (e) {
console.warn("toggleFullscreen unavailable:", e);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { store } from "@store/state.svelte";
let themeStyleEl: HTMLStyleElement | null = null;
export function applyTheme() {
const themeId = store.settings.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = store.settings.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
}
+23
View File
@@ -0,0 +1,23 @@
import { store } from "@store/state.svelte";
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
let timer: ReturnType<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));
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from './idle';
export * from './zoom';
+61
View File
@@ -0,0 +1,61 @@
import { store } from "@store/state.svelte";
let _appliedZoom: number = -1;
let _vhRafId: number | null = null;
export function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.0;
if (uiZoom === _appliedZoom) return;
_appliedZoom = uiZoom;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${uiZoom * 100}%`;
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
}
export function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
const current = store.settings.uiZoom ?? 1.0;
if (e.key === "=" || e.key === "+") { e.preventDefault(); store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); }
else if (e.key === "-") { e.preventDefault(); store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); }
else if (e.key === "0") { e.preventDefault(); store.settings.uiZoom = 1.0; }
}
export function mountZoomKey(): () => void {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
}
export function clampZoom(z: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
}
export function captureZoomAnchor(
containerEl: HTMLElement | null,
style: string,
out: { el: HTMLElement | null; offset: number },
) {
if (!containerEl || style !== "longstrip") return;
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
}
}
export function restoreZoomAnchor(
containerEl: HTMLElement | null,
out: { el: HTMLElement | null; offset: number },
) {
if (!out.el || !containerEl) return;
const el = out.el;
out.el = null;
requestAnimationFrame(() => {
const containerTop = containerEl!.getBoundingClientRect().top;
containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset;
});
}
+40
View File
@@ -0,0 +1,40 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { addToast } from "@store/state.svelte";
function parse(tag: string): number[] {
return tag.replace(/^v/, "").split(".").map(Number);
}
function compare(a: number[], b: number[]): number {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
}
export async function checkForUpdateSilently(): Promise<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 {}
}
+84 -98
View File
@@ -1,16 +1,43 @@
import { clsx, type ClassValue } from "clsx";
import type { Source } from "./types";
import type { Manga, Source } from "@types";
import type { Settings } from "@types";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
// ── Class utility ─────────────────────────────────────────────────────────────
export { clsx as cn } from "clsx";
// ── Time / formatting ─────────────────────────────────────────────────────────
export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
// ── NSFW genre filtering ──────────────────────────────────────────────────────
export function dayLabel(ts: number): string {
const d = new Date(ts), now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
export function formatReadTime(m: number): string {
if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
// ── NSFW filtering ────────────────────────────────────────────────────────────
/**
* Default substrings used when no user-configured list is available.
* The Settings > Content tab lets users add/remove entries from this list,
* which is stored as settings.nsfwFilteredTags.
* Default genre substrings used when no user-configured list is available.
* Stored as settings.nsfwFilteredTags; editable in Settings > Content.
*/
export const DEFAULT_NSFW_TAGS = [
"adult",
@@ -27,55 +54,39 @@ export const DEFAULT_NSFW_TAGS = [
];
/**
* Returns true if the manga carries at least one genre tag matching any of
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
* Returns true if the manga's genre list contains any of the given substrings.
* Falls back to DEFAULT_NSFW_TAGS if no tag list is provided.
*/
export function isNsfwManga(
manga: { genre?: string[] | null },
tags: string[] = DEFAULT_NSFW_TAGS,
): boolean {
return (manga.genre ?? []).some((g) => {
const normalized = g.toLowerCase().trim();
return tags.some((sub) => normalized.includes(sub));
});
return (manga.genre ?? []).some(g =>
tags.some(sub => g.toLowerCase().trim().includes(sub))
);
}
/**
* Single authoritative NSFW gate used by all views.
* Returns true when the manga should be HIDDEN. Priority order:
* 1. Source in blockedSourceIds always hidden, even when showNsfw is on.
* 2. showNsfw globally enabled only blocked sources are hidden.
* 3. Source in allowedSourceIds skip isNsfw flag, but genre tags still apply.
* 4. source.isNsfw flag hidden.
* 5. Genre tag match hidden.
*
* Returns true when the manga should be HIDDEN. Checks in order:
* 1. showNsfw disabled globally skip everything, hide by source flag or genre match.
* 2. Source is in blockedSourceIds always hide regardless of showNsfw.
* 3. Source is in allowedSourceIds always show (bypasses isNsfw flag only, genre tags still apply).
* 4. Source isNsfw flag hide unless source is allowed.
* 5. Genre tag match hide.
*
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
*/
export function shouldHideNsfw(
manga: {
genre?: string[] | null;
source?: { id?: string; isNsfw?: boolean } | null;
},
settings: {
showNsfw: boolean;
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
manga: Pick<Manga, "genre" | "source">,
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
): boolean {
const srcId = manga.source?.id;
// Explicit block always wins, even when showNsfw is on
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
// If NSFW is globally allowed, only explicit blocks apply
if (settings.showNsfw) return false;
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true;
return isNsfwManga(manga, settings.nsfwFilteredTags);
@@ -83,21 +94,11 @@ export function shouldHideNsfw(
/**
* Gate for Source objects parallel to shouldHideNsfw for manga.
*
* Priority:
* 1. Blocked list always hidden, even when showNsfw is on.
* 2. Allowed list always shown, even if isNsfw is true.
* 3. Fallback hide when showNsfw is off and source.isNsfw is true.
*
* Usage: sources.filter(s => !shouldHideSource(s, settings))
* Usage: sources.filter(s => !shouldHideSource(s, settings))
*/
export function shouldHideSource(
source: { id: string; isNsfw: boolean },
settings: {
showNsfw: boolean;
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
source: Pick<Source, "id" | "isNsfw">,
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
): boolean {
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
@@ -106,6 +107,11 @@ export function shouldHideSource(
// ── Source deduplication ──────────────────────────────────────────────────────
/**
* Deduplicates sources by name. When multiple sources share a name,
* the preferred language wins; otherwise falls back to alphabetical by lang.
* The local source (id "0") is always excluded.
*/
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
const byName = new Map<string, Source[]>();
for (const src of sources) {
@@ -115,7 +121,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
}
const picked: Source[] = [];
for (const group of byName.values()) {
const preferred = group.find((s) => s.lang === preferredLang);
const preferred = group.find(s => s.lang === preferredLang);
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
}
return picked;
@@ -123,12 +129,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
// ── Manga deduplication ───────────────────────────────────────────────────────
/**
* Normalizes a title for fuzzy matching.
* Strips punctuation, articles, and common source-specific suffixes so that
* "The Greatest Estate Developer" and "Yeokdaegeum Yeongji Seolgyesa" won't
* match on title alone but their identical descriptions will catch them.
*/
/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */
export function normalizeTitle(title: string): string {
return title
.toLowerCase()
@@ -139,76 +140,61 @@ export function normalizeTitle(title: string): string {
.trim();
}
/**
* Normalizes a string for fingerprinting strip all non-alpha, collapse spaces.
*/
/** Strips all non-alphanumeric chars and collapses whitespace. */
function norm(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
}
/**
* Description fingerprint first 200 normalized chars.
* Long enough to reliably identify the same series across sources even when
* translations differ in punctuation or minor wording.
* Returns null if too short (< 60 chars) to be a reliable signal.
* First 200 normalized chars of a description reliable cross-source fingerprint.
* Returns null if too short (< 60 chars) to be a trustworthy signal.
*/
function descFingerprint(desc: string | null | undefined): string | null {
if (!desc) return null;
const n = norm(desc);
if (n.length < 60) return null;
return n.slice(0, 200);
return n.length >= 60 ? n.slice(0, 200) : null;
}
/**
* Author fingerprint normalized concatenation of author + artist.
* Used as a tie-breaker / additional signal alongside description.
* Two manga with the same authors AND same description are almost certainly
* the same series. Returns null if no author info.
* Normalized author + artist concatenation for tie-breaking.
* Returns null if no author info available.
*/
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
if (!parts.length) return null;
return parts.sort().join("|");
return parts.length ? parts.sort().join("|") : null;
}
/**
* Deduplicates manga by:
* 1. Normalized title
* 2. Description fingerprint (first 200 chars)
* 3. Author + description together
* 4. User-defined links (mangaLinks from store) explicit "same series" overrides
* Deduplicates manga across sources using title, description, and author signals,
* plus explicit user-defined links (settings.mangaLinks).
*
* Pass `links` as `settings.mangaLinks` to honour user-registered pairs.
* When two entries match, the PREFERRED one is kept:
* - Library membership wins
* - Otherwise higher downloadCount wins
* - Otherwise first occurrence wins
* When two entries match, the better one is kept:
* - Library membership wins over non-library.
* - Otherwise higher downloadCount wins.
* - Otherwise first occurrence wins.
*/
export function dedupeMangaByTitle<T extends {
id: number;
title: string;
description?: string | null;
author?: string | null;
artist?: string | null;
inLibrary?: boolean;
id: number;
title: string;
description?: string | null;
author?: string | null;
artist?: string | null;
inLibrary?: boolean;
downloadCount?: number;
}>(items: T[], links: Record<number, number[]> = {}): T[] {
const byTitle = new Map<string, number>();
const byDesc = new Map<string, number>();
const byAuthorDesc = new Map<string, number>();
// id → index in out[]
const byId = new Map<number, number>();
const out: T[] = [];
const out: T[] = [];
for (const m of items) {
const tk = normalizeTitle(m.title);
const dk = descFingerprint(m.description);
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
// Check user-defined links first (explicit override)
const linkedIds = links[m.id] ?? [];
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
const linkedIds = links[m.id] ?? [];
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
const existingIdx =
linkedIdx ??
byTitle.get(tk) ??
@@ -217,7 +203,7 @@ export function dedupeMangaByTitle<T extends {
if (existingIdx !== undefined) {
const existing = out[existingIdx];
const mBetter =
const mBetter =
(m.inLibrary && !existing.inLibrary) ||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
@@ -243,7 +229,7 @@ export function dedupeMangaByTitle<T extends {
}
/**
* Deduplicates manga by id only (lossless).
* Lossless deduplication by ID only. Preserves first occurrence.
*/
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
const seen = new Set<number>();
@@ -252,4 +238,4 @@ export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
}
return out;
}
}
@@ -1,7 +1,3 @@
/*
Moku Animations
*/
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
@@ -34,26 +30,19 @@
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
to { background-position: 200% 0; }
}
/* Utility classes */
.anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-down { animation: fadeDown 0.18s ease both; }
.anim-scale-in { animation: scaleIn 0.14s ease both; }
.anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; }
.anim-scale-in { animation: scaleIn 0.14s ease both; }
.anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; }
/* Skeleton shimmer */
.skeleton {
background: linear-gradient(
90deg,
var(--bg-raised) 25%,
var(--bg-overlay) 50%,
var(--bg-raised) 75%
);
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm);
}
}
+4
View File
@@ -0,0 +1,4 @@
@import "./reset.css";
@import "./animations.css";
@import "./scrollbars.css";
@import "./typography.css";
+41
View File
@@ -0,0 +1,41 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#app {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
p { margin: 0; }
+9
View File
@@ -0,0 +1,9 @@
* {
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; }
+9
View File
@@ -0,0 +1,9 @@
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme="high-contrast"] {
--bg-void: #000000;
--bg-base: #080808;
--bg-surface: #0d0d0d;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
}
+5
View File
@@ -0,0 +1,5 @@
@import "./high-contrast.css";
@import "./light-contrast.css";
@import "./light.css";
@import "./midnight.css";
@import "./warm.css";
+29
View File
@@ -0,0 +1,29 @@
[data-theme="light-contrast"] {
--bg-void: #d8d4ce;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
+32
View File
@@ -0,0 +1,32 @@
[data-theme="light"] {
--bg-void: #e8e6e2;
--bg-base: #eeece8;
--bg-surface: #f4f2ee;
--bg-raised: #faf8f4;
--bg-overlay: #ffffff;
--bg-subtle: #f0ede8;
--border-dim: #dedad4;
--border-base: #d0ccc6;
--border-strong: #bbb6ae;
--border-focus: #5a7a5a;
--text-primary: #1a1916;
--text-secondary: #2e2c28;
--text-muted: #5a5750;
--text-faint: #9a9890;
--text-disabled: #c8c4bc;
--accent: #4a724a;
--accent-dim: #c8dcc8;
--accent-muted: #deeade;
--accent-fg: #2a5a2a;
--accent-bright: #3a6a3a;
--color-error: #a03030;
--color-error-bg: #fce8e8;
--color-success: #2a6a2a;
--color-info: #2a4a7a;
--color-info-bg: #e8eef8;
--color-read: #e8e4dc;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme="midnight"] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme="warm"] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
+35
View File
@@ -0,0 +1,35 @@
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
}
+8
View File
@@ -0,0 +1,8 @@
@import "./colors.css";
@import "./typography.css";
@import "./spacing.css";
@import "./radius.css";
@import "./motion.css";
@import "./shadows.css";
@import "./zindex.css";
@import "../themes/index.css";

Some files were not shown because too many files have changed in this diff Show More