Compare commits

...

223 Commits

Author SHA1 Message Date
Youwes09 e33464b05b Chore: Update to Version 0.9.1 (V2) 2026-04-27 21:21:08 -05:00
Youwes09 6f15e8fbc2 Chore: Update Bump & Tauri-Windows 2026-04-27 21:19:20 -05:00
Youwes09 86c6558bab Chore: Update Version to 0.9.1 2026-04-27 21:00:30 -05:00
Youwes09 c041f99c75 Feat: Download Queue Patching & UI Revisions (#54) 2026-04-27 14:40:47 -05:00
Youwes09 84c2a82c2c Feat: Touch Gestures (Pinch Zoom) for Reader (#29) 2026-04-27 13:31:10 -05:00
Youwes09 dc174bee4a Chore: Switched Zoom-Keybinds & Moku-Full SVG (#53) 2026-04-27 11:15:32 -05:00
Youwes09 72496a25e2 Feat: Hide Completed-Mangas in Default 2026-04-26 23:35:06 -05:00
Youwes09 8b074e4b97 Chore: Patch PKGBUILD for AUR (V1) 2026-04-26 22:41:34 -05:00
Youwes09 743f14f561 Chore: Build-Linux Workflow Debugging (V4) 2026-04-26 22:27:48 -05:00
Youwes09 d9ae94f0ff Chore: Build-Linux Workflow Debugging (V3) 2026-04-26 22:23:54 -05:00
Youwes09 045bcc5bc4 Chore: Build-Linux Workflow (V2) 2026-04-26 22:11:39 -05:00
Youwes09 4004a49cfb Chore: Patch for LinuxDeploy 2026-04-26 22:00:05 -05:00
Youwes09 ee72e345bd Chore: Change TauriBuild to MokuProject from moku_project 2026-04-26 21:52:11 -05:00
Youwes09 336ab0a24f Chore: Appimage-Workflow (V1) 2026-04-26 21:41:24 -05:00
Youwes09 c3b015f00f Chore: Change from Youwes09 to moku-project 2026-04-26 21:22:08 -05:00
Shozikan 22e3095cf5 Chore: Change Badge Theme in README 2026-04-26 20:51:48 -05:00
Shozikan 7bc2050971 Chore: Update README with Flatpak & Badges 2026-04-26 20:48:59 -05:00
Youwes09 d26f0b85e3 Feat: TrackingPanel + Tracking Re-design (WIP) 2026-04-26 14:28:45 -05:00
Youwes09 e8e6f18851 Fix: Library-Folder Truncation 2026-04-26 13:58:04 -05:00
Youwes09 50c5131477 Feat: Dual-Sync Tracking (#52) 2026-04-26 13:36:30 -05:00
Youwes09 c0efbba4df Feat: Automated Tracking + Proper Sync 2026-04-26 12:11:45 -05:00
Youwes09 361a145702 Chore: Update Theme-Default & Remove Light-Contrast 2026-04-26 00:29:31 -05:00
Youwes09 1c004d7e5c Chore: Re-Upload Moku-Home Image 2026-04-25 23:39:40 -05:00
Shozikan fb72e45817 Chore: Update README with new Image Layout 2026-04-25 23:38:46 -05:00
Youwes09 5c2e2b6866 Chore: Update Moku Images for 0.9.0 2026-04-25 23:33:59 -05:00
Shozikan 4b313512d4 Chore: Update README with Winget (Attribution included) 2026-04-25 23:24:30 -05:00
Youwes09 63258b2aa1 Feat: Update-All Extensions Button 2026-04-25 17:22:13 -05:00
Youwes09 b5f96a3a5c Feat: System-Default Based Custom Theme Switching (#45) 2026-04-25 15:54:21 -05:00
Youwes09 4eef03cbb1 Fix: Library Folder-Tab Adjustment Enhancement 2026-04-25 15:33:15 -05:00
Youwes09 f6118077fb Feat: Downloads Auto-Retry Button & Toast Enhancements 2026-04-25 15:17:18 -05:00
Youwes09 544792a7ad Fix: MacOS x86 Detection Logic (UNTESTED) 2026-04-25 09:45:34 -05:00
Youwes09 e063369dfb Fix: Flatpak Patches + Fix BulkMove Library 2026-04-25 09:41:11 -05:00
Youwes09 514910667b Chore: Update Tags for v0.9.0 2026-04-24 21:45:06 -05:00
Youwes09 2e9939c4a9 Feat: Per-Manga Reader Settings + Settings Access (#42 & #46) 2026-04-24 21:09:05 -05:00
Youwes09 581aea5694 Feat: Pin Sources on SourceTab (#48) 2026-04-23 22:37:42 -05:00
Youwes09 72a88b10c8 Feat: Always Display Library Stats & Library Stats Overhaul (#47) 2026-04-23 21:44:11 -05:00
Youwes09 371b4af73f Feat: Settings Button in Reader + Dropdown Overhaul + Settings Listener (#46) 2026-04-23 21:35:33 -05:00
Youwes09 634d32f372 Feat: Download Queue Move to Top/Bottom + Tooltip (#38) 2026-04-23 20:54:57 -05:00
Youwes09 4e6be5d9f5 Feat: Import & Export Store + Update Trigger 2026-04-23 20:09:50 -05:00
Youwes09 bb7256c4f8 Feat: BulkAutomationPanel & Z-Index Issue (#39 & #44) 2026-04-23 16:03:36 -05:00
Youwes09 b12ff4cbaa Fix: Derive Auto-Download List from Filter (#39) 2026-04-23 11:27:54 -05:00
Youwes09 63a829ddca Fix: Chapter Nodes in LibraryUpdater 2026-04-23 10:52:15 -05:00
Youwes09 94b14fb7f6 Fix: Patch Cargo to remove TPU (Windows Installer Patch #41) 2026-04-22 23:33:33 -05:00
Youwes09 bd2fd7a6d7 Fix: Windows Auto-Installer (WIP) 2026-04-23 03:58:20 -05:00
Youwes09 6634ad56d2 Fix: Attempt at Windows-Installer without TPU 2026-04-22 22:25:24 -05:00
Youwes09 2eb8a7662e Feat: Home Re-Design & MacOS Detection Fix 2026-04-22 22:15:13 -05:00
Youwes09 7dd4f52308 Fix: Attempt to Fix Tab Boundaries 2026-04-22 10:57:03 -05:00
Youwes09 690f59c602 Fix: Dark-Theme on Settings Slider 2026-04-22 10:34:09 -05:00
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
Youwes09 db44afc4dc Chore: Bump versions for 0.7.0 2026-04-02 23:28:16 -05:00
Youwes09 4248e344ab Chore: Embed AuthURL into Images 2026-04-02 23:19:41 -05:00
Youwes09 8941bfef10 Feat: Improved ThemeEditor with ColorPicker (WIP) 2026-04-02 22:50:19 -05:00
Youwes09 11cd6ff870 Fix: CSS Issues 2026-04-02 22:46:55 -05:00
Youwes09 15adb02be3 Fix: Revise Authentication Methods & Add Edge-Case Handling for Auth 2026-04-02 22:27:39 -05:00
Youwes09 51bb6cdab9 Feat: Markers 2026-04-02 18:07:49 -05:00
Youwes09 454a674ada Merge branch 'main' of github.com:Youwes09/Moku 2026-04-02 11:05:25 -05:00
Youwes09 f146de5c02 Fix: Search Tags + Status (WIP) 2026-04-02 11:05:19 -05:00
Shozikan 04f680c3bb Fix Discord link in README
Updated Discord link in README to new URL.
2026-04-02 09:13:41 -05:00
Youwes09 f49f7e7ac1 Feat: Automation Panel (WIP) & SeriesDetail Additions 2026-04-02 00:56:27 -05:00
Youwes09 a62512bf42 Feat: Filtering in Library 2026-04-01 22:26:29 -05:00
Youwes09 d91ed2e6d1 Chore: Redesign MigrationModal Sources 2026-04-01 15:38:10 -05:00
Youwes09 61e3c4ee2f Chore: Redesign SeriesDetail Elements 2026-04-01 14:25:18 -05:00
Youwes09 9151820843 Fix: TitleBar Issue (WIP) & Allow Sources in Content Settings 2026-04-01 11:09:40 -05:00
Youwes09 63c890dadf Fix: Bookmark Notification Spam 2026-04-01 10:53:18 -05:00
Youwes09 51a33679d5 Chore: Bump for 0.6.7 2026-03-31 23:49:17 -05:00
Youwes09 82f8a9a36b Fix: Forgot Auto-Bookmark Toggle & NSFW On GenreDrill 2026-03-31 22:55:26 -05:00
Youwes09 4decce9a7f Fix: Reworked Bookmark System & Added Double Page (WIP) 2026-03-31 19:46:11 -05:00
Youwes09 a69d5eacc5 Fix: Improved Loading (WIP) 2026-03-31 11:28:00 -05:00
Youwes09 4959722759 Feat: Change Download Directory (WIP) 2026-03-30 23:14:40 -05:00
Youwes09 35ba0171c7 Fix: Zoom Issue & Sidebar Overflow 2026-03-30 00:26:04 -05:00
Youwes09 d26fa50e76 Chore: Bump to 0.6.1 2026-03-30 00:04:54 -05:00
Youwes09 fd9d216325 Fix: Emergency Push + Bookmark Feature (WIP) 2026-03-30 00:02:21 -05:00
Youwes09 581eb2adb0 Fix: Home-Screen Argument for RPC & Total Time 2026-03-29 17:35:25 -05:00
Youwes09 8aa2dc2547 Chore: Prepare for Version 0.6.0 2026-03-29 15:47:12 -05:00
Youwes09 0a11fe3982 Feat: Discord RPC 2026-03-29 15:38:39 -05:00
Youwes09 f6786def87 Fix: SeriesDetail passing Incorrect Args to Reader 2026-03-29 14:03:28 -05:00
Youwes09 262027d9f9 Feat: Added Filtering System in Library (Request: #13) 2026-03-29 13:22:08 -05:00
Youwes09 d407359973 Fix: Added Slight Border to Mitigate Windows Tab Issue (WIP) 2026-03-29 12:58:03 -05:00
Youwes09 a77572a8d4 Fix: Constrained Home-Screen Completed & SplashScreen #15 2026-03-29 12:51:17 -05:00
Youwes09 32d2fffdc5 Fix: Zoom Issue (Bug #14) 2026-03-29 12:40:28 -05:00
Youwes09 e850cbac1e Fix: Bump Update for 0.5.1 2026-03-28 20:17:14 -05:00
Youwes09 eebd1b6446 Fix: Remove Manga Drag & Drop + Libray Move System 2026-03-28 20:09:40 -05:00
Youwes09 5ed072211b Fix: Folder State & Tabs 2026-03-28 19:36:16 -05:00
Youwes09 62e41e5f07 Fix: Reader Store Refactor (Issue #11) & Feat: Drag n Drop (WIP) 2026-03-28 17:15:01 -05:00
Youwes09 4b6d0780c9 Fix: Installation Server Kill [V2] 2026-03-27 20:59:22 -05:00
Youwes09 6ef0facb89 Fix: Installation Server Kill -> Overwrite Error 2026-03-27 20:19:36 -05:00
Youwes09 34d997fc9d Feat: Chapter Organization 2026-03-27 15:46:15 -05:00
Youwes09 1f08b46919 Fix: SplashScreen Default 2026-03-27 15:37:02 -05:00
Youwes09 ac6b70fb32 Feat: Lock-Feature & Server-Authentication + Experimentals 2026-03-26 23:21:39 -05:00
Youwes09 2c93d8743d Fix: Tauri-Overlay Set-False 2026-03-26 00:02:47 -05:00
Youwes09 b9fe54c08d Fix: MacOS Tauri Conf PascalCase 2026-03-25 23:41:43 -05:00
Youwes09 3abb4bb96c Fix: MacOS TitleBar & History Reactive-Glitch 2026-03-25 23:36:18 -05:00
Youwes09 4b3493465d Fix: MacOS Directory Build Change 2026-03-25 00:01:48 -05:00
Youwes09 2163f4a8a6 Fix: Reader Rewrite 2026-03-24 23:52:39 -05:00
Youwes09 fc535f3f74 Fix: Reader Backlog-Glitch & History/Stats Rewrite 2026-03-24 11:44:53 -05:00
Shozikan c819d03222 Fix: README Logical Error 2026-03-23 23:11:16 -05:00
Youwes09 b23292cff5 Chore: README Update 2026-03-23 19:18:27 -05:00
Youwes09 6d85be751a Fix: MacOS Workflow Flatten Directory 2026-03-23 17:46:38 -05:00
Youwes09 06a9e71a90 Fix: MacOS Workflow YAML Error 2026-03-23 11:53:55 -05:00
Youwes09 1a183e7a24 Fix: MacOS Tauri Conf (Build Testing) 2026-03-23 11:46:36 -05:00
Youwes09 dcb3377349 Chore: Standardized UI & Revamped Series-Detail 2026-03-23 11:39:01 -05:00
Youwes09 077ea4dd8f Merge branch 'main' of github.com:Youwes09/Moku 2026-03-23 01:12:25 -05:00
Youwes09 6bdf59db6a Feat: Implemented Basic Tracker Support (Anilist, Mal, Etc) 2026-03-23 01:12:14 -05:00
Shozikan db9ff33c64 Fix: Updated Drafting Stage (Build-Windows) 2026-03-22 16:23:43 -07:00
Shozikan fb1b3d9789 Fix: Patch to Create latest.json 2026-03-22 16:11:13 -07:00
Youwes09 041f735a6e Fix: Windows Key Update 2026-03-22 17:57:28 -05:00
Youwes09 a27c20fabf Fix: Windows Build Type Error (Emitter) 2026-03-22 14:44:05 -05:00
Shozikan 29323c534b Fix: Update logo image in README 2026-03-22 14:39:21 -05:00
Youwes09 a3ef693ed8 Fix: Discover V2 + Windows Update System (Testing) 2026-03-22 14:36:11 -05:00
Youwes09 4691f3aed7 Fix: Discover Cache Refresh & Populating 2026-03-22 09:56:48 -05:00
Youwes09 06cb70048b Feat: Revamped Logo, QOL Home-Screen Additions, Scaling Logic Revamp 2026-03-22 01:26:40 -05:00
Youwes09 d3e62a7a08 Fix: Switch Tauri Conf to Windows Specific 2026-03-21 23:26:33 -05:00
Youwes09 b6ef2b1b3c Fix: Windows Prod-Server Launch 2026-03-21 21:13:58 -07:00
Youwes09 c13a4eb77a Fix: Improve CI Patch 2026-03-20 22:34:32 -05:00
Youwes09 bd972eccf3 Fix: Clear externalBin, Remove Linux CI 2026-03-20 22:29:24 -05:00
Youwes09 9610c0294d Fix: Clear externalBin in Base Config 2026-03-20 22:21:17 -05:00
Youwes09 406819ccca Feat: Bundle Suwayomi JRE for Windows/Linux 2026-03-20 22:13:43 -05:00
Youwes09 272e026210 Fix: Inject Bundle Resources in CI 2026-03-20 21:14:35 -05:00
Youwes09 57bf9d5fb1 Fix: Attempt #1 Windows Workflow 2026-03-20 21:07:19 -05:00
Youwes09 7df7191799 Fix: Attempt to Fix Nix/Flatpak (Testing) 2026-03-20 21:03:53 -05:00
Youwes09 e6b542cd6b Fix: Removed Update Badge from Extensions 2026-03-20 16:01:03 -05:00
Youwes09 4903b066b1 Chore: Finalized Svelte-5 Rewrite (Testing Phase) 2026-03-20 15:58:35 -05:00
Youwes09 96bac1ad2b Chore: Revamped Shared Files for Svelte 5 Rewrite 2026-03-19 23:43:43 -05:00
Youwes09 94b92d000f Chore: Revamped Lib Files for Svelte 5 Rewrite 2026-03-19 23:36:26 -05:00
Youwes09 43630ef72d Chore: Temporary Home-Page (Until Fixed with Rewrite) 2026-03-19 22:53:51 -05:00
Youwes09 161b1f9f52 Chore: Revamped Home-Page (Pending Statistics Display) 2026-03-19 22:17:23 -05:00
Youwes09 816b384d64 Feat: Implemented Series-Link 2026-03-19 22:00:24 -05:00
Youwes09 b772b94c6c Chore: Attempted De-Dupe Patch #1 & Alternative Thumbnails 2026-03-19 21:39:51 -05:00
Youwes09 deb8a5ee02 Chore: Patched Library Completed & Added Home-Page 2026-03-19 21:20:33 -05:00
Youwes09 821e13fc44 chore: migrated context + series-detail + migrate 2026-03-19 00:36:42 -05:00
Youwes09 937054d674 chore: ported over extensions & settings 2026-03-18 23:46:11 -05:00
Youwes09 4532b37201 chore: patched/rewrote reader 2026-03-18 23:16:18 -05:00
Youwes09 73b73e85d7 chore: ported over basics 2026-03-18 23:05:32 -05:00
Youwes09 697116b630 fix: tauri dev added 2026-03-18 22:31:45 -05:00
Youwes09 0e87c51801 chore: init svelte rewrite scaffold 2026-03-18 22:29:03 -05:00
Youwes09 bf38e00cf3 [V1] Fixed Mark as Read Refresh + Auto Feature 2026-03-04 00:00:12 -06:00
Youwes09 eb7360ee05 [V1] Rebased Reader to 9a0afed + Improvements 2026-02-28 18:30:00 -06:00
Youwes09 c9eba3da86 [V1] Fixed Bad State Issue on Reader (WIP) 2026-02-27 22:18:38 -06:00
Youwes09 fc68d3ac7e New Patch for Reader 2026-02-27 17:49:07 -06:00
Youwes09 1fa1c3a2e0 [V1] Search Overhaul + Tag Fixes 2026-02-26 23:55:39 -06:00
Youwes09 8c38330143 [V1] Reader Simplification & Fixes 2026-02-26 23:31:01 -06:00
Youwes09 272d7673ce [V1] Fix NixOS Build 2026-02-26 21:05:24 -06:00
Youwes09 3d074a1fb1 [V1] Attempt on Reader Optimization + Infinite Scroll Glitches 2026-02-26 19:49:48 -06:00
Youwes09 be15cb6ad8 [V1] Patched Tauri Capabilities Permissions 2026-02-25 21:56:05 -06:00
Youwes09 3aee69939b [V1] Forgot to add Binaries prefix 2026-02-25 21:52:57 -06:00
Youwes09 0557f3f2d6 [V1] Fixed Tauri Sidecar Capabilities 2026-02-25 21:51:31 -06:00
Youwes09 817af0d10a [V1] Changed Windows Auto-Detect Binary 2026-02-25 21:40:52 -06:00
Youwes09 70afb08f83 [V1] Updated Search on Workflow 2026-02-25 21:06:43 -06:00
Youwes09 f751f34c68 [V1] Updated Hashing 2026-02-25 21:01:33 -06:00
Youwes09 8c9d3fc783 [V1] Updated SHA Checker 2026-02-25 20:59:19 -06:00
Youwes09 0f0cd87e6d [V1] Windows Workflow 2026-02-25 20:53:20 -06:00
Youwes09 f5a1b13e43 [V1] Attempt to fix Apple Cert Signing 2026-02-25 20:30:06 -06:00
Youwes09 4fca379715 [V1] Fixed Tauri Cert Signing 2026-02-25 20:21:05 -06:00
Youwes09 ac5e3ae53b [V1] Requires Bundle Patch (MacOS) 2026-02-25 20:11:27 -06:00
Youwes09 6d39d5574a [V1] Removed Tauri-MacOS Patch 2026-02-25 20:06:08 -06:00
Youwes09 5e8f0d2f52 [V1] Fix Suwayomi Detection in Workflow 2026-02-25 20:02:11 -06:00
Youwes09 87e2009d4e [V1] MacOS-Patch & Fixes (WIP) 2026-02-25 19:58:37 -06:00
Youwes09 2f5103c48c [V1] Patched Tauri-Targets & Removed Bun Detection 2026-02-25 19:53:12 -06:00
Shozikan 9d9c1b61e7 [V1] Changed to MacOS-Latest & Tauri-Refactor 2026-02-25 19:49:14 -06:00
Shozikan a1a0f360d7 [V1] Fixed ENV & Download Link 2026-02-25 19:43:47 -06:00
Youwes09 9a0afed2b0 [V1] Query-Optimizations & Preparation for MacOS & Windows Compatibility 2026-02-25 19:41:14 -06:00
Youwes09 28e9e3bcf8 [V1] Redid Series Detail Download Layout 2026-02-24 22:02:53 -06:00
Youwes09 ac04c39ead [V1] Prepared for v0.3.0 Release 2026-02-24 20:18:45 -06:00
306 changed files with 33718 additions and 19044 deletions
-66
View File
@@ -1,66 +0,0 @@
name: Build AppImage
on:
workflow_dispatch:
inputs:
version:
description: "Version tag (e.g. 0.1.0)"
required: false
default: ""
jobs:
build:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
# ubuntu-20.04 ships webkit2gtk 2.44 by default, avoiding the
# EGL_BAD_PARAMETER crash present in 2.46+
# https://github.com/gitbutlerapp/gitbutler/issues/5282
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
patchelf \
file
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: pnpm install
- name: Build AppImage
run: pnpm tauri build --bundles appimage
env:
NO_STRIP: "true"
- name: Upload AppImage
uses: actions/upload-artifact@v4
with:
name: Moku-${{ github.event.inputs.version || github.sha }}-amd64.AppImage
path: src-tauri/target/release/bundle/appimage/*.AppImage
if-no-files-found: error
+171
View File
@@ -0,0 +1,171 @@
name: Build Linux
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
required: true
permissions:
contents: write
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist-linux
path: dist/
retention-days: 1
tauri:
name: Tauri (Linux x64)
needs: frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist-linux
path: dist/
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libfuse2
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Download Suwayomi (Linux x64)
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-linux-x64.tar.gz" \
-o suwayomi-linux.tar.gz
echo "b2344bd73c4e26bede63cdb4b44b1b4168d8a8500b3b2b1a0219519a3ef708fe suwayomi-linux.tar.gz" | sha256sum -c -
mkdir -p suwayomi-extracted
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
- name: Stage Suwayomi bundle
run: |
mkdir -p src-tauri/binaries
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
JAVA="suwayomi-extracted/jre/bin/java"
CATCH="suwayomi-extracted/bin/catch_abort.so"
for f in "$JAR" "$JAVA" "$CATCH"; do
if [ ! -e "$f" ]; then
echo "ERROR: expected file not found: $f"
find suwayomi-extracted -type f | head -40
exit 1
fi
done
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
- name: Stage Linux launcher sidecar
run: |
cp src-tauri/binaries/suwayomi-launcher-linux.sh \
src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
chmod +x src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
- name: Patch tauri.conf.json for CI
run: |
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Build Tauri app
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
env:
NO_STRIP: "true"
- name: Upload Linux artifacts to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.event.inputs.version }}
run: |
for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i"
sleep 15
done
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Could not find release for v$VERSION after waiting"
exit 1
fi
echo "Found release ID: $RELEASE_ID"
upload_asset() {
local file="$1"
local name="$2"
echo "Uploading $name..."
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
}
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
[ -n "$APPIMAGE" ] && upload_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
+194
View File
@@ -0,0 +1,194 @@
name: Build macOS
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.4.0)"
required: true
permissions:
contents: write
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/
retention-days: 1
tauri:
name: Tauri (macOS)
needs: frontend
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist/
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Download Suwayomi binaries
run: |
download_suwayomi() {
local asset="$1" sha="$2" outdir="$3"
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
-o "${outdir}.tar.gz"
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
mkdir -p "${outdir}"
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
}
download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
"suwayomi-arm64"
download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
"suwayomi-x64"
- name: Stage Suwayomi sidecars
run: |
mkdir -p src-tauri/binaries
stage_arch() {
local srcdir="$1"
local arch="$2"
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
if [ -z "$JAR" ]; then
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
find "$srcdir" -type f | head -30
exit 1
fi
if [ -z "$JAVA" ]; then
echo "ERROR: jre/bin/java not found in $srcdir"
find "$srcdir" -type f | head -30
exit 1
fi
echo "${arch}: jar=${JAR} java=${JAVA}"
cp -r "$srcdir" "$bundle_dest"
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
chmod +x "$sidecar"
echo "Staged sidecar: $sidecar"
}
stage_arch suwayomi-arm64 aarch64-apple-darwin
stage_arch suwayomi-x64 x86_64-apple-darwin
- name: Patch tauri.conf.json for CI
run: |
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Swap bundle for aarch64
run: |
rm -rf src-tauri/binaries/suwayomi-bundle
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
src-tauri/binaries/suwayomi-bundle
- name: Build Tauri app (aarch64)
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
- name: Swap bundle for x86_64
run: |
rm -rf src-tauri/binaries/suwayomi-bundle
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
src-tauri/binaries/suwayomi-bundle
- name: Build Tauri app (x86_64)
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
- name: Upload macOS artifacts to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.event.inputs.version }}
run: |
# Wait for the Windows workflow to have created the draft release
for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i"
sleep 15
done
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Could not find release for v$VERSION after waiting"
exit 1
fi
echo "Found release ID: $RELEASE_ID"
upload_asset() {
local file="$1"
local name="$2"
echo "Uploading $name..."
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
}
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
+167
View File
@@ -0,0 +1,167 @@
name: Build Windows
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
required: true
permissions:
contents: write
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist-windows
path: dist/
retention-days: 1
tauri:
name: Tauri (Windows x64)
needs: frontend
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist-windows
path: dist/
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Download Suwayomi (Windows x64)
shell: bash
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
-o suwayomi-windows.zip
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
unzip -q suwayomi-windows.zip -d suwayomi-raw
- name: Extract Suwayomi bundle
shell: bash
run: |
mkdir -p suwayomi-extracted
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
cp -r "$INNER"/. suwayomi-extracted/
else
cp -r suwayomi-raw/. suwayomi-extracted/
fi
- name: Stage Suwayomi bundle
shell: bash
run: |
mkdir -p src-tauri/binaries
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
if [ -z "$JAVA" ]; then
echo "ERROR: jre/bin/java.exe not found"
find suwayomi-extracted -type f | head -50
exit 1
fi
if [ -z "$JAR" ]; then
echo "ERROR: Suwayomi-Server.jar not found"
find suwayomi-extracted -type f | head -50
exit 1
fi
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
- name: Validate staging
shell: bash
run: |
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
echo "Staging OK"
- name: Patch tauri.conf.json for CI
shell: bash
run: |
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Delete existing draft release if present
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
if [ -n "$RELEASE_ID" ]; then
echo "Deleting existing draft release $RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
echo "Deleted draft release and tag"
else
echo "No existing draft release found"
fi
- name: Build Tauri app + create draft release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v${{ github.event.inputs.version }}
releaseName: Moku v${{ github.event.inputs.version }}
releaseBody: |
Moku v${{ github.event.inputs.version }}
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
**Linux:** Download `moku.flatpak`
releaseDraft: true
prerelease: false
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
+3 -1
View File
@@ -37,5 +37,7 @@ src-tauri/gen/
# --- Flatpak build artifacts --- # --- Flatpak build artifacts ---
build-dir/ build-dir/
repo/ repo/
dist/
packaging/frontend-dist.tar.gz
*.flatpak *.flatpak
.flatpak-builder/ .flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright [yyyy] [name of copyright owner] Copyright [2026] [@Youwes09]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+18 -33
View File
@@ -1,10 +1,10 @@
pkgname=moku pkgname=moku
pkgver=0.2.0 pkgver=0.9.1
pkgrel=1 pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server" pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64') arch=('x86_64')
url="https://github.com/Youwes09/Moku" url="https://github.com/moku-project/Moku"
license=('Apache 2.0') license=('Apache-2.0')
depends=( depends=(
'webkit2gtk-4.1' 'webkit2gtk-4.1'
'gtk3' 'gtk3'
@@ -18,13 +18,13 @@ makedepends=(
'pnpm' 'pnpm'
) )
source=( source=(
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz" "$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar" "Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar"
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
) )
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4' sha256sums=(
'SKIP'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af' '51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d') )
prepare() { prepare() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
@@ -33,14 +33,7 @@ prepare() {
build() { build() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
# Build frontend
pnpm build pnpm build
# Repack dist for Tauri
tar -czf packaging/frontend-dist.tar.gz -C dist .
# Build Tauri binary
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \ TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
--release \ --release \
--manifest-path src-tauri/Cargo.toml --manifest-path src-tauri/Cargo.toml
@@ -49,19 +42,12 @@ build() {
package() { package() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
# Moku binary
install -Dm755 src-tauri/target/release/moku \ install -Dm755 src-tauri/target/release/moku \
"$pkgdir/usr/bin/moku" "$pkgdir/usr/bin/moku"
# Bundled JRE install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \
install -dm755 "$pkgdir/usr/lib/moku/jre"
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
# Suwayomi server jar
install -Dm644 "$srcdir/suwayomi-server.jar" \
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar" "$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
# tachidesk-server wrapper script
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf" install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF' cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
@@ -76,7 +62,7 @@ server.maxSourcesInParallel = 6
server.extensionRepos = [] server.extensionRepos = []
EOF EOF
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF' install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF'
#!/bin/sh #!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
@@ -100,7 +86,7 @@ unset WAYLAND_DISPLAY
export _JAVA_OPTIONS="-Djava.awt.headless=true" export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true" export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
exec /usr/lib/moku/jre/bin/java \ exec java \
-Djava.awt.headless=true \ -Djava.awt.headless=true \
-Dapple.awt.UIElement=true \ -Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \ -Dsun.java2d.noddraw=true \
@@ -109,17 +95,16 @@ exec /usr/lib/moku/jre/bin/java \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar -jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
EOF EOF
# Desktop entry and icons install -Dm644 packaging/io.github.moku_project.Moku.desktop \
install -Dm644 packaging/dev.moku.app.desktop \ "$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
install -Dm644 src-tauri/icons/32x32.png \ 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.moku_project.Moku.png"
install -Dm644 src-tauri/icons/128x128.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.moku_project.Moku.png"
install -Dm644 src-tauri/icons/128x128@2x.png \ install -Dm644 src-tauri/icons/128x128@2x.png \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
install -Dm644 packaging/dev.moku.app.metainfo.xml \ install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml" "$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+118 -88
View File
@@ -1,137 +1,167 @@
<div align="center"> <div align="center">
<img src="src/assets/rounded-logo.png" width="96" /> <img src="docs/banner.svg" width="100%" alt="Moku" />
<h1>Moku</h1> </div>
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
<table> <div align="center">
<tr>
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td> [![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest)
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td> [![Last Commit](https://www.shieldcn.dev/github/last-commit/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/commits/main)
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td> [![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku)
</tr> [![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](https://discord.gg/x97hj8zR72)
<tr>
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td> </div>
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td> <br/>
</tr>
</table> Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
---
## Screenshots
<div align="center">
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
</div>
<div align="center">
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
</div>
<div align="center">
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a>
</div> </div>
--- ---
## Features ## Features
### Reader - **Library management** — organize manga into folders, track unread counts, filter by genre
- **Single**, **double-page**, and **longstrip** reading modes - **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.)
- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon - **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
- Fit modes: fit width, fit height, fit screen, and 1:1 original - **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
- Per-series zoom control via Ctrl+scroll or a slider popover - **Extension support** — install and manage Suwayomi extensions directly from the app
- RTL / LTR reading direction toggle - **Download management** — queue and monitor chapter downloads with progress toasts
- Configurable page gaps - **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
- Full keyboard navigation with rebindable keybinds - **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges - **Auto-start server** — optionally launch Suwayomi in the background on startup
- Chapter-relative page counter that updates live as you scroll through the infinite strip - **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
- Auto-mark chapters as read when the last page is reached - **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
### Library
- Grid view of your entire manga collection with lazy-loaded cover art
- Filter tabs: **Saved**, **Downloaded**, and **All**
- Genre tag filter chips — multi-select to narrow by any combination of tags
- In-line search
- Context menu: open, add/remove from library
### Series Detail
- Cover, author, artist, status badge, genres, and synopsis
- Read progress bar with percentage
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
- Chapter list with scanlator, upload date, and in-progress page indicator
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
- Sort by newest or oldest first
- Jump-to-chapter input
- Bulk download menu: from current chapter, unread only, or all
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
- Collapsible source details panel with source ID, language, and source migration
### Search
- Cross-source search running up to 3 concurrent requests
- Language filter bar (preferred language default, per-language, or all)
- Results grouped by source with skeleton loading states
### Sources & Extensions
- Browse and search installed sources, grouped by extension with per-language expansion
- Extension manager: install, update, remove, and install from external APK URL
- Repo refresh with update count badge
### Downloads
- Download queue with live progress
### History
- Reading history grouped by day with relative timestamps
- Per-entry thumbnail, chapter name, and last-read page
- Full-text search across titles and chapter names
- One-click clear
---
## Requirements
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
--- ---
## Installation ## Installation
**Nix (recommended)** <div align="center">
![Runs on Windows](https://www.shieldcn.dev/badge/Runs%20on-Windows-0078D4.svg?logo=windows&logoColor=fff)
![Runs on Linux](https://www.shieldcn.dev/badge/Runs%20on-Linux-FCC624.svg?logo=linux&logoColor=000)
![Runs on MacOS](https://www.shieldcn.dev/badge/Runs%20on-MacOS-000000.svg?mode=light&logo=apple&logoColor=fff)
</div>
### Windows
**winget:**
```powershell
winget install Moku.Moku
```
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
### Linux (Flatpak, recommended)
Suwayomi-Server and a bundled JRE are included — no separate install needed.
```bash ```bash
nix run github:Youwes09/moku flatpak install io.github.moku_app.Moku
```
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
```bash
flatpak install moku.flatpak
```
### Nix
```bash
nix run github:moku-project/Moku
``` ```
Add to your flake: Add to your flake:
```nix ```nix
inputs.moku.url = "github:Youwes09/moku"; inputs.moku.url = "github:moku-project/Moku";
``` ```
**From source** ### macOS
```bash Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
git clone https://github.com/Youwes09/moku
cd moku > **Note:** Builds are ad-hoc signed. On first launch you may need to run:
nix build > ```bash
./result/bin/moku > xattr -rd com.apple.quarantine /Applications/Moku.app
``` > ```
---
## Requirements
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
--- ---
## Development ## Development
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
```bash
git clone https://github.com/moku-project/Moku
cd Moku
pnpm install
pnpm tauri:dev
```
Or with Nix:
```bash ```bash
nix develop nix develop
pnpm install pnpm install
pnpm tauri:dev pnpm tauri:dev
``` ```
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
--- ---
## Stack ## Stack
| | | | | |
|---|---| |---|---|
| [Tauri v2](https://tauri.app) | Native app shell | | [Tauri v2](https://tauri.app) | Native app shell |
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI | | [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite](https://vitejs.dev) | Frontend bundler | | [Vite](https://vitejs.dev) | Frontend bundler |
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds | | [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
--- ---
## Community
Questions, feedback, or just want to hang out — join the Discord.
[![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=secondary&size=large)](https://discord.gg/x97hj8zR72)
---
## License ## License
Distributed under the [Apache 2.0 License](./LICENSE). Distributed under the [Apache 2.0 License](./LICENSE).
+30 -80
View File
@@ -1,89 +1,39 @@
Todo: Major Revisions:
3. Explore Manga Upscaler & Other Image Processing - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
4. Font Weird on Flatpak, Investigate and Fix - Moku-Share allows exporting of Manga
5. Investigate "egl:failed to create dri2 screen" - Compressed Format (Storage)
- Import as Local-Source
- Takes existing Local-Source or Creates Own
Bugs: Minor Revisions:
- Investigate feasibility of Multi-Page Screenshot (Reader)
- - Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug) - Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug) - Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
- Add Back after Search & Clear on Search
- Add as Package in Nix Flake & Check Later
- GenreDrill & GenreFilter pages do not populate completely.
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
- Fix Mangafire Main Dispatcher Issue Priority Bugs:
- Fix Library-Refresh System (TESTING)
- Suwayomi RESET
- Allow User to Wipe Suwayomi (Scratch)
- If Possible, Component based Wipe (Library, Etc)
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break) In-Progress:
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards
- Clean up Migrate Model to be more initutive - Add Flathub Support (Pending Video)
Features:
- Add PDF Textbook Support
- Major revision to disable entire manga-subsection and use as
solely as a reader/document launcher.
- Multiple Tag Filters + Mor Tags, Types, Etc
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
- Properly Kill Tachidesk-Server
- Migration Features
- Multi-Page Long Screenshot
-
Big Revisions: - Fix Tracking Login
0. Expand into fully-fledged reader, with modular manga support - Pasting OAuth URL is not User-Friendly, Look for Alternatives
1. Anime & Novel Support
2. Tracker Support - Tracking
3. Cloudflare Bypass Enable Support - Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
4. macOS Support (feasible)
- Hide Completed from Library Settting
Notes from last time:
Testing: - Currently working on #42, just need to mount panel and fix button in reader
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc.
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
20. Expand History (Total Time Read, etc)
12. Delete all Downloads should also cancel all download queues
13. Cancel Download along with Queue & Download Timeout Feature
Completed:
8. Fix Polling on Download Manager (Instantanous Response)
19. Debounce Time on Reader to improve lag (Toggle Setting)
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
17. Change Library Text change to "No manga saved to library, browse sources to add some."
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
7. Fix Scaling (100 = 125% and so forth)
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
11. Reader & UI needs download and other Notifications
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
- Add Refresh Details on Series Details.
- Patch GenreDrill & Integrate into Explore Folder
18. Disable NSFW Extensions option in settings
- Filtering by Genre (Accessed by Clicking tags on Manga)
- Remove Series Detail Mark Read & Unread
Important Commands:
cd ~/Projects/Manga/Moku
pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
3. flatpak build-bundle repo moku.flatpak dev.moku.app
-184
View File
@@ -1,184 +0,0 @@
#!/usr/bin/env bash
# build-scripts/release.sh
# ─────────────────────────────────────────────────────────────────────────────
# Usage:
# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak)
# ./build-scripts/release.sh 0.2.0 --aur — AUR bin package only
# ./build-scripts/release.sh 0.2.0 --flatpak — Flatpak sources + bundle only
#
# Requires: nix, podman (for AUR .SRCINFO generation in Arch container)
set -euo pipefail
# ── Colour helpers ─────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}${RESET} $*"; }
success() { echo -e "${GREEN}${RESET} $*"; }
warn() { echo -e "${YELLOW}${RESET} $*"; }
die() { echo -e "${RED}${RESET} $*" >&2; exit 1; }
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
# ── Args ───────────────────────────────────────────────────────────────────────
[[ $# -lt 1 ]] && die "Usage: $0 <version> [--aur|--flatpak]"
VERSION="$1"
MODE="${2:-all}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
AUR_DIR="${REPO_ROOT}/../moku-bin"
TARBALL="moku-${VERSION}-x86_64.tar.gz"
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
# ── Sanity checks ──────────────────────────────────────────────────────────────
section "Pre-flight"
command -v nix &>/dev/null || die "nix not found"
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)"
[[ -d "$AUR_DIR" ]] || die "AUR dir not found at $AUR_DIR\nClone it first:\n git clone ssh://aur@aur.archlinux.org/moku-bin.git ../moku-bin"
[[ -f "${AUR_DIR}/PKGBUILD" ]] || die "PKGBUILD not found in $AUR_DIR"
fi
success "OK"
# ── Bump versions ──────────────────────────────────────────────────────────────
section "Bumping version → ${VERSION}"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
"${REPO_ROOT}/src-tauri/tauri.conf.json"
success "tauri.conf.json → ${VERSION}"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
"${REPO_ROOT}/src-tauri/Cargo.toml"
success "Cargo.toml → ${VERSION}"
# ── Build frontend ─────────────────────────────────────────────────────────────
section "Building frontend"
cd "$REPO_ROOT"
nix develop --command pnpm install --frozen-lockfile
nix develop --command pnpm build
success "Frontend built → dist/"
# ── Build Rust binary ──────────────────────────────────────────────────────────
section "Building Rust binary"
nix develop --command cargo build --release --manifest-path src-tauri/Cargo.toml
BINARY="${REPO_ROOT}/src-tauri/target/release/moku"
[[ -f "$BINARY" ]] || die "Binary not found: $BINARY"
success "Binary → $BINARY"
# ── Flatpak ────────────────────────────────────────────────────────────────────
if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
section "Regenerating cargo-sources.json"
cd "$REPO_ROOT"
nix-shell \
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
success "cargo-sources.json updated"
section "Rebuilding frontend-dist.tar.gz"
tar -czf packaging/frontend-dist.tar.gz -C dist .
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
# Patch the sha256 in dev.moku.app.yml automatically via a temp script
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
cat > "$PATCH_SCRIPT" << PYEOF
import re, sys
path = "${FLATPAK_MANIFEST}"
new_sha = "${FRONTEND_SHA}"
text = open(path).read()
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
replacement = r'\g<1>' + new_sha
updated, n = re.subn(pattern, replacement, text)
if n == 0:
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
open(path, 'w').write(updated)
PYEOF
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
rm -f "$PATCH_SCRIPT"
success "dev.moku.app.yml sha256 updated"
section "Building Flatpak bundle"
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
flatpak-builder \
--repo="${REPO_ROOT}/repo" \
--force-clean \
"${REPO_ROOT}/build-dir" \
"$FLATPAK_MANIFEST"
flatpak build-bundle \
"${REPO_ROOT}/repo" \
"${REPO_ROOT}/moku.flatpak" \
dev.moku.app
# Clean up intermediate build artefacts — keep only moku.flatpak
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
success "moku.flatpak created"
fi
# ── AUR tarball + PKGBUILD ─────────────────────────────────────────────────────
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
section "Assembling release tarball"
cd "$REPO_ROOT"
STAGE="release-${VERSION}"
rm -rf "$STAGE"
install -Dm755 "$BINARY" "${STAGE}/usr/bin/moku"
install -Dm644 packaging/dev.moku.app.desktop "${STAGE}/usr/share/applications/dev.moku.app.desktop"
install -Dm644 src-tauri/icons/32x32.png "${STAGE}/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
install -Dm644 src-tauri/icons/128x128.png "${STAGE}/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" "${STAGE}/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
install -Dm644 packaging/dev.moku.app.metainfo.xml "${STAGE}/usr/share/metainfo/dev.moku.app.metainfo.xml"
tar -czf "$TARBALL" "$STAGE/"
AUR_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
rm -rf "$STAGE"
success "Tarball: ${TARBALL} sha256: ${AUR_SHA}"
section "Patching PKGBUILD"
PKGBUILD="${AUR_DIR}/PKGBUILD"
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
sed -i "s/sha256sums=('[^']*')/sha256sums=('${AUR_SHA}')/" "$PKGBUILD"
success "PKGBUILD patched"
# Tarball is only needed for the GitHub upload — remind user then it can go
info "Tarball kept at ${REPO_ROOT}/${TARBALL} — upload it to GitHub, then it can be deleted"
section "Generating .SRCINFO (Arch container)"
# Mount only the AUR dir into a throwaway Arch container and run makepkg
podman run --rm \
--volume "${AUR_DIR}:/aur:z" \
--workdir /aur \
archlinux:latest \
bash -c "
pacman -Sy --noconfirm pacman >/dev/null 2>&1
source PKGBUILD
makepkg --printsrcinfo > .SRCINFO
"
success ".SRCINFO generated"
section "Next steps"
echo ""
echo -e " ${BOLD}1. Upload tarball to GitHub:${RESET}"
echo -e " ${CYAN}gh release create v${VERSION} '${REPO_ROOT}/${TARBALL}' --title 'v${VERSION}' --generate-notes${RESET}"
echo ""
echo -e " ${BOLD}2. Push AUR:${RESET}"
echo -e " ${CYAN}cd ${AUR_DIR}${RESET}"
echo -e " ${CYAN}git add PKGBUILD .SRCINFO${RESET}"
echo -e " ${CYAN}git commit -m 'Update to ${VERSION}'${RESET}"
echo -e " ${CYAN}git push origin master${RESET}"
echo ""
echo -e " ${BOLD}3. Clean up:${RESET}"
echo -e " ${CYAN}rm -f ${REPO_ROOT}/${TARBALL}${RESET}"
fi
echo ""
success "v${VERSION} ready"
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
<defs>
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0%" stop-color="#52b888"/>
<stop offset="100%" stop-color="#1e5840"/>
</linearGradient>
<clipPath id="roundedBounds">
<rect width="1280" height="320" rx="18" ry="18"/>
</clipPath>
</defs>
<g clip-path="url(#roundedBounds)">
<rect width="1280" height="320" fill="#070e09"/>
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
fill="url(#leafHero)" opacity="0.97">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
<!-- Stack text pinned to bottom -->
<text
x="640" y="300"
text-anchor="middle"
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
font-size="14"
letter-spacing="5"
fill="#a8c4a8"
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Generated
+15 -87
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1771438068, "lastModified": 1773857772,
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=", "narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597", "rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -15,32 +15,16 @@
"type": "github" "type": "github"
} }
}, },
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": { "flake-parts": {
"inputs": { "inputs": {
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1769996383, "lastModified": 1772408722,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381", "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -49,53 +33,13 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-appimage": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1757920913,
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
"owner": "ralismark",
"repo": "nix-appimage",
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
"type": "github"
},
"original": {
"owner": "ralismark",
"repo": "nix-appimage",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1771369470, "lastModified": 1773821835,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "0182a361324364ae3f436a63005877674cf45efb", "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -107,11 +51,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1769909678, "lastModified": 1772328832,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=", "narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c", "rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -124,7 +68,6 @@
"inputs": { "inputs": {
"crane": "crane", "crane": "crane",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nix-appimage": "nix-appimage",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
} }
@@ -136,11 +79,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1771556776, "lastModified": 1773975983,
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=", "narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860", "rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -148,21 +91,6 @@
"repo": "rust-overlay", "repo": "rust-overlay",
"type": "github" "type": "github"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",
+225 -54
View File
@@ -9,36 +9,27 @@
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
nix-appimage = {
url = "github:ralismark/nix-appimage";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = outputs =
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }: inputs@{ flake-parts, crane, rust-overlay, ... }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ systems = [ "x86_64-linux" "aarch64-linux" ];
"x86_64-linux"
"aarch64-linux"
];
perSystem = perSystem = { system, lib, ... }:
{ system, pkgs, lib, ... }:
let let
pkgs' = import inputs.nixpkgs { version = "0.9.1";
pkgs = import inputs.nixpkgs {
inherit system; inherit system;
overlays = [ rust-overlay.overlays.default ]; overlays = [ rust-overlay.overlays.default ];
}; };
rustToolchain = pkgs'.rust-bin.stable.latest.default.override { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ extensions = [ "rust-src" "rust-analyzer" ];
"rust-src"
"rust-analyzer"
];
}; };
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
runtimeLibs = with pkgs; [ runtimeLibs = with pkgs; [
webkitgtk_4_1 webkitgtk_4_1
@@ -65,31 +56,22 @@
|| base == "package.json" || base == "package.json"
|| base == "pnpm-lock.yaml" || base == "pnpm-lock.yaml"
|| base == "tsconfig.json" || base == "tsconfig.json"
|| base == "tsconfig.node.json" || base == "vite.config.ts";
|| base == "vite.config.ts"
|| base == "postcss.config.js"
|| base == "postcss.config.cjs"
|| base == "tailwind.config.js"
|| base == "tailwind.config.ts";
}; };
frontend = pkgs.stdenv.mkDerivation { frontend = pkgs.stdenv.mkDerivation {
pname = "moku-frontend"; pname = "moku-frontend";
version = "0.1.0"; inherit version;
src = frontendSrc; src = frontendSrc;
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
nodejs_22
pnpm
pnpmConfigHook
];
pnpmDeps = pkgs.fetchPnpmDeps { pnpmDeps = pkgs.fetchPnpmDeps {
pname = "moku-frontend"; pname = "moku-frontend";
version = "0.1.0"; inherit version;
src = frontendSrc; src = frontendSrc;
fetcherVersion = 1; fetcherVersion = 1;
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY="; hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
}; };
buildPhase = "pnpm build"; buildPhase = "pnpm build";
@@ -111,10 +93,7 @@
cargoLock = ./src-tauri/Cargo.lock; cargoLock = ./src-tauri/Cargo.lock;
strictDeps = true; strictDeps = true;
buildInputs = runtimeLibs; buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
pkg-config
wrapGAppsHook3
];
preBuild = '' preBuild = ''
cp -r ${frontend} ../dist cp -r ${frontend} ../dist
''; '';
@@ -126,6 +105,36 @@
inherit cargoArtifacts; inherit cargoArtifacts;
meta.mainProgram = "moku"; meta.mainProgram = "moku";
postInstall = '' postInstall = ''
mkdir -p "$out/share/applications"
cat > "$out/share/applications/moku.desktop" << EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Moku
Comment=Manga reader frontend for Suwayomi
Exec=$out/bin/moku
Icon=moku
Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF
for size in 32x32 128x128 256x256 512x512; do
src="icons/$size.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/$size/apps/moku.png"
done
for size in 128x128 256x256; do
src="icons/''${size}@2x.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
done
install -Dm644 "${./src/assets/moku-icon.svg}" \
"$out/share/icons/hicolor/scalable/apps/moku.svg"
wrapProgram $out/bin/moku \ wrapProgram $out/bin/moku \
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [ --prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
pkgs.gsettings-desktop-schemas pkgs.gsettings-desktop-schemas
@@ -134,16 +143,184 @@
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \ --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \ --prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
--set GDK_BACKEND wayland \ --set GDK_BACKEND wayland \
--set WEBKIT_FORCE_SANDBOX 0 --set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
''; '';
}); });
bumpScript = pkgs.writeShellApplication {
name = "moku-bump";
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
nodejs_22 pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
echo " Bumping version fields to $VERSION "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
"$REPO/src-tauri/Cargo.toml"
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
"$REPO/flake.nix"
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
echo "Done"
echo " Regenerating Cargo.lock "
(cd "$REPO/src-tauri" && cargo generate-lockfile)
echo "Done"
echo " Building frontend "
cd "$REPO"
pnpm install --frozen-lockfile
pnpm build
echo "Done"
echo " Repacking frontend-dist.tar.gz "
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"
echo " Regenerating cargo-sources.json "
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$REPO/src-tauri/Cargo.lock" \
-o "$REPO/packaging/cargo-sources.json"
echo "Done"
echo " Patching flatpak manifest (version + frontend sha256) "
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
echo "Done"
echo ""
echo "Bumped to v$VERSION"
echo ""
echo "Commit field in the flatpak manifest still points to the old tag."
echo "After pushing the tag, run:"
echo " nix run .#post-tag-bump -- $VERSION"
'';
};
postTagBumpScript = pkgs.writeShellApplication {
name = "moku-post-tag-bump";
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
echo " Resolving commit for v$VERSION "
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
| awk '{print $1}')
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
echo "commit: $COMMIT"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
echo "Done"
echo " Fetching PKGBUILD tarball sha256 "
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
echo "Done"
echo ""
echo "post-tag-bump complete for v$VERSION"
'';
};
flatpakScript = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [
gnused coreutils git
appstream flatpak-builder flatpak
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
echo " Building flatpak for v$VERSION "
rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \
--repo="$REPO/repo" \
--force-clean \
"$REPO/build-dir" \
"$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo"
echo ""
echo "moku.flatpak created v$VERSION"
'';
};
pkgbuildBumpScript = pkgs.writeShellApplication {
name = "moku-pkgbuild-bump";
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
PKGBUILD="$REPO/PKGBUILD"
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
echo "Fetching tarball sha256..."
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
'';
};
tunnelScript = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
in in
{ {
apps = {
default = { type = "app"; program = "${moku}/bin/moku"; };
moku = { type = "app"; program = "${moku}/bin/moku"; };
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-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 = { packages = {
inherit moku frontend; inherit moku frontend;
default = moku; default = moku;
appimage = nix-appimage.bundlers."${system}".default moku;
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
@@ -155,29 +332,23 @@
nodejs_22 nodejs_22
pnpm pnpm
suwayomi-server suwayomi-server
cloudflared
xdg-utils xdg-utils
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
]; ];
shellHook = '' shellHook = ''
export APPIMAGE_EXTRACT_AND_RUN=1
export NO_STRIP=true export NO_STRIP=true
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
if [ ! -e /usr/bin/xdg-open ]; then echo "Moku dev shell pnpm install && pnpm tauri:dev"
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open echo ""
fi echo "Release workflow:"
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage" echo " git commit && git tag && git push"
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real" echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha"
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then echo " nix run .#flatpak -- <ver> build moku.flatpak"
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL" echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
chmod +x "$LINUXDEPLOY"
echo "linuxdeploy wrapped with appimage-run"
fi
echo "Moku dev shell"
echo " pnpm install && pnpm tauri:dev"
''; '';
}; };
+2 -2
View File
@@ -6,7 +6,7 @@
<title>Moku</title> <title>Moku</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="app"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
@@ -1,4 +1,4 @@
app-id: dev.moku.app app-id: io.github.moku_project.Moku
runtime: org.gnome.Platform runtime: org.gnome.Platform
runtime-version: '48' runtime-version: '48'
sdk: org.gnome.Sdk sdk: org.gnome.Sdk
@@ -9,16 +9,22 @@ separate-locales: false
finish-args: finish-args:
- --socket=wayland - --socket=wayland
- --socket=x11
- --socket=fallback-x11 - --socket=fallback-x11
- --share=ipc - --share=ipc
- --device=dri - --device=dri
- --share=network - --share=network
- --socket=session-bus
- --socket=system-bus - --talk-name=org.freedesktop.Notifications
- --filesystem=home - --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 - --filesystem=xdg-data/moku:create
- --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-download
build-options: build-options:
append-path: /usr/lib/sdk/rust-stable/bin append-path: /usr/lib/sdk/rust-stable/bin
@@ -33,13 +39,10 @@ modules:
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1 - tar -xf jdk.tar.gz -C /app/jre --strip-components=1
sources: sources:
- type: file - 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 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: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
dest-filename: jdk.tar.gz 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 - name: catch-abort
buildsystem: simple buildsystem: simple
build-commands: build-commands:
@@ -120,7 +123,6 @@ modules:
fi fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch. # 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 \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
@@ -138,8 +140,6 @@ modules:
export _JAVA_OPTIONS="-Djava.awt.headless=true" export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_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" export LD_PRELOAD="/app/lib/catch_abort.so"
exec /app/jre/bin/java \ exec /app/jre/bin/java \
@@ -171,17 +171,19 @@ modules:
- tar -xzf frontend-dist.tar.gz - 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 - . /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 -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 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png - install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png - install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png - install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml - install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
sources: sources:
- type: dir - type: git
path: . url: https://github.com/moku-project/Moku.git
tag: v0.9.1
commit: 514910667b0d6e375569a48fb7cef11411d30fbd
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+15 -25
View File
@@ -1,41 +1,31 @@
{ {
"name": "moku", "name": "moku",
"private": true, "version": "0.5.0",
"version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
"tauri:build": "tauri build"
}, },
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.13.18",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "~2", "@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", "clsx": "^2.1.1",
"lucide-react": "^0.575.0", "phosphor-svelte": "^3.1.0",
"react": "^18.3.1", "svelte-spa-router": "^4.0.1",
"react-dom": "^18.3.1", "tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
"react-router-dom": "^6.26.0", "tauri-plugin-drpc": "^1.0.3"
"zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.3", "svelte": "^5.0.0",
"@types/react-dom": "^18.3.0", "svelte-check": "^3.0.0",
"@vitejs/plugin-react": "^4.3.1", "typescript": "^5.0.0",
"autoprefixer": "^10.4.20", "vite": "^5.0.0"
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3",
"vite": "^5.4.0"
} }
} }
+1891 -528
View File
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.1.0" date="2025-01-01">
<description>
<p>Initial release.</p>
</description>
</release>
</releases>
</component>
Binary file not shown.
@@ -2,7 +2,7 @@
Name=Moku Name=Moku
Comment=Manga reader powered by Suwayomi Comment=Manga reader powered by Suwayomi
Exec=moku Exec=moku
Icon=dev.moku.app Icon=io.github.moku_project.Moku
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Graphics;Viewer; Categories=Graphics;Viewer;
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.moku_project.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.moku_project.Moku.desktop</launchable>
<url type="homepage">https://github.com/moku-project/Moku</url>
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
<caption>Home screen showing your manga library</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
<caption>Built-in manga reader</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
<caption>Discover new manga across hundreds of sources</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
<caption>Download manager</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/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.9.0" date="2025-04-01">
<description>
<p>Latest release with improved stability and UI refinements.</p>
</description>
</release>
<release version="0.8.0" date="2025-04-01">
<description>
<p>Old 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>
+578 -1859
View File
File diff suppressed because it is too large Load Diff
+1567 -359
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -1,11 +1,11 @@
[package] [package]
name = "moku" name = "moku"
version = "0.2.0" version = "0.9.1"
edition = "2021" edition = "2021"
[lib] [lib]
name = "moku_lib" name = "moku_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[[bin]] [[bin]]
name = "moku" name = "moku"
@@ -17,11 +17,19 @@ tauri-build = { version = "2.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0", features = [] } tauri = { version = "2.0", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
walkdir = "2" walkdir = "2"
nix = { version = "0.29", features = ["fs"] } sysinfo = "0.32"
dirs = "5" dirs = "5"
urlencoding = "2"
tokio = { version = "1", features = ["rt-multi-thread"] }
reqwest = { version = "0.12", features = ["blocking"] }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
@@ -0,0 +1,112 @@
#!/bin/sh
# Moku — Suwayomi launcher for Linux AppImage/deb.
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
set -e
# ── Locate our resource directory ─────────────────────────────────────────────
# In an AppImage: resources sit at <mountpoint>/resources/
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
# We resolve relative to this script's own location.
SELF="$0"
while [ -L "$SELF" ]; do
SELF="$(readlink "$SELF")"
done
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
# Tauri places resources one level up from the binary on Linux.
# Try a few candidates so this works in both AppImage and installed layouts.
find_resource() {
for candidate in \
"${SCRIPT_DIR}" \
"${SCRIPT_DIR}/../resources" \
"${SCRIPT_DIR}/resources"
do
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
echo "$(cd "$candidate" && pwd)"
return 0
fi
done
return 1
}
RESOURCE_DIR=$(find_resource) || {
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
exit 1
}
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
JAVA="${RESOURCE_DIR}/jre/bin/java"
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
echo "[launcher] JAVA=$JAVA" >&2
echo "[launcher] JAR=$JAR" >&2
if [ ! -x "$JAVA" ]; then
echo "[launcher] ERROR: java not executable at $JAVA" >&2
exit 1
fi
if [ ! -f "$JAR" ]; then
echo "[launcher] ERROR: jar not found at $JAR" >&2
exit 1
fi
# ── Data directory ─────────────────────────────────────────────────────────────
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
mkdir -p "$DATA_DIR"
# ── Seed server.conf on first run ──────────────────────────────────────────────
if [ ! -f "$DATA_DIR/server.conf" ]; then
cat > "$DATA_DIR/server.conf" << 'EOF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
EOF
fi
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
# Append keys if absent (e.g. user-managed conf missing them)
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
unset DISPLAY
unset WAYLAND_DISPLAY
export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
# bring down the whole server process (mirrors the Flatpak build).
if [ -f "$CATCH_ABORT" ]; then
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
fi
exec "$JAVA" \
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar "$JAR"
+66
View File
@@ -0,0 +1,66 @@
#!/bin/sh
# Moku — Suwayomi launcher sidecar for macOS.
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
set -e
# Resolve the real directory of this script, following symlinks.
SELF="$0"
while [ -L "$SELF" ]; do
SELF="$(readlink "$SELF")"
done
DIR="$(cd "$(dirname "$SELF")" && pwd)"
# ── Locate the bundle ─────────────────────────────────────────────────────────
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
# bundle = Contents/Resources/suwayomi-bundle/
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
find_bundle() {
local base="$1"
for candidate in \
"${base}/../Resources/suwayomi-bundle" \
"${base}/suwayomi-bundle" \
"${base}/../suwayomi-bundle"
do
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
echo "$(cd "$candidate" && pwd)"
return 0
fi
done
return 1
}
BUNDLE=$(find_bundle "$DIR") || {
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
echo "[sidecar] Tried:" >&2
echo " $DIR/../Resources/suwayomi-bundle" >&2
echo " $DIR/suwayomi-bundle" >&2
echo " $DIR/../suwayomi-bundle" >&2
exit 1
}
JAVA="${BUNDLE}/jre/bin/java"
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
echo "[sidecar] BUNDLE=$BUNDLE" >&2
echo "[sidecar] JAVA=$JAVA" >&2
echo "[sidecar] JAR=$JAR" >&2
if [ ! -x "$JAVA" ]; then
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
exit 1
fi
if [ ! -f "$JAR" ]; then
echo "[sidecar] ERROR: jar not found at $JAR" >&2
exit 1
fi
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
# prepended by spawn_server in lib.rs, followed by -jar <path>.
# We call java directly so all JVM flags reach it properly.
exec "$JAVA" \
-Djava.awt.headless=true \
"$@" \
-jar "$JAR"
+31 -10
View File
@@ -1,19 +1,40 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Allow launching tachidesk-server", "description": "Default permissions for Moku",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"shell:allow-open", "shell:allow-open",
{ "shell:allow-kill",
"identifier": "shell:allow-spawn", "shell:allow-spawn",
"allow": [ "shell:allow-execute",
{ "core:window:allow-minimize",
"name": "tachidesk-server", "core:window:allow-unminimize",
"cmd": "tachidesk-server" "core:window:allow-maximize",
} "core:window:allow-unmaximize",
] "core:window:allow-toggle-maximize",
} "core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen",
"core:window:allow-is-maximized",
"core:window:allow-is-minimized",
"core:window:allow-inner-size",
"core:window:allow-outer-size",
"core:window:allow-inner-position",
"core:window:allow-outer-position",
"core:window:allow-scale-factor",
"process:default",
"process:allow-restart",
"http:default",
"http:allow-fetch",
"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://*/*" }
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 B

After

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+744 -42
View File
@@ -1,8 +1,11 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use nix::sys::statvfs::statvfs; use std::io::Write;
use sysinfo::Disks;
use serde::Serialize; use serde::Serialize;
use tauri::{Manager, WindowEvent}; use tauri::{Manager, WindowEvent};
#[cfg(target_os = "windows")]
use tauri::Emitter;
use tauri_plugin_shell::{ShellExt, process::CommandChild}; use tauri_plugin_shell::{ShellExt, process::CommandChild};
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -16,18 +19,43 @@ pub struct StorageInfo {
path: String, path: String,
} }
#[derive(Serialize, Debug)]
#[serde(tag = "kind", content = "message")]
pub enum SpawnError {
NotConfigured(String),
SpawnFailed(String),
}
#[derive(Serialize, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub html_url: String,
}
#[derive(Clone, serde::Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
}
fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
fn resolve_downloads_path(downloads_path: &str) -> PathBuf { fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() { if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path); return PathBuf::from(downloads_path.trim());
} }
let base = std::env::var("XDG_DATA_HOME") suwayomi_data_dir().join("downloads")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/"))
.join(".local/share")
});
base.join("Tachidesk/downloads")
} }
#[tauri::command] #[tauri::command]
@@ -46,68 +74,501 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
0 0
}; };
let stat_path = if path.exists() { path.clone() } else { let stat_path = if path.exists() {
path.clone()
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}; };
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
let frsize = vfs.fragment_size() as u64; let disks = Disks::new_with_refreshed_list();
let total_bytes = vfs.blocks() * frsize; let disk = disks
let free_bytes = vfs.blocks_available() * frsize; .iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
Ok(StorageInfo { Ok(StorageInfo {
manga_bytes, manga_bytes,
total_bytes, total_bytes: disk.total_space(),
free_bytes, free_bytes: disk.available_space(),
path: path.to_string_lossy().into_owned(), path: path.to_string_lossy().into_owned(),
}) })
} }
/// Returns the true OS-level scale factor for the main window.
/// This reads directly from the underlying winit window handle, bypassing
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
/// This is the only reliable way to get the correct DPR in all launch
/// environments — tauri dev, nix run, flatpak, etc.
#[tauri::command] #[tauri::command]
fn get_scale_factor(window: tauri::Window) -> f64 { fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
#[tauri::command]
fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
#[tauri::command]
fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
#[tauri::command]
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
use tauri::Emitter;
use std::fs;
let src_path = std::path::PathBuf::from(src.trim());
let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(());
}
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 mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
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()
}));
}
}
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0) window.scale_factor().unwrap_or(1.0)
} }
fn kill_tachidesk(app: &tauri::AppHandle) { fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>(); let state = app.state::<ServerState>();
let mut guard = state.0.lock().unwrap(); if let Some(child) = state.0.lock().unwrap().take() {
if let Some(child) = guard.take() {
let _ = child.kill(); let _ = child.kill();
println!("Killed tracked server child.");
} }
let _ = std::process::Command::new("pkill")
.arg("-f") #[cfg(target_os = "windows")]
.arg("tachidesk") {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
.creation_flags(CREATE_NO_WINDOW)
.status(); .status();
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
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();
}
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
fn seed_server_conf(data_dir: &PathBuf) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
eprintln!("Could not write server.conf: {e}");
}
return;
}
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(contents, "server.webUIEnabled", "false"),
"server.initialOpenInBrowserEnabled", "false",
),
"server.systemTrayEnabled", "false",
);
let _ = std::fs::write(&conf_path, patched);
}
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
let lines: Vec<&str> = text.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
let mut out = lines
.iter()
.enumerate()
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
return out;
}
let mut out = text;
if !out.ends_with('\n') { out.push('\n'); }
out.push_str(&replacement);
out.push('\n');
out
}
fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("moku\\tachidesk")
}
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("io.github.moku_project.Moku.app/tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
base.join("moku/tachidesk")
}
}
struct ServerInvocation {
bin: String,
args: Vec<String>,
working_dir: Option<PathBuf>,
}
#[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] path: {:?} exists: {}", java, java.exists()));
if java.exists() { Some(java) } else { None }
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
}
fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary = {:?}", binary));
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
if path.exists() {
return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(),
args: vec![],
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
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()),
});
}
}
}
}
#[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
};
#[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={:?} 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) 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"),
}
}
#[cfg(not(target_os = "macos"))]
{
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(resource_dir.clone()),
});
}
}
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())
});
if let Some(jar_path) = jar {
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),
});
}
}
}
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let contents_dir = resource_dir
.parent()
.unwrap_or(&resource_dir)
.to_path_buf();
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
const NATIVE_NAMES: &[&str] = &[
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
let mut found_binary: Option<ServerInvocation> = None;
let mut found_java: Option<(PathBuf, PathBuf)> = None;
'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));
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;
}
}
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));
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;
}
}
}
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");
}
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);
#[cfg(not(target_os = "windows"))]
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
if found {
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
}
}
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
} }
#[tauri::command] #[tauri::command]
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> { fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
let state = app.state::<ServerState>();
{ {
let guard = state.0.lock().unwrap(); let state = app.state::<ServerState>();
if guard.is_some() { if state.0.lock().unwrap().is_some() {
println!("Server already running, skipping spawn.");
return Ok(()); return Ok(());
} }
} }
let shell = app.shell(); let data_dir = suwayomi_data_dir();
match shell.command(&binary).spawn() { 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();
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
seed_server_conf(&data_dir);
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
e
})?;
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);
}
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
let cmd = app.shell()
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&invocation.args)
.current_dir(&working_dir);
match cmd.spawn() {
Ok((_rx, child)) => { Ok((_rx, child)) => {
println!("Spawned server: {}", binary); *app.state::<ServerState>().0.lock().unwrap() = Some(child);
let mut guard = state.0.lock().unwrap();
*guard = Some(child);
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
eprintln!("Failed to spawn {}: {}", binary, e); do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
Err(e.to_string()) Err(SpawnError::SpawnFailed(e.to_string()))
} }
} }
} }
@@ -118,16 +579,257 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command]
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest;
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {}", resp.status()));
}
#[derive(serde::Deserialize)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: Option<String>,
html_url: String,
}
let body = resp.text().await.map_err(|e| e.to_string())?;
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
Ok(releases.into_iter().map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url,
}).collect())
}
#[tauri::command]
#[allow(unused_variables)]
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")]
{
use tauri_plugin_http::reqwest;
use std::io::Write;
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let url = format!("https://api.github.com/repos/moku-project/Moku/releases/tags/{}", tag);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
}
#[derive(serde::Deserialize)]
struct Asset { name: String, browser_download_url: String, size: u64 }
#[derive(serde::Deserialize)]
struct Release { assets: Vec<Asset> }
let body = resp.text().await.map_err(|e| e.to_string())?;
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let asset = release.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 { Some(asset.size) } else { None };
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(())
}
}
#[tauri::command]
fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
#[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())
}
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path().app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
#[tauri::command]
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("moku-backup-{}.json", now);
let path = app.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.blocking_save_file()
.ok_or("Cancelled")?;
let dest = PathBuf::from(path.to_string());
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().into_owned())
}
#[tauri::command]
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog()
.file()
.set_title("Open Moku app data backup")
.blocking_pick_file()
.ok_or("Cancelled")?;
let src = PathBuf::from(path.to_string());
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
Ok(contents)
}
#[tauri::command]
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
let backup_dir = moku_backup_dir(&app);
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
moku_backup_dir(&app).to_string_lossy().into_owned()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_process::init())
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, get_storage_info,
get_default_downloads_path,
check_path_exists,
create_directory,
migrate_downloads,
spawn_server, spawn_server,
kill_server, kill_server,
get_scale_factor, get_platform_ui_scale,
list_releases,
download_and_install_update,
restart_app,
open_path,
pick_downloads_folder,
export_app_data,
import_app_data,
auto_backup_app_data,
get_auto_backup_dir,
]) ])
.setup(|_app| Ok(())) .setup(|_app| Ok(()))
.on_window_event(|window, event| { .on_window_event(|window, event| {
+15 -6
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.2.0", "version": "0.9.1",
"identifier": "dev.moku.app", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm build"
@@ -17,7 +17,8 @@
"minHeight": 600, "minHeight": 600,
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"decorations": false "decorations": false,
"center": true
} }
], ],
"security": { "security": {
@@ -26,14 +27,22 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["appimage"], "targets": ["nsis"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico",
] "icons/icon.png"
],
"externalBin": [],
"windows": {
"nsis": {
"installerIcon": "icons/icon.ico",
"installMode": "currentUser"
}
}
}, },
"plugins": { "plugins": {
"shell": { "shell": {
+3
View File
@@ -9,5 +9,8 @@
"devtools": true "devtools": true
} }
] ]
},
"bundle": {
"externalBin": []
} }
} }
+13
View File
@@ -0,0 +1,13 @@
{
"bundle": {
"targets": ["appimage", "deb"],
"externalBin": [
"binaries/suwayomi-launcher-linux"
],
"resources": {
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
"binaries/suwayomi-bundle/jre": "jre"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"app": {
"windows": [
{
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
]
},
"bundle": {
"targets": ["dmg"],
"externalBin": [
"binaries/suwayomi-server"
],
"resources": {
"binaries/suwayomi-bundle": "suwayomi-bundle"
},
"macOS": {
"minimumSystemVersion": "11.0",
"exceptionDomain": "localhost",
"frameworks": []
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"bundle": {
"resources": [
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
"binaries/suwayomi-bundle/jre/**/*"
]
}
}
-12
View File
@@ -1,12 +0,0 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.content {
flex: 1;
overflow: hidden;
min-height: 0;
}
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { store, 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";
const win = getCurrentWindow();
void platform();
let appReady = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null);
function openThemeEditor(id?: string | null) {
themeEditorEditId = id ?? null;
themeEditorOpen = true;
}
function closeThemeEditor() {
themeEditorOpen = false;
themeEditorEditId = null;
}
$effect(() => { void store.settings.theme; applyTheme(); });
$effect(() => { void store.settings.uiZoom; applyZoom(); });
$effect(() => mountZoomKey());
$effect(() => {
if (!appReady) return;
return mountIdleDetection(
() => { idle = true; },
() => { if (idle) idle = false; },
);
});
$effect(() => {
if (!appReady) return;
const timer = setTimeout(checkForUpdateSilently, 5_000);
return () => clearTimeout(timer);
});
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
} else {
clearReading();
destroyRpc();
}
});
$effect(() => {
if (!store.activeChapter && store.settings.discordRpc) setIdle();
});
$effect(() => {
const next = downloadStore.queue.slice();
downloadStore.detectTransitions(next);
});
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => { devSplash = true; };
applyZoom();
store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen();
});
const unlistenScale = await win.onScaleChanged(async () => {
applyZoom();
});
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);
});
}
startProbe();
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 && !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={retryBoot}
onBypass={() => bypassBoot(() => { appReady = true; })} />
{:else if boot.unsupportedMode || boot.loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { appReady = true; }} />
{:else}
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; }} />
{/if}
<div id="app-shell" class="root">
{#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} />
{/if}
<MangaPreview />
<Toaster />
</div>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; }
</style>
-193
View File
@@ -1,193 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { gql } from "./lib/client";
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import "./styles/global.css";
import { useStore } from "./store";
import Layout from "./components/layout/Layout";
import Reader from "./components/pages/Reader";
import Settings from "./components/settings/Settings";
import MangaPreview from "./components/explore/MangaPreview";
import TitleBar from "./components/layout/TitleBar";
import Toaster from "./components/layout/Toaster";
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import s from "./App.module.css";
const MAX_ATTEMPTS = 30;
export default function App() {
const activeChapter = useStore((s) => s.activeChapter);
const settingsOpen = useStore((s) => s.settingsOpen);
const settings = useStore((s) => s.settings);
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
const addToast = useStore((s) => s.addToast);
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
// appReady = ring filled + transition done, show main UI
const [appReady, setAppReady] = useState(!settings.autoStartServer);
const [failed, setFailed] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const [idle, setIdle] = useState(false);
// dev tools: force show splash
const [devSplash, setDevSplash] = useState(false);
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// expose devSplash trigger via window for settings
useEffect(() => {
(window as any).__mokuShowSplash = () => setDevSplash(true);
return () => { delete (window as any).__mokuShowSplash; };
}, []);
useEffect(() => {
if (!appReady) return;
function resetIdle() {
setIdle(false);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (idleTimeoutMs === 0) return;
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
}
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
resetIdle();
return () => {
events.forEach(e => window.removeEventListener(e, resetIdle));
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
};
}, [appReady, settings.idleTimeoutMin]);
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(prevQueueRef.current, next);
prevQueueRef.current = next;
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
})));
}
useEffect(() => {
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
}, [settings.uiScale]);
useEffect(() => {
const theme = settings.theme ?? "dark";
document.documentElement.setAttribute("data-theme", theme);
}, [settings.theme]);
useEffect(() => {
const p = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", p);
return () => document.removeEventListener("contextmenu", p);
}, []);
useEffect(() => {
if (!settings.autoStartServer) return;
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
console.warn("Could not start server:", err));
return () => { invoke("kill_server").catch(() => {}); };
}, [settings.autoStartServer, settings.serverBinary]);
// Poll until server responds
useEffect(() => {
if (serverProbeOk) return;
let cancelled = false, tries = 0;
async function probe() {
if (cancelled) return;
tries++;
try {
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ query:"{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
} catch {}
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
if (!cancelled) setTimeout(probe, 800);
}
const t = setTimeout(probe, 800);
return () => { cancelled = true; clearTimeout(t); };
}, [serverProbeOk, settings.serverUrl, retryKey]);
useEffect(() => {
if (!appReady) return;
function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
}
poll();
const id = setInterval(poll, 2000);
return () => clearInterval(id);
}, [appReady]);
useEffect(() => {
type P = { chapterId:number; mangaId:number; progress:number }[];
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
return () => { unsub.then(fn => fn()); };
}, [setActiveDownloads]);
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
if (devSplash) {
return (
<SplashScreen
mode="idle"
showFps
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
/>
);
}
// Loading splash — shown until ring fills + transition completes
if (!appReady) {
return (
<SplashScreen
mode="loading"
ringFull={serverProbeOk}
failed={failed}
showCards={settings.splashCards ?? true}
onReady={() => setAppReady(true)}
onRetry={() => {
setFailed(false);
setServerProbeOk(false);
setRetryKey(k => k+1);
}}
/>
);
}
return (
<div className={s.root}>
{idle && !activeChapter && (
<SplashScreen
mode="idle"
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
/>
)}
{!activeChapter && <TitleBar/>}
<div className={s.content}>
{activeChapter ? <Reader/> : <Layout/>}
</div>
{settingsOpen && <Settings/>}
<MangaPreview/>
<Toaster/>
</div>
);
}
+16 -44
View File
@@ -1,31 +1,26 @@
import { store } from "@store/state.svelte";
import { fetchAuthenticated } from "../core/auth";
const DEFAULT_URL = "http://127.0.0.1:4567"; const DEFAULT_URL = "http://127.0.0.1:4567";
function getServerUrl(): string { function getServerUrl(): string {
try { const url = store.settings.serverUrl;
const raw = localStorage.getItem("moku-store"); return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
if (raw) {
const parsed = JSON.parse(raw);
const url = parsed?.state?.settings?.serverUrl;
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
}
} catch {}
return DEFAULT_URL;
} }
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } export function plainThumbUrl(path: string): string {
export function thumbUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("http")) return path; if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`; return `${getServerUrl()}${path}`;
} }
export const thumbUrl = plainThumbUrl;
interface GQLResponse<T> { interface GQLResponse<T> {
data: T; data: T;
errors?: { message: string }[]; errors?: { message: string }[];
} }
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> { function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; } if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
@@ -37,12 +32,6 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
}); });
} }
/**
* Retry wrapper with these guarantees:
* 1. AbortErrors always propagate immediately no retry, no delay.
* 2. Retry delays are abort-aware closing a manga mid-delay doesn't hang.
* 3. If the signal is already aborted before we even start, we bail instantly.
*/
async function fetchWithRetry( async function fetchWithRetry(
url: string, url: string,
init: RequestInit, init: RequestInit,
@@ -50,29 +39,17 @@ async function fetchWithRetry(
retries = 3, retries = 3,
delayMs = 300, delayMs = 300,
): Promise<Response> { ): Promise<Response> {
// Bail immediately if already aborted before we start
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
// Check abort at the top of every iteration
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try { try {
const res = await fetch(url, { ...init, signal }); const res = await fetchAuthenticated(url, init, signal);
// Check abort again — fetch can return a response even after abort in some runtimes
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res; return res;
} catch (e: any) { } catch (e: any) {
// Never retry aborted requests if (e?.authRequired) throw e;
const isAbort = e?.name === "AbortError" || signal?.aborted; if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (isAbort) throw new DOMException("Aborted", "AbortError");
// Last retry — give up
if (i === retries - 1) throw e; if (i === retries - 1) throw e;
// Abort-aware delay between retries
await abortableSleep(delayMs * Math.pow(1.5, i), signal); await abortableSleep(delayMs * Math.pow(1.5, i), signal);
} }
} }
@@ -84,20 +61,15 @@ export async function gql<T>(
variables?: Record<string, unknown>, variables?: Record<string, unknown>,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), { const res = await fetchWithRetry(
method: "POST", `${getServerUrl()}/api/graphql`,
headers: { "Content-Type": "application/json" }, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
body: JSON.stringify({ query, variables }), signal,
}, signal); );
// Check abort before reading the body — avoids hanging on res.json() after cancel
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json(); const json: GQLResponse<T> = await res.json();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) throw new Error(json.errors[0].message); if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data; 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 }
}
}
}
}
}
`;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#091209"/>
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<g transform="translate(256,265) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+21 -26
View File
@@ -1,27 +1,22 @@
<?xml version="1.0" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" <rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> <g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" <path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000" -1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
preserveAspectRatio="xMidYMid meet"> 255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)" m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
fill="#2d7a5f" stroke="none"> 509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790 -447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47 -146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z 196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309 -374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357 217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166 -5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150 -196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339 20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193 -338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254 75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457 -215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100 98 127 -125 c70 -69 136 -147 147 -175z"/>
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262 </g>
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

@@ -1,83 +0,0 @@
.menu {
position: fixed;
z-index: 200;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-1);
min-width: 190px;
box-shadow:
0 0 0 1px rgba(0,0,0,0.08),
0 4px 12px rgba(0,0,0,0.35),
0 16px 40px rgba(0,0,0,0.25);
animation: scaleIn 0.1s ease both;
transform-origin: top left;
}
.item {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 5px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: left;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
border: none;
background: none;
outline: none;
}
.item:hover:not(:disabled),
.itemFocused:not(:disabled) {
background: var(--bg-overlay);
color: var(--text-primary);
}
/* Icon area — fixed-width column so labels align */
.itemIconWrap {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-faint);
transition: color var(--t-fast);
border-radius: var(--radius-sm);
}
.item:hover .itemIconWrap,
.itemFocused .itemIconWrap {
color: var(--text-muted);
}
.itemLabel {
flex: 1;
line-height: 1.3;
}
/* Danger variant */
.itemDanger { color: var(--color-error); }
.itemDanger:hover:not(:disabled),
.itemDanger.itemFocused:not(:disabled) {
background: var(--color-error-bg);
color: var(--color-error);
}
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
/* Disabled */
.itemDisabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.separator {
height: 1px;
background: var(--border-dim);
margin: 3px var(--sp-1);
}
-133
View File
@@ -1,133 +0,0 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import s from "./ContextMenu.module.css";
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
separator?: never;
}
export interface ContextMenuSeparator {
separator: true;
label?: never;
icon?: never;
onClick?: never;
danger?: never;
disabled?: never;
}
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
interface Props {
x: number;
y: number;
items: ContextMenuEntry[];
onClose: () => void;
}
export default function ContextMenu({ x, y, items, onClose }: Props) {
const menuRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState<number>(-1);
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
const actionable = items
.map((_, i) => i)
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
useEffect(() => {
function onDown(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
if (e.key === "ArrowDown") {
e.preventDefault();
setFocused((prev) => {
const cur = actionable.indexOf(prev);
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
});
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setFocused((prev) => {
const cur = actionable.indexOf(prev);
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
});
return;
}
if (e.key === "Enter" && focused >= 0) {
e.preventDefault();
const item = items[focused] as ContextMenuItem;
if (item && !item.disabled) { item.onClick(); onClose(); }
return;
}
}
document.addEventListener("mousedown", onDown, true);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("keydown", onKey, true);
};
}, [onClose, focused, actionable, items]);
// Focus first item on open
useEffect(() => {
if (actionable.length) setFocused(actionable[0]);
}, []);
const getPosition = useCallback(() => {
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
const scaledX = x / zoom;
const scaledY = y / zoom;
const menuW = 200;
const menuH = items.length * 34;
const vw = window.innerWidth / zoom;
const vh = window.innerHeight / zoom;
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
return { left: Math.max(4, left), top: Math.max(4, top) };
}, [x, y, items.length]);
return createPortal(
<div
ref={menuRef}
className={s.menu}
style={getPosition()}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) => {
if ("separator" in item && item.separator) {
return <div key={i} className={s.separator} />;
}
const mi = item as ContextMenuItem;
const isFocused = focused === i;
return (
<button
key={i}
className={[
s.item,
mi.danger ? s.itemDanger : "",
mi.disabled ? s.itemDisabled : "",
isFocused ? s.itemFocused : "",
].filter(Boolean).join(" ")}
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
onMouseEnter={() => !mi.disabled && setFocused(i)}
onMouseLeave={() => setFocused(-1)}
disabled={mi.disabled}
>
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
{mi.icon ?? null}
</span>
<span className={s.itemLabel}>{mi.label}</span>
</button>
);
})}
</div>,
document.body
);
}
@@ -1,215 +0,0 @@
.root {
padding: var(--sp-6);
overflow-y: auto;
height: 100%;
animation: fadeIn 0.14s ease both;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.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;
}
.headerActions { display: flex; gap: var(--sp-2); }
.iconBtn {
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);
}
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.iconBtn:disabled { opacity: 0.3; cursor: default; }
/* Loading state — accent tint so it's visually distinct */
.iconBtnLoading {
border-color: var(--accent-dim);
color: var(--accent-fg);
background: var(--accent-muted);
}
.iconBtnLoading:hover:not(:disabled) {
border-color: var(--accent-dim);
color: var(--accent-fg);
background: var(--accent-muted);
}
.statusBar {
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);
margin-bottom: var(--sp-4);
}
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-faint);
flex-shrink: 0;
transition: background var(--t-base);
}
.statusDotActive {
background: var(--accent);
animation: pulse 1.6s ease infinite;
}
.statusText {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
flex: 1;
letter-spacing: var(--tracking-wide);
transition: color var(--t-base);
}
.statusCount {
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);
}
.rowActive { border-color: var(--accent-dim); }
/* Fade out rows being removed */
.rowRemoving { opacity: 0.4; pointer-events: none; }
/* Thumbnail */
.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);
}
.thumbImg {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Info block */
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
overflow: hidden;
min-width: 0;
}
.mangaTitle {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapterName {
font-size: var(--text-xs);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pagesLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.progressWrap {
height: 2px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
margin-top: 4px;
}
.progressBar {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
/* Right side */
.rowRight {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
flex-shrink: 0;
}
.stateLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.removeBtn {
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);
}
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.removeBtn: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);
}
-230
View File
@@ -1,230 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
} from "../../lib/queries";
import { useStore } from "../../store";
import type { DownloadStatus } from "../../lib/types";
import s from "./DownloadQueue.module.css";
export default function DownloadQueue() {
const [status, setStatus] = useState<DownloadStatus | null>(null);
const [loading, setLoading] = useState(true);
const [togglingPlay, setTogglingPlay] = useState(false);
const [clearing, setClearing] = useState(false);
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
// Apply status to local state + global store.
// Completion toasting is handled globally in App.tsx — no duplication here.
const applyStatus = useCallback((ds: DownloadStatus) => {
setStatus(ds);
setActiveDownloads(
ds.queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}))
);
}, [setActiveDownloads]);
async function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => setLoading(false));
}
useEffect(() => {
poll();
const id = setInterval(poll, 2000);
return () => clearInterval(id);
}, []);
// ── Actions ─────────────────────────────────────────────────────────────────
async function togglePlay() {
if (togglingPlay) return;
setTogglingPlay(true);
const wasRunning = status?.state === "STARTED";
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
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 {
setTogglingPlay(false);
}
}
async function clear() {
if (clearing) return;
setClearing(true);
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
applyStatus(d.clearDownloader.downloadStatus);
} catch (e) {
console.error(e);
poll();
} finally {
setClearing(false);
}
}
async function dequeue(chapterId: number) {
if (dequeueing.has(chapterId)) return;
setDequeueing((prev) => new Set(prev).add(chapterId));
setStatus((prev) =>
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
);
try {
await gql(DEQUEUE_DOWNLOAD, { chapterId });
poll();
} catch (e) {
console.error(e);
poll();
} finally {
setDequeueing((prev) => {
const next = new Set(prev);
next.delete(chapterId);
return next;
});
}
}
const queue = status?.queue ?? [];
const isRunning = status?.state === "STARTED";
function pagesDownloaded(progress: number, pageCount: number): number {
return Math.round(progress * pageCount);
}
return (
<div className={s.root}>
<div className={s.header}>
<h1 className={s.heading}>Downloads</h1>
<div className={s.headerActions}>
<button
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
onClick={togglePlay}
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
title={isRunning ? "Pause" : "Resume"}
>
{togglingPlay ? (
<CircleNotch size={14} weight="light" className="anim-spin" />
) : isRunning ? (
<Pause size={14} weight="fill" />
) : (
<Play size={14} weight="fill" />
)}
</button>
<button
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
onClick={clear}
disabled={clearing || queue.length === 0}
title="Clear queue"
>
{clearing ? (
<CircleNotch size={14} weight="light" className="anim-spin" />
) : (
<Trash size={14} weight="regular" />
)}
</button>
</div>
</div>
<div className={s.statusBar}>
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
<span className={s.statusText}>
{togglingPlay
? (isRunning ? "Pausing…" : "Starting…")
: isRunning ? "Downloading" : "Paused"}
</span>
<span className={s.statusCount}>{queue.length} queued</span>
</div>
{loading ? (
<div className={s.empty}>
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
) : queue.length === 0 ? (
<div className={s.empty}>Queue is empty.</div>
) : (
<div className={s.list}>
{queue.map((item, i) => {
const isActive = i === 0 && isRunning;
const pages = item.chapter.pageCount ?? 0;
const done = pagesDownloaded(item.progress, pages);
const manga = item.chapter.manga;
const isRemoving = dequeueing.has(item.chapter.id);
return (
<div
key={item.chapter.id}
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
>
{manga?.thumbnailUrl && (
<div className={s.thumb}>
<img
src={thumbUrl(manga.thumbnailUrl)}
alt={manga.title}
className={s.thumbImg}
loading="lazy"
decoding="async"
/>
</div>
)}
<div className={s.info}>
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
<span className={s.chapterName}>{item.chapter.name}</span>
{pages > 0 && (
<span className={s.pagesLabel}>
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
</span>
)}
{isActive && (
<div className={s.progressWrap}>
<div
className={s.progressBar}
style={{ width: `${Math.round(item.progress * 100)}%` }}
/>
</div>
)}
</div>
<div className={s.rowRight}>
<span className={s.stateLabel}>{item.state}</span>
{!isActive && (
<button
className={s.removeBtn}
onClick={() => dequeue(item.chapter.id)}
disabled={isRemoving}
title="Remove from queue"
>
{isRemoving
? <CircleNotch size={11} weight="light" className="anim-spin" />
: <X size={12} weight="light" />}
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
-441
View File
@@ -1,441 +0,0 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
.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;
gap: var(--sp-4);
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--sp-4);
}
.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;
flex-shrink: 0;
}
.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);
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap;
}
.tab:hover { color: var(--text-muted); }
.tabActive {
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
}
.tabActive:hover { color: var(--accent-fg); }
/* Source picker */
.sourcePicker {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.sourcePickerLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
white-space: nowrap;
}
.sourceSelect {
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 4px 8px;
color: var(--text-secondary);
font-size: var(--text-sm);
font-family: var(--font-ui);
outline: none;
cursor: pointer;
transition: border-color var(--t-base);
max-width: 160px;
}
.sourceSelect:focus { border-color: var(--border-strong); }
/* ── Scrollable body ─────────────────────────────────────────────────────── */
.body {
flex: 1;
overflow-y: auto;
padding: var(--sp-5) 0 var(--sp-6);
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
}
/* ── Section ─────────────────────────────────────────────────────────────── */
.section {
margin-bottom: var(--sp-6);
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--sp-6) var(--sp-3);
}
.sectionTitle {
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;
}
.sectionTitleIcon {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.seeAll {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 2px 0;
transition: color var(--t-base);
}
.seeAll:hover { color: var(--accent-fg); }
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
.row {
display: flex;
gap: var(--sp-3);
padding: 0 var(--sp-6);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: smooth;
}
.row::-webkit-scrollbar { display: none; }
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
.card {
flex-shrink: 0;
width: 110px;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover .cover { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); }
.coverWrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
transform: translateZ(0);
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
transition: filter var(--t-base);
will-change: filter;
}
.inLibraryBadge {
position: absolute;
bottom: var(--sp-1);
left: var(--sp-1);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
padding: 2px 5px;
border-radius: var(--radius-sm);
}
.progressBar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--bg-overlay);
}
.progressFill {
height: 100%;
background: var(--accent-fg);
border-radius: 0 2px 0 0;
transition: width 0.2s ease;
}
.title {
margin-top: var(--sp-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color var(--t-base);
}
.subtitle {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
margin-top: 2px;
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Ghost card — invisible placeholder to fill row trailing space */
.ghostCard {
flex-shrink: 0;
width: 110px;
aspect-ratio: 2 / 3;
pointer-events: none;
visibility: hidden;
}
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
.skeletonRow {
display: flex;
gap: var(--sp-3);
padding: 0 var(--sp-6);
overflow: hidden;
}
.cardSkeleton { flex-shrink: 0; width: 110px; }
.coverSkeleton {
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
}
.titleSkeleton {
height: 11px;
margin-top: var(--sp-2);
width: 80%;
}
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
.drillRoot {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.drillHeader {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.back {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--text-muted);
font-size: var(--text-xs);
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base);
flex-shrink: 0;
}
.back:hover { color: var(--text-secondary); }
.drillTitle {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
letter-spacing: var(--tracking-tight);
}
.drillGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
gap: var(--sp-4);
padding: var(--sp-5) var(--sp-6);
overflow-y: auto;
flex: 1;
align-content: start;
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
contain: layout style;
}
.drillCard {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.drillCard:hover .cover { filter: brightness(1.06); }
.drillCard:hover .title { color: var(--text-primary); }
/* ── Empty state ─────────────────────────────────────────────────────────── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sp-8) var(--sp-6);
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
gap: var(--sp-2);
text-align: center;
}
.emptyHint {
font-size: var(--text-2xs);
color: var(--text-faint);
opacity: 0.6;
}
/* ── No source state ─────────────────────────────────────────────────────── */
.noSource {
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-4) var(--sp-6);
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
/* ── Explore More end-cap card ───────────────────────────────────────────── */
.exploreMoreCard {
flex-shrink: 0;
width: 110px;
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
border: 1px dashed var(--border-strong);
background: var(--bg-raised);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color var(--t-base), background var(--t-base);
padding: 0;
}
.exploreMoreCard:hover {
border-color: var(--accent);
background: var(--accent-muted);
}
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
.exploreMoreInner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3);
pointer-events: none;
}
.exploreMoreIcon {
color: var(--text-faint);
transition: color var(--t-base);
}
.exploreMoreLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
transition: color var(--t-base);
text-align: center;
}
.exploreMoreGenre {
font-size: var(--text-2xs);
color: var(--text-faint);
opacity: 0.6;
text-align: center;
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
}
-535
View File
@@ -1,535 +0,0 @@
import { useEffect, useState, useMemo, useRef, memo } from "react";
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
import GenreDrillPage from "./GenreDrillPage";
import { gql, thumbUrl } from "../../lib/client";
import { UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types";
import SourceList from "../sources/SourceList";
import SourceBrowse from "../sources/SourceBrowse";
import s from "./Explore.module.css";
// ── Frecency score ────────────────────────────────────────────────────────────
function frecencyScore(readAt: number, count: number): number {
const hoursSince = (Date.now() - readAt) / 3_600_000;
return count / Math.log(hoursSince + 2);
}
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
const GHOST_COUNT = 3;
const ROW_CAP = 25;
// Hijack vertical wheel delta → horizontal scroll on .row divs
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const el = e.currentTarget;
const canScrollLeft = el.scrollLeft > 0;
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
if (!canScrollLeft && !canScrollRight) return;
e.stopPropagation();
el.scrollLeft += e.deltaY;
}
function SkeletonRow({ count = 8 }: { count?: number }) {
return (
<div className={s.skeletonRow}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={s.cardSkeleton}>
<div className={["skeleton", s.coverSkeleton].join(" ")} />
<div className={["skeleton", s.titleSkeleton].join(" ")} />
</div>
))}
</div>
);
}
// ── Cover image with fade-in ──────────────────────────────────────────────────
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img
src={src} alt={alt} className={className}
loading="lazy" decoding="async"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
/>
);
});
// ── Mini card ─────────────────────────────────────────────────────────────────
const MiniCard = memo(function MiniCard({
manga, onClick, onContextMenu, subtitle, progress,
}: {
manga: Manga;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
subtitle?: string;
progress?: number;
}) {
return (
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
<div className={s.coverWrap}>
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
{progress !== undefined && progress > 0 && (
<div className={s.progressBar}>
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
</div>
)}
</div>
<p className={s.title}>{manga.title}</p>
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
</button>
);
});
// ── Explore More end-cap ──────────────────────────────────────────────────────
const ExploreMoreCard = memo(function ExploreMoreCard({
genre, onClick,
}: { genre: string; onClick: () => void }) {
return (
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
<div className={s.exploreMoreInner}>
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
<span className={s.exploreMoreLabel}>Explore more</span>
<span className={s.exploreMoreGenre}>{genre}</span>
</div>
</button>
);
});
// ── Section ───────────────────────────────────────────────────────────────────
function Section({
title, icon, onSeeAll, loading, children,
}: {
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
loading?: boolean; children: React.ReactNode;
}) {
return (
<div className={s.section}>
<div className={s.sectionHeader}>
<span className={s.sectionTitle}>
<span className={s.sectionTitleIcon}>{icon}{title}</span>
</span>
{onSeeAll && (
<button className={s.seeAll} onClick={onSeeAll}>
See all <ArrowRight size={11} weight="light" />
</button>
)}
</div>
{loading ? <SkeletonRow /> : children}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type ExploreMode = "explore" | "sources";
export default function Explore() {
const [mode, setMode] = useState<ExploreMode>("explore");
const activeSource = useStore((s) => s.activeSource);
const genreFilter = useStore((s) => s.genreFilter);
if (activeSource) return <SourceBrowse />;
if (genreFilter) return <GenreDrillPage />;
return (
<div className={s.root}>
<div className={s.header}>
<div className={s.headerLeft}>
<h1 className={s.heading}>Explore</h1>
<div className={s.tabs}>
<button
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
onClick={() => setMode("explore")}
>
<Compass size={11} weight="bold" /> Explore
</button>
<button
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
onClick={() => setMode("sources")}
>
<List size={11} weight="bold" /> Sources
</button>
</div>
</div>
</div>
{/* Keep ExploreFeed always mounted so data survives tab switches */}
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
{mode === "sources" && <SourceList />}
</div>
);
}
// ── Explore feed ──────────────────────────────────────────────────────────────
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
function ExploreFeed() {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [loadingLib, setLoadingLib] = useState(true);
const [popularManga, setPopularManga] = useState<Manga[]>([]);
const [loadingPopular, setLoadingPopular] = useState(true);
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
const [loadingGenres, setLoadingGenres] = useState(false);
const [sources, setSources] = useState<Source[]>([]);
const [loadError, setLoadError] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const fetchedGenresRef = useRef<string>("");
const history = useStore((s) => s.history);
const settings = useStore((s) => s.settings);
const setPreviewManga = useStore((s) => s.setPreviewManga);
const setGenreFilter = useStore((s) => s.setGenreFilter);
const folders = useStore((s) => s.settings.folders);
const addFolder = useStore((s) => s.addFolder);
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
useEffect(() => {
return () => { abortRef.current?.abort(); };
}, []);
function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, manga: m });
}
function buildCtxItems(m: Manga): ContextMenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
.catch(console.error),
},
...(folders.length > 0 ? [
{ separator: true } as ContextMenuEntry,
...folders.map((f): ContextMenuEntry => ({
label: f.mangaIds.includes(m.id) ? `${f.name}` : f.name,
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: <FolderSimplePlus size={13} weight="light" />,
onClick: () => {
const name = prompt("Folder name:");
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
},
},
];
}
// ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
useEffect(() => {
// If we already have data, no need to re-fetch (cache hit path)
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
if (alreadyLoaded) return;
setLoadingLib(true);
setLoadingPopular(true);
setLoadError(false);
const preferredLang = settings.preferredExtensionLang || "en";
// Clear stale failed cache entries so we actually retry
if (retryCount > 0) {
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.SOURCES);
fetchedGenresRef.current = "";
}
// Library — fire immediately, independent of sources
cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
]).then(([all, lib]) => {
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
})
).then(setAllManga)
.catch((e) => { console.error(e); setLoadError(true); })
.finally(() => setLoadingLib(false));
// Sources — then kick off popular AND genres simultaneously
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
).then((allSources) => {
if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; }
// Cap to 2 sources for the explore feed — halves the network calls
const topSources = getTopSources(allSources).slice(0, 2);
setSources(allSources);
// ── Popular — don't block genres ──────────────────────────────────
cache.get(CACHE_KEYS.POPULAR, () =>
Promise.allSettled(
topSources.map((src) =>
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "POPULAR", page: 1, query: null,
}).then((d) => d.fetchSourceManga.mangas)
)
).then((results) => {
const merged: Manga[] = [];
for (const r of results)
if (r.status === "fulfilled") merged.push(...r.value);
return dedupeMangaByTitle(merged).slice(0, 30);
})
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
// ── Genres — start immediately alongside popular using foundational
// genres as a starting point; personalized genres replace these once
// library loads. Results stream in as each genre resolves.
const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3);
const genreKey = genresToFetch.join(",");
if (fetchedGenresRef.current === genreKey) return;
fetchedGenresRef.current = genreKey;
setLoadingGenres(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const streamingMap = new Map<string, Manga[]>();
Promise.allSettled(
genresToFetch.map((genre) =>
cache.get(CACHE_KEYS.GENRE(genre), () =>
Promise.allSettled(
topSources.map((src) =>
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: genre,
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
)
).then((results) => {
const merged: Manga[] = [];
for (const r of results)
if (r.status === "fulfilled") merged.push(...r.value);
return dedupeMangaByTitle(merged).slice(0, 24);
})
).then((mangas) => {
if (ctrl.signal.aborted) return;
// Stream: each genre paints immediately as it resolves
streamingMap.set(genre, mangas);
setGenreResults(new Map(streamingMap));
})
)
)
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
})
.catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [retryCount]);
// ── Frecency genres (derived from history + library) ──────────────────────
const frecencyGenres = useMemo(() => {
const mangaScores = new Map<number, number>();
const mangaReadAt = new Map<number, number>();
for (const entry of history) {
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
mangaReadAt.set(entry.mangaId, entry.readAt);
}
const genreWeights = new Map<string, number>();
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
for (const [mangaId, count] of mangaScores.entries()) {
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
}
if (genreWeights.size === 0)
allManga.filter((m) => m.inLibrary).forEach((m) =>
(m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
return Array.from(genreWeights.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g);
}, [allManga, history]);
// ── Re-fetch only when personalized genres differ from what's cached ───────
useEffect(() => {
if (frecencyGenres.length === 0 || sources.length === 0) return;
const genreKey = frecencyGenres.join(",");
if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit
fetchedGenresRef.current = genreKey;
setLoadingGenres(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const topSources = getTopSources(sources).slice(0, 2);
const streamingMap = new Map<string, Manga[]>();
Promise.allSettled(
frecencyGenres.map((genre) =>
cache.get(CACHE_KEYS.GENRE(genre), () =>
Promise.allSettled(
topSources.map((src) =>
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: genre,
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
)
).then((results) => {
const merged: Manga[] = [];
for (const r of results)
if (r.status === "fulfilled") merged.push(...r.value);
return dedupeMangaByTitle(merged).slice(0, 24);
})
).then((mangas) => {
if (ctrl.signal.aborted) return;
streamingMap.set(genre, mangas);
setGenreResults(new Map(streamingMap));
})
)
)
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
}, [frecencyGenres, sources]);
function openManga(m: Manga) { setPreviewManga(m); }
// ── Continue reading ──────────────────────────────────────────────────────
const continueReading = useMemo(() => {
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
const seen = new Set<number>();
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
for (const entry of history) {
if (seen.has(entry.mangaId)) continue;
seen.add(entry.mangaId);
const manga = mangaMap.get(entry.mangaId);
if (!manga) continue;
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
if (result.length >= 12) break;
}
return result;
}, [history, allManga]);
// ── Recommended ───────────────────────────────────────────────────────────
const recommended = useMemo(() => {
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
const continueIds = new Set(continueReading.map((r) => r.manga.id));
return allManga
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
.slice(0, 20);
}, [allManga, frecencyGenres, continueReading]);
const genresLoading = loadingGenres;
return (
<div className={s.body}>
{(continueReading.length > 0 || loadingLib) && (
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
<div className={s.row} onWheel={handleRowWheel}>
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
</div>
</Section>
)}
{(recommended.length > 0 || loadingLib) && (
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
<div className={s.row} onWheel={handleRowWheel}>
{recommended.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
</div>
</Section>
)}
{(popularManga.length > 0 || loadingPopular) && (
<Section
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
icon={<Fire size={11} weight="bold" />}
loading={loadingPopular}
>
{sources.length === 0 ? (
<div className={s.noSource}>No sources installed. Add extensions first.</div>
) : (
<div className={s.row} onWheel={handleRowWheel}>
{popularManga.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
</div>
)}
</Section>
)}
{frecencyGenres.map((genre) => {
const items = genreResults.get(genre) ?? [];
const isLoading = genresLoading && items.length === 0;
if (!isLoading && items.length === 0) return null;
return (
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
<div className={s.row} onWheel={handleRowWheel}>
{items.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
))}
{items.length >= ROW_CAP && (
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
)}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
</div>
</Section>
);
})}
{!loadingLib && !loadingPopular && !loadingGenres &&
continueReading.length === 0 && recommended.length === 0 &&
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
<div className={s.empty}>
{loadError ? (
<>
<span>Could not reach Suwayomi</span>
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
<button
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
>
Retry
</button>
</>
) : (
<>
<span>Nothing to explore yet</span>
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
</>
)}
</div>
)}
{ctx && (
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
)}
</div>
);
}
@@ -1,176 +0,0 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.header {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.back {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--text-muted);
font-size: var(--text-xs);
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base);
flex-shrink: 0;
}
.back:hover { color: var(--text-secondary); }
.title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
letter-spacing: var(--tracking-tight);
}
.loadingHint {
margin-left: auto;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
/* Grid fills entire remaining height, no show-more needed */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
gap: var(--sp-4);
padding: var(--sp-5) var(--sp-6) var(--sp-6);
overflow-y: auto;
flex: 1;
align-content: start;
/* Smooth GPU-accelerated scrolling */
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
contain: layout style;
}
.card {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover .cover { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); }
.coverWrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
/* Solid bg shown while image fades in — matches skeleton color */
background: var(--bg-raised);
border: 1px solid var(--border-dim);
transform: translateZ(0);
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
transition: filter var(--t-base);
will-change: filter;
}
.inLibraryBadge {
position: absolute;
bottom: var(--sp-1);
left: var(--sp-1);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
padding: 2px 5px;
border-radius: var(--radius-sm);
}
.cardTitle {
margin-top: var(--sp-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color var(--t-base);
}
/* Skeletons */
.cardSkeleton { padding: 0; }
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
.empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
.resultCount {
margin-left: auto;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
/* Show more — spans full grid width */
.showMoreCell {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: var(--sp-2) 0 var(--sp-4);
}
.showMoreBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 20px;
border-radius: var(--radius-md);
background: var(--bg-raised);
color: var(--text-muted);
border: 1px solid var(--border-dim);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.showMoreBtn:hover:not(:disabled) {
color: var(--text-secondary);
border-color: var(--border-strong);
}
.showMoreBtn:disabled {
opacity: 0.5;
cursor: default;
}
-306
View File
@@ -1,306 +0,0 @@
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
import { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import type { Manga, Source } from "../../lib/types";
import s from "./GenreDrillPage.module.css";
// ── Constants ──────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50; // how many items to show at once
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
const MAX_SOURCES = 12; // max sources to query concurrently
const CONCURRENCY = 4; // parallel source fetches
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
// ── CoverImg ──────────────────────────────────────────────────────────────────
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img
src={src} alt={alt} className={className}
loading="lazy" decoding="async"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
/>
);
});
// ── GenreDrillPage ────────────────────────────────────────────────────────────
export default function GenreDrillPage() {
const genre = useStore((st) => st.genreFilter);
const setGenreFilter = useStore((st) => st.setGenreFilter);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const settings = useStore((st) => st.settings);
const folders = useStore((st) => st.settings.folders);
const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
// Per-source next-page tracker; -1 means exhausted
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!genre) return;
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoadingInitial(true);
setSourceManga([]);
setLibraryManga([]);
setVisibleCount(PAGE_SIZE);
nextPageRef.current = new Map();
const preferredLang = settings.preferredExtensionLang || "en";
// ── Library (fire-and-forget, doesn't block skeleton removal) ─────────
cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
]).then(([all, lib]) => {
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
})
)
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
// ── Sources: stream results in as each source responds ────────────────
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
).then(async (allSources) => {
const sources = allSources.slice(0, MAX_SOURCES);
sourcesRef.current = sources;
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
for (const src of sources) nextPageRef.current.set(src.id, -1);
await runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
);
pageItems.push(...d.fetchSourceManga.mangas);
if (!d.fetchSourceManga.hasNextPage) {
nextPageRef.current.set(src.id, -1);
break;
} else if (page === INITIAL_PAGES) {
// Has more pages beyond what we fetched upfront — mark for "load more"
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
}
} catch (e: any) {
if (e?.name === "AbortError") return;
nextPageRef.current.set(src.id, -1);
break;
}
}
if (!ctrl.signal.aborted && pageItems.length > 0) {
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
// legitimate different-source results that share a common title (e.g. "Action" genre)
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
// Drop the skeleton as soon as we have anything
setLoadingInitial(false);
}
}, ctrl.signal);
if (!ctrl.signal.aborted) setLoadingInitial(false);
}).catch((e) => {
if (e?.name !== "AbortError") console.error(e);
if (!ctrl.signal.aborted) setLoadingInitial(false);
});
return () => { ctrl.abort(); };
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Derived merged list ────────────────────────────────────────────────────
const filtered = useMemo(() => {
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
const libIds = new Set(libMatches.map((m) => m.id));
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
return dedupeMangaById([...libMatches, ...srcAll]);
}, [libraryManga, sourceManga, genre]);
// ── Load more ──────────────────────────────────────────────────────────────
const hasMoreVisible = visibleCount < filtered.length;
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
const hasMore = hasMoreVisible || hasMoreNetwork;
const loadMore = useCallback(async () => {
if (loadingMore) return;
// If there are buffered results, just reveal the next page
if (hasMoreVisible) {
setVisibleCount((v) => v + PAGE_SIZE);
return;
}
// Fetch next pages from network
const sources = sourcesRef.current.filter(
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
);
if (!sources.length) return;
setLoadingMore(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
await runConcurrent(sources, async (src) => {
const page = nextPageRef.current.get(src.id)!;
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
);
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
} catch (e: any) {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
}
}, ctrl.signal);
} finally {
if (!ctrl.signal.aborted) {
setVisibleCount((v) => v + PAGE_SIZE);
setLoadingMore(false);
}
}
}, [loadingMore, hasMoreVisible, genre]);
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, manga: m });
}
function buildCtxItems(m: Manga): ContextMenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
cache.clear(CACHE_KEYS.LIBRARY);
})
.catch(console.error),
},
...(folders.length > 0 ? [
{ separator: true } as ContextMenuEntry,
...folders.map((f): ContextMenuEntry => ({
label: f.mangaIds.includes(m.id) ? `${f.name}` : f.name,
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: <FolderSimplePlus size={13} weight="light" />,
onClick: () => {
const name = prompt("Folder name:");
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
},
},
];
}
const visibleItems = filtered.slice(0, visibleCount);
return (
<div className={s.root}>
<div className={s.header}>
<button className={s.back} onClick={() => setGenreFilter("")}>
<ArrowLeft size={13} weight="light" />
<span>Back</span>
</button>
<span className={s.title}>{genre}</span>
{loadingInitial && filtered.length === 0 ? null : (
<span className={s.resultCount}>
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
</span>
)}
{!loadingInitial && hasMoreNetwork && (
<span className={s.loadingHint}>More loading</span>
)}
</div>
{loadingInitial && filtered.length === 0 ? (
<div className={s.grid}>
{Array.from({ length: 50 }).map((_, i) => (
<div key={i} className={s.cardSkeleton}>
<div className={["skeleton", s.coverSkeleton].join(" ")} />
<div className={["skeleton", s.titleSkeleton].join(" ")} />
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className={s.empty}>No manga found for "{genre}".</div>
) : (
<div className={s.grid}>
{visibleItems.map((m) => (
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
<div className={s.coverWrap}>
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
</div>
<p className={s.cardTitle}>{m.title}</p>
</button>
))}
{hasMore && (
<div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading</>
: `Show more`}
</button>
</div>
)}
</div>
)}
{ctx && (
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
)}
</div>
);
}
@@ -1,395 +0,0 @@
/* ── Animations ──────────────────────────────────────────────────────────── */
@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.8 } }
/* ── Backdrop ────────────────────────────────────────────────────────────── */
.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 shell ─────────────────────────────────────────────────────────── */
.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), 0 8px 24px rgba(0,0,0,0.4);
}
/* ── Cover column ────────────────────────────────────────────────────────── */
.coverCol {
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-y: auto; overflow-x: hidden;
scrollbar-width: none;
}
.coverCol::-webkit-scrollbar { display: none; }
.coverWrap {
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;
}
.coverSpinner {
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);
}
.coverActions {
display: flex; flex-direction: column; gap: var(--sp-2);
}
/* ── Cover action buttons ────────────────────────────────────────────────── */
.actionBtn {
display: flex; align-items: center; justify-content: 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;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-align: center;
}
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.actionBtn:disabled { opacity: 0.4; cursor: default; }
.actionBtnActive {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
.actionBtnLabel {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
/* ── Folder picker ───────────────────────────────────────────────────────── */
.folderWrap { position: relative; width: 100%; }
.folderMenu {
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;
}
.folderEmpty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
}
.folderItem {
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);
}
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folderItemOn { color: var(--accent-fg); }
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folderCreateRow {
display: flex; gap: var(--sp-1); padding: var(--sp-1);
}
.folderInput {
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;
}
.folderInput:focus { border-color: var(--border-focus); }
.folderOkBtn {
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);
}
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folderNewBtn {
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);
}
.folderNewBtn:hover { color: var(--accent-fg); }
/* ── Content column ──────────────────────────────────────────────────────── */
.content {
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.contentHeader {
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;
}
.titleBlock {
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);
}
.skByline {
height: 14px; width: 55%;
background: var(--bg-overlay); border-radius: var(--radius-sm);
animation: pulse 1.4s ease infinite;
}
.closeBtn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-faint); border: none; background: none;
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), background var(--t-base);
}
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* ── Scrollable body ─────────────────────────────────────────────────────── */
.contentBody {
flex: 1; overflow-y: auto;
padding: var(--sp-5) var(--sp-6);
display: flex; flex-direction: column; gap: var(--sp-4);
scrollbar-width: thin;
}
/* ── Error banner ────────────────────────────────────────────────────────── */
.errorBanner {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--color-warn, #f59e0b);
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
}
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
.skRow {
display: flex; gap: var(--sp-2); align-items: center;
}
.skBadge {
height: 20px; width: 54px;
background: var(--bg-overlay); border-radius: var(--radius-sm);
animation: pulse 1.4s ease infinite;
}
.skDesc {
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
}
.skLine {
height: 13px; background: var(--bg-overlay);
border-radius: var(--radius-sm);
animation: pulse 1.4s ease infinite;
}
/* ── Badges ──────────────────────────────────────────────────────────────── */
.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);
}
.badgeGreen {
background: color-mix(in srgb, #22c55e 12%, transparent);
border-color: color-mix(in srgb, #22c55e 30%, transparent);
color: #22c55e;
}
.badgeDim { /* default */ }
.badgeAccent {
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
}
.badgeUnread {
background: color-mix(in srgb, #f59e0b 12%, transparent);
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
color: #f59e0b;
}
.badgeNsfw {
background: color-mix(in srgb, #ef4444 12%, transparent);
border-color: color-mix(in srgb, #ef4444 30%, transparent);
color: #ef4444;
}
/* ── Chapter box — clearly separated from description ────────────────────── */
.chapterBox {
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);
}
.chapterLoading {
display: flex; align-items: center; gap: var(--sp-2);
}
.chapterLoadingLabel {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.chapterMeta {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
}
.chapterLabel {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
}
.dlAllBtn {
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);
}
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
.progressTrack {
height: 3px; background: var(--bg-overlay);
border-radius: var(--radius-full); overflow: hidden;
}
.progressFill {
height: 100%; background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.3s ease;
}
.readBtn {
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);
}
.readBtn:hover { filter: brightness(1.1); }
/* ── Description block ───────────────────────────────────────────────────── */
.descBlock {
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;
}
.descOpen {
display: block; -webkit-line-clamp: unset; overflow: visible;
}
.descToggle {
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);
}
.descToggle:hover { color: var(--accent-fg); }
/* ── Genre tags ──────────────────────────────────────────────────────────── */
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genreTag {
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);
}
.genreTagClickable {
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.genreTagClickable:hover {
color: var(--accent-fg);
border-color: var(--accent-dim);
background: var(--accent-muted);
}
/* ── Metadata table ──────────────────────────────────────────────────────── */
.metaTable {
display: flex; flex-direction: column; gap: 1px;
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
}
.metaRow {
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
}
.metaKey {
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;
}
.metaVal {
font-size: var(--text-sm); color: var(--text-secondary);
line-height: var(--leading-snug);
}
.metaLink {
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);
}
.metaLink:hover { opacity: 0.75; }
-569
View File
@@ -1,569 +0,0 @@
import { useEffect, useRef, useState, useCallback } from "react";
import {
X, BookmarkSimple, ArrowSquareOut, Play,
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { useStore } from "../../store";
import type { Manga, Chapter } from "../../lib/types";
import s from "./MangaPreview.module.css";
export default function MangaPreview() {
const previewManga = useStore((st) => st.previewManga);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const setActiveManga = useStore((st) => st.setActiveManga);
const setNavPage = useStore((st) => st.setNavPage);
const setGenreFilter = useStore((st) => st.setGenreFilter);
const openReader = useStore((st) => st.openReader);
const addToast = useStore((st) => st.addToast);
const folders = useStore((st) => st.settings.folders);
const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
const [manga, setManga] = useState<Manga | null>(null);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [loadingDetail, setLoadingDetail] = useState(false);
const [loadingChapters, setLoadingChapters] = useState(false);
const [togglingLib, setTogglingLib] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
const [folderOpen, setFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [queueingAll, setQueueingAll] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const detailAbort = useRef<AbortController | null>(null);
const chapterAbort = useRef<AbortController | null>(null);
const folderRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => {
detailAbort.current?.abort();
chapterAbort.current?.abort();
setPreviewManga(null);
setManga(null);
setChapters([]);
setDescExpanded(false);
setFolderOpen(false);
setCreatingFolder(false);
setNewFolderName("");
setFetchError(null);
}, [setPreviewManga]);
// ── Fetch detail + chapters on open ──────────────────────────────────────
useEffect(() => {
if (!previewManga) return;
// Abort any in-flight requests from previous manga
detailAbort.current?.abort();
chapterAbort.current?.abort();
const dCtrl = new AbortController();
const cCtrl = new AbortController();
detailAbort.current = dCtrl;
chapterAbort.current = cCtrl;
setManga(null);
setChapters([]);
setDescExpanded(false);
setFetchError(null);
setLoadingDetail(true);
setLoadingChapters(true);
const id = previewManga.id;
// ── Detail fetch strategy ─────────────────────────────────────────────
// For source/explore manga we must call FETCH_MANGA (mutation that
// hits the source and syncs to the local DB). GET_MANGA only works for
// manga already in the local DB with full metadata.
//
// Fast path: if we already cached a full record, use it directly.
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
// and returns the richest data. Fall back to GET_MANGA if it errors.
//
(async (): Promise<Manga> => {
const cacheKey = CACHE_KEYS.MANGA(id);
// Already have a cached rich record — no network needed
if (cache.has(cacheKey)) {
return cache.get(cacheKey, () =>
Promise.resolve(previewManga as Manga)
) as Promise<Manga>;
}
// Try FETCH_MANGA first — works for all manga regardless of whether
// they are in the local DB yet (it fetches from source and syncs).
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;
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
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;
// Cache the rich record so re-opening is instant
if (!cache.has(CACHE_KEYS.MANGA(id))) {
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
}
setManga(fullManga);
setLoadingDetail(false);
})
.catch((e) => {
if (e?.name === "AbortError") return;
console.error("MangaPreview detail fetch:", e);
// Show whatever sparse data we have from previewManga
setManga(previewManga as Manga);
setFetchError("Could not load full details — showing cached data");
setLoadingDetail(false);
});
// ── Chapter fetch — local DB first, fall back to source fetch ────────
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 no local chapters yet (explore/source manga), fetch from source
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;
// Leave nodes empty — not a fatal error
}
}
if (!cCtrl.signal.aborted) setChapters(nodes);
})
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
return () => { dCtrl.abort(); cCtrl.abort(); };
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keyboard close ────────────────────────────────────────────────────────
useEffect(() => {
if (!previewManga) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [previewManga, close]);
// ── Folder outside click ──────────────────────────────────────────────────
useEffect(() => {
if (!folderOpen) return;
const handler = (e: MouseEvent) => {
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [folderOpen]);
if (!previewManga) return null;
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
const displayManga = manga ?? previewManga;
const totalCount = chapters.length;
const readCount = chapters.filter((c) => c.isRead).length;
const unreadCount = totalCount - readCount;
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
// Scanlators — deduplicated, non-empty
const scanlators = [...new Set(
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
)];
// Publication date range from chapter upload dates
const uploadDates = chapters
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
.filter((d): d is number => d !== null && !isNaN(d));
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
function formatDate(d: Date) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
const statusLabel = displayManga.status
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
: null;
const continueChapter = (() => {
if (!chapters.length) return null;
const asc = [...chapters];
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
return { ch: asc[0], label: "Read again" };
})();
async function toggleLibrary() {
if (!manga) return;
setTogglingLib(true);
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
const updated = { ...manga, inLibrary: next };
setManga(updated);
// Update cache so subsequent opens reflect new state
cache.clear(CACHE_KEYS.MANGA(manga.id));
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
cache.clear(CACHE_KEYS.LIBRARY);
setTogglingLib(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;
setQueueingAll(true);
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
setQueueingAll(false);
}
function openSeriesDetail() {
setActiveManga(displayManga);
setNavPage("library");
close();
}
function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !previewManga) return;
const newId = addFolder(name);
assignMangaToFolder(newId, previewManga.id);
setNewFolderName("");
setCreatingFolder(false);
}
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
return (
<div
className={s.backdrop}
ref={backdropRef}
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
>
<div className={s.modal} role="dialog" aria-label="Manga preview">
{/* ── Cover column ── */}
<div className={s.coverCol}>
<div className={s.coverWrap}>
<img
src={thumbUrl(previewManga.thumbnailUrl)}
alt={displayManga.title}
className={s.cover}
/>
{loadingDetail && (
<div className={s.coverSpinner}>
<CircleNotch size={18} weight="light" className="anim-spin" />
</div>
)}
</div>
<div className={s.coverActions}>
<button
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
onClick={toggleLibrary}
disabled={togglingLib || loadingDetail}
>
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
</button>
<button className={s.actionBtn} onClick={openSeriesDetail}>
<Books size={13} weight="light" />
Series Detail
</button>
{/* Folder picker */}
<div className={s.folderWrap} ref={folderRef}>
<button
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
onClick={() => setFolderOpen((p) => !p)}
>
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
<span className={s.actionBtnLabel}>
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
</span>
</button>
{folderOpen && (
<div className={s.folderMenu}>
{folders.length === 0 && !creatingFolder && (
<p className={s.folderEmpty}>No folders yet</p>
)}
{folders.map((f) => {
const isIn = f.mangaIds.includes(previewManga.id);
return (
<button key={f.id}
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
onClick={() => isIn
? removeMangaFromFolder(f.id, previewManga.id)
: assignMangaToFolder(f.id, previewManga.id)}
>
<Folder size={12} weight={isIn ? "fill" : "light"} />
{isIn ? "✓ " : ""}{f.name}
</button>
);
})}
<div className={s.folderDivider} />
{creatingFolder ? (
<div className={s.folderCreateRow}>
<input autoFocus className={s.folderInput} placeholder="Folder name…"
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleFolderCreate();
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
}}
/>
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
</div>
) : (
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
)}
</div>
)}
</div>
</div>
</div>
{/* ── Content column ── */}
<div className={s.content}>
{/* Header — title visible immediately from previewManga */}
<div className={s.contentHeader}>
<div className={s.titleBlock}>
<h2 className={s.title}>{displayManga.title}</h2>
{loadingDetail
? <div className={s.skByline} />
: (displayManga.author || displayManga.artist)
? <p className={s.byline}>
{[displayManga.author, displayManga.artist]
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
</p>
: null}
</div>
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
</div>
{/* Scrollable body */}
<div className={s.contentBody}>
{/* Error banner */}
{fetchError && (
<div className={s.errorBanner}>{fetchError}</div>
)}
{/* ── Badges ── */}
{loadingDetail ? (
<div className={s.skRow}>
<div className={s.skBadge} />
<div className={s.skBadge} style={{ width: 72 }} />
</div>
) : (
<div className={s.badges}>
{statusLabel && (
<span className={[s.badge,
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
].join(" ")}>{statusLabel}</span>
)}
{displayManga.source && (
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
</span>
)}
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
{!loadingChapters && unreadCount > 0 && (
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
)}
{!loadingChapters && bookmarkCount > 0 && (
<span className={s.badge}>{bookmarkCount} bookmarked</span>
)}
</div>
)}
{/* ── Chapter section — visually separated box ── */}
<div className={s.chapterBox}>
{loadingChapters ? (
<div className={s.chapterLoading}>
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
<span className={s.chapterLoadingLabel}>Loading chapters</span>
</div>
) : totalCount > 0 ? (
<>
<div className={s.chapterMeta}>
<span className={s.chapterLabel}>
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
{readCount > 0 && ` · ${readCount} read`}
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
</span>
{unreadCount > 0 && (
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
)}
</div>
{readCount > 0 && (
<div className={s.progressTrack}>
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
</div>
)}
{continueChapter && (
<button className={s.readBtn}
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
>
<Play size={12} weight="fill" />
{continueChapter.label}
</button>
)}
</>
) : !loadingDetail ? (
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
No chapters in local library
</span>
) : null}
</div>
{/* ── Description — clearly separated from chapter block ── */}
{loadingDetail ? (
<div className={s.skDesc}>
<div className={s.skLine} style={{ width: "100%" }} />
<div className={s.skLine} style={{ width: "88%" }} />
<div className={s.skLine} style={{ width: "70%" }} />
</div>
) : displayManga.description ? (
<div className={s.descBlock}>
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
{displayManga.description}
</p>
{displayManga.description.length > 220 && (
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown size={10} weight="light" style={{
transform: descExpanded ? "rotate(180deg)" : "none",
transition: "transform 0.15s ease",
}} />
</button>
)}
</div>
) : null}
{/* ── Genre tags ── */}
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
<div className={s.genres}>
{displayManga.genre.map((g) => (
<button
key={g}
className={[s.genreTag, s.genreTagClickable].join(" ")}
title={`Browse "${g}"`}
onClick={() => {
setGenreFilter(g);
setNavPage("explore");
close();
}}
>
{g}
</button>
))}
</div>
)}
{/* ── Metadata table ── */}
{!loadingDetail && (
<div className={s.metaTable}>
{displayManga.author && (
<div className={s.metaRow}>
<span className={s.metaKey}>Author</span>
<span className={s.metaVal}>{displayManga.author}</span>
</div>
)}
{displayManga.artist && displayManga.artist !== displayManga.author && (
<div className={s.metaRow}>
<span className={s.metaKey}>Artist</span>
<span className={s.metaVal}>{displayManga.artist}</span>
</div>
)}
{statusLabel && (
<div className={s.metaRow}>
<span className={s.metaKey}>Status</span>
<span className={s.metaVal}>{statusLabel}</span>
</div>
)}
{displayManga.source && (
<div className={s.metaRow}>
<span className={s.metaKey}>Source</span>
<span className={s.metaVal}>{displayManga.source.displayName}</span>
</div>
)}
{!loadingChapters && scanlators.length > 0 && (
<div className={s.metaRow}>
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
<span className={s.metaVal}>{scanlators.join(", ")}</span>
</div>
)}
{!loadingChapters && firstUpload && lastUpload && (
<div className={s.metaRow}>
<span className={s.metaKey}>Published</span>
<span className={s.metaVal}>
{firstUpload.getTime() === lastUpload.getTime()
? formatDate(firstUpload)
: `${formatDate(firstUpload)} ${formatDate(lastUpload)}`}
</span>
</div>
)}
{!loadingChapters && downloadedCount > 0 && (
<div className={s.metaRow}>
<span className={s.metaKey}>Downloaded</span>
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
</div>
)}
{!loadingChapters && bookmarkCount > 0 && (
<div className={s.metaRow}>
<span className={s.metaKey}>Bookmarks</span>
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
</div>
)}
{displayManga.realUrl && (
<div className={s.metaRow}>
<span className={s.metaKey}>Link</span>
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
Open <ArrowSquareOut size={11} weight="light" />
</a>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -1,278 +0,0 @@
.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-5) var(--sp-6) var(--sp-3); 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;
}
.headerActions { display: flex; gap: var(--sp-1); }
.iconBtn {
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);
}
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.iconBtn:disabled { opacity: 0.4; }
.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); }
.externalPanel {
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;
}
.externalHeader {
display: flex; align-items: center; justify-content: space-between;
}
.externalTitle {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
}
.externalRow {
display: flex; gap: var(--sp-2);
}
.externalInput {
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);
}
.externalInput:focus { border-color: var(--border-focus); }
.externalInput:disabled { opacity: 0.5; }
.externalInputError { border-color: var(--color-error) !important; }
.externalError {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--color-error); letter-spacing: var(--tracking-wide);
padding: 0 2px;
}
.installBtn {
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;
}
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
.installBtn:disabled { opacity: 0.5; cursor: default; }
.installBtnSuccess {
background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f);
color: #fff;
}
.controls {
display: flex; align-items: center; justify-content: space-between;
padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0;
}
.tabs { display: flex; gap: 2px; }
.tab {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 10px; border-radius: var(--radius-md); border: none;
background: none; color: var(--text-muted); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.searchWrap { position: relative; display: flex; align-items: center; }
.searchIcon { 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);
}
.langTag {
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);
}
.nsfwTag {
background: transparent; border: 1px solid var(--color-error);
border-radius: var(--radius-sm); padding: 1px 5px;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--color-error); letter-spacing: var(--tracking-wider);
}
.updateBadge {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
padding: 2px 6px; flex-shrink: 0;
}
.updateBadgeSmall {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--accent-fg); flex-shrink: 0;
}
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.actionBtn {
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);
}
.actionBtn:hover { filter: brightness(1.1); }
.actionBtnDim {
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);
}
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
.expandBtn {
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);
}
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.expandCount {
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;
}
.variantRow {
display: flex; align-items: center; gap: var(--sp-2);
padding: 5px var(--sp-2); border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.variantRow:hover { background: var(--bg-raised); }
.variantName {
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.variantVersion {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.variantActions { 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);
}
/* ── Panel shared styles ── */
.externalPanel {
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;
}
.panelHeader {
display: flex; align-items: center; justify-content: space-between;
}
.panelTitle {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
}
.panelError {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--color-error); letter-spacing: var(--tracking-wide);
padding: 0 2px;
}
.externalRow { display: flex; gap: var(--sp-2); }
.externalInput {
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);
}
.externalInput:focus { border-color: var(--border-focus); }
.externalInput:disabled { opacity: 0.5; }
.externalInputError { border-color: var(--color-error) !important; }
.installBtn {
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;
}
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
.installBtn:disabled { opacity: 0.5; cursor: default; }
.installBtnSuccess {
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
border-color: var(--accent-fg); color: var(--accent-fg);
}
/* ── Repo list ── */
.repoLoading {
display: flex; align-items: center; justify-content: center;
padding: var(--sp-3);
}
.repoEmpty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
padding: var(--sp-1) 2px;
}
.repoList {
display: flex; flex-direction: column; gap: 2px;
}
.repoRow {
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);
}
.repoUrl {
flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs);
color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
letter-spacing: 0;
}
.repoRemoveBtn {
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);
}
.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
.repoRemoveBtn:disabled { opacity: 0.4; }
-407
View File
@@ -1,407 +0,0 @@
import { useEffect, useState, useMemo } from "react";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react";
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 { useStore } from "../../store";
import type { Extension } from "../../lib/types";
import s from "./ExtensionList.module.css";
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();
}
interface ExtGroup {
base: string;
primary: Extension;
variants: Extension[];
}
export default function ExtensionList() {
const [extensions, setExtensions] = useState<Extension[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<Filter>("installed");
const [search, setSearch] = useState("");
const [working, setWorking] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [panel, setPanel] = useState<Panel>(null);
// APK install state
const [externalUrl, setExternalUrl] = useState("");
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [installSuccess, setInstallSuccess] = useState(false);
// Repo management state
const [repos, setRepos] = useState<string[]>([]);
const [reposLoading, setReposLoading] = useState(false);
const [newRepoUrl, setNewRepoUrl] = useState("");
const [repoError, setRepoError] = useState<string | null>(null);
const [savingRepos, setSavingRepos] = useState(false);
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
async function load() {
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
.then((d) => setExtensions(d.extensions.nodes))
.catch(console.error);
}
async function fetchFromRepo() {
setRefreshing(true);
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
.then((d) => setExtensions(d.fetchExtensions.extensions))
.catch(console.error)
.finally(() => setRefreshing(false));
}
async function loadRepos() {
setReposLoading(true);
try {
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
setRepos(d.settings.extensionRepos ?? []);
} catch (e) {
console.error(e);
} finally {
setReposLoading(false);
}
}
async function saveRepos(updated: string[]) {
setSavingRepos(true);
try {
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
SET_EXTENSION_REPOS, { repos: updated }
);
setRepos(d.setSettings.settings.extensionRepos);
} catch (e: unknown) {
setRepoError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSavingRepos(false);
}
}
function addRepo() {
const url = newRepoUrl.trim();
if (!url) return;
if (!url.startsWith("http://") && !url.startsWith("https://")) {
setRepoError("URL must start with http:// or https://");
return;
}
if (repos.includes(url)) {
setRepoError("Repo already added");
return;
}
setRepoError(null);
setNewRepoUrl("");
saveRepos([...repos, url]);
}
function removeRepo(url: string) {
saveRepos(repos.filter((r) => r !== url));
}
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
setWorking((p) => new Set(p).add(pkgName));
await fn().catch(console.error);
await load();
setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; });
};
async function installExternal() {
const url = externalUrl.trim();
if (!url) return;
if (!url.startsWith("http://") && !url.startsWith("https://")) {
setInstallError("URL must start with http:// or https://");
return;
}
if (!url.endsWith(".apk")) {
setInstallError("URL must point to an .apk file");
return;
}
setInstalling(true);
setInstallError(null);
setInstallSuccess(false);
try {
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
setInstallSuccess(true);
setExternalUrl("");
await load();
setTimeout(() => {
setPanel(null);
setInstallSuccess(false);
}, 1500);
} catch (e: unknown) {
setInstallError(e instanceof Error ? e.message : "Install failed");
} finally {
setInstalling(false);
}
}
function openPanel(p: Panel) {
if (panel === p) {
setPanel(null);
return;
}
setPanel(p);
setInstallError(null);
setInstallSuccess(false);
setExternalUrl("");
setRepoError(null);
setNewRepoUrl("");
if (p === "repos") loadRepos();
}
useEffect(() => {
fetchFromRepo().finally(() => setLoading(false));
}, []);
const filtered = 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 = useMemo<ExtGroup[]>(() => {
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);
}
return Array.from(map.entries()).map(([base, all]) => {
const primary =
all.find((v) => v.lang === preferredLang) ??
all.find((v) => v.lang === "en") ??
all[0];
const variants = all.filter((v) => v.pkgName !== primary.pkgName);
return { base, primary, variants };
});
}, [filtered, preferredLang]);
const updateCount = extensions.filter((e) => e.hasUpdate).length;
const FILTERS: { id: Filter; label: string }[] = [
{ id: "installed", label: "Installed" },
{ id: "available", label: "Available" },
{ id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" },
{ id: "all", label: "All" },
];
function toggleExpand(base: string) {
setExpanded((p) => {
const n = new Set(p);
n.has(base) ? n.delete(base) : n.add(base);
return n;
});
}
function renderActions(ext: Extension) {
if (working.has(ext.pkgName))
return <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
if (ext.hasUpdate) return (
<div className={s.rowActions}>
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
</div>
);
if (ext.isInstalled)
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
}
return (
<div className={s.root}>
<div className={s.header}>
<h1 className={s.heading}>Extensions</h1>
<div className={s.headerActions}>
<button
className={[s.iconBtn, panel === "repos" ? s.iconBtnActive : ""].join(" ").trim()}
onClick={() => openPanel("repos")} title="Manage repos">
<GitBranch size={14} weight="light" />
</button>
<button
className={[s.iconBtn, panel === "apk" ? s.iconBtnActive : ""].join(" ").trim()}
onClick={() => openPanel("apk")} title="Install from URL">
<Plus size={14} weight="light" />
</button>
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
</button>
</div>
</div>
{/* ── APK install panel ── */}
{panel === "apk" && (
<div className={s.externalPanel}>
<div className={s.panelHeader}>
<span className={s.panelTitle}>Install from APK URL</span>
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
</div>
<div className={s.externalRow}>
<input
className={[s.externalInput, installError ? s.externalInputError : ""].join(" ").trim()}
placeholder="https://example.com/extension.apk"
value={externalUrl}
onChange={(e) => { setExternalUrl(e.target.value); setInstallError(null); }}
onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()}
autoFocus
disabled={installing}
/>
<button
className={[s.installBtn, installSuccess ? s.installBtnSuccess : ""].join(" ").trim()}
onClick={installExternal}
disabled={installing || !externalUrl.trim()}
>
{installing
? <CircleNotch size={13} weight="light" className="anim-spin" />
: installSuccess
? <><Check size={13} weight="bold" /> Done</>
: "Install"}
</button>
</div>
{installError && <div className={s.panelError}>{installError}</div>}
</div>
)}
{/* ── Repo management panel ── */}
{panel === "repos" && (
<div className={s.externalPanel}>
<div className={s.panelHeader}>
<span className={s.panelTitle}>Extension Repositories</span>
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
</div>
{reposLoading ? (
<div className={s.repoLoading}>
<CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
) : (
<>
{repos.length === 0 ? (
<div className={s.repoEmpty}>No repos configured.</div>
) : (
<div className={s.repoList}>
{repos.map((url) => (
<div key={url} className={s.repoRow}>
<span className={s.repoUrl}>{url}</span>
<button
className={s.repoRemoveBtn}
onClick={() => removeRepo(url)}
disabled={savingRepos}
title="Remove repo"
>
{savingRepos
? <CircleNotch size={12} weight="light" className="anim-spin" />
: <X size={12} weight="bold" />}
</button>
</div>
))}
</div>
)}
<div className={s.externalRow} style={{ marginTop: "var(--sp-2)" }}>
<input
className={[s.externalInput, repoError ? s.externalInputError : ""].join(" ").trim()}
placeholder="https://example.com/index.min.json"
value={newRepoUrl}
onChange={(e) => { setNewRepoUrl(e.target.value); setRepoError(null); }}
onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
disabled={savingRepos}
/>
<button
className={s.installBtn}
onClick={addRepo}
disabled={savingRepos || !newRepoUrl.trim()}
>
{savingRepos
? <CircleNotch size={13} weight="light" className="anim-spin" />
: "Add"}
</button>
</div>
{repoError && <div className={s.panelError}>{repoError}</div>}
</>
)}
</div>
)}
<div className={s.controls}>
<div className={s.tabs}>
{FILTERS.map((f) => (
<button key={f.id} onClick={() => setFilter(f.id)}
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
{f.label}
</button>
))}
</div>
<div className={s.searchWrap}>
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
<input className={s.search} placeholder="Search"
value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
{loading ? (
<div className={s.empty}>
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
) : groups.length === 0 ? (
<div className={s.empty}>No extensions found.</div>
) : (
<div className={s.list}>
{groups.map(({ base, primary, variants }) => {
const isExpanded = expanded.has(base);
const hasVariants = variants.length > 0;
return (
<div key={base} className={s.group}>
<div className={s.row}>
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div className={s.info}>
<span className={s.name}>{base}</span>
<span className={s.meta}>
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
{" "}v{primary.versionName}
</span>
</div>
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
{renderActions(primary)}
{hasVariants && (
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
title={`${variants.length + 1} languages`}>
{isExpanded ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
<span className={s.expandCount}>{variants.length + 1}</span>
</button>
)}
</div>
{isExpanded && hasVariants && (
<div className={s.variants}>
{variants.map((v) => (
<div key={v.pkgName} className={s.variantRow}>
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
<span className={s.variantName}>{v.name}</span>
<span className={s.variantVersion}>v{v.versionName}</span>
{v.hasUpdate && <span className={s.updateBadgeSmall}></span>}
<div className={s.variantActions}>{renderActions(v)}</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
.root {
display: flex;
height: 100%;
background: var(--bg-base);
overflow: hidden;
}
.main {
flex: 1;
overflow: hidden;
background: var(--bg-surface);
/* GPU layer for main content area */
transform: translateZ(0);
contain: layout style;
}
-36
View File
@@ -1,36 +0,0 @@
import { useStore } from "../../store";
import Sidebar from "./Sidebar";
import Library from "../pages/Library";
import SeriesDetail from "../pages/SeriesDetail";
import History from "../pages/History";
import Search from "../pages/Search";
import Explore from "../explore/Explore";
import DownloadQueue from "../downloads/DownloadQueue";
import ExtensionList from "../extensions/ExtensionList";
import s from "./Layout.module.css";
export default function Layout() {
const navPage = useStore((s) => s.navPage);
const activeManga = useStore((s) => s.activeManga);
function renderContent() {
if (activeManga) return <SeriesDetail />;
switch (navPage) {
case "library": return <Library />;
case "search": return <Search />;
case "history": return <History />;
case "sources": return <Explore />;
case "explore": return <Explore />;
case "downloads": return <DownloadQueue />;
case "extensions": return <ExtensionList />;
default: return <Library />;
}
}
return (
<div className={s.root}>
<Sidebar />
<main className={s.main}>{renderContent()}</main>
</div>
);
}

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