Compare commits

..

13 Commits

Author SHA1 Message Date
Zerebos d74790c3a0 Make it build 2026-05-23 22:32:17 -04:00
Zerebos 0e93908bb2 Phase d cleanup 2026-05-23 22:16:40 -04:00
Zerebos 074147f64f Cleanup core utilities and abstractions 2026-05-23 21:47:54 -04:00
Zerebos f91b46cfa5 Reader core parity 2026-05-23 21:33:02 -04:00
Zerebos 71ee4052f3 Cleanup routes and ux 2026-05-23 21:19:07 -04:00
Zerebos 5e2114810e Polish the migration 2026-05-23 21:09:08 -04:00
Zerebos b3fca70f27 Migrate remaining routes 2026-05-23 17:15:02 -04:00
Zerebos 68f25a2ea7 Migrate remaining feature routes 2026-05-23 16:37:09 -04:00
Zerebos 3d6b6430ed Reader route migration 2026-05-23 16:21:09 -04:00
Zerebos 54307d4411 Implement series route 2026-05-23 16:12:15 -04:00
Zerebos f8f080eff3 Finish phase 3 2026-05-23 02:48:31 -04:00
Zerebos f41f8a9c22 Finish phase 2 2026-05-23 02:30:27 -04:00
Zerebos 8cef79b2b4 Implement phase 1 2026-05-23 02:18:36 -04:00
193 changed files with 11102 additions and 12529 deletions
+1
View File
@@ -6,6 +6,7 @@ dist-tauri/
target/ target/
bin/ bin/
out/ out/
notes/
.direnv/ .direnv/
result result
+5 -6
View File
@@ -13,7 +13,7 @@
"build:node": "MOKU_TARGET=node vite build", "build:node": "MOKU_TARGET=node vite build",
"build:tauri": "MOKU_TARGET=static vite build", "build:tauri": "MOKU_TARGET=static vite build",
"build:android": "MOKU_TARGET=static vite build", "build:android": "MOKU_TARGET=static vite build",
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "tauri:dev": "tauri dev",
"tauri:build": "tauri build" "tauri:build": "tauri build"
}, },
"devDependencies": { "devDependencies": {
@@ -21,24 +21,23 @@
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2.1.0", "@tauri-apps/cli": "^2.0.0",
"@types/node": "^25.9.1",
"svelte": "^5.55.2", "svelte": "^5.55.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.7" "vite": "^8.0.7"
}, },
"dependencies": { "dependencies": {
"@capacitor/app": "^8.1.0",
"@capacitor/browser": "^8.0.3", "@capacitor/browser": "^8.0.3",
"@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2", "@capacitor/filesystem": "^8.1.2",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-store": "^2.4.3",
"capacitor-native-biometric": "^4.2.2", "capacitor-native-biometric": "^4.2.2",
"phosphor-svelte": "^3.1.0" "phosphor-svelte": "^3.1.0"
} }
+117 -108
View File
@@ -8,15 +8,15 @@ importers:
.: .:
dependencies: dependencies:
'@capacitor/app':
specifier: ^8.1.0
version: 8.1.0(@capacitor/core@3.9.0)
'@capacitor/browser': '@capacitor/browser':
specifier: ^8.0.3 specifier: ^8.0.3
version: 8.0.3(@capacitor/core@8.3.4) version: 8.0.3(@capacitor/core@3.9.0)
'@capacitor/core':
specifier: ^8.3.4
version: 8.3.4
'@capacitor/filesystem': '@capacitor/filesystem':
specifier: ^8.1.2 specifier: ^8.1.2
version: 8.1.2(@capacitor/core@8.3.4) version: 8.1.2(@capacitor/core@3.9.0)
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.11.0 version: 2.11.0
@@ -26,43 +26,40 @@ importers:
'@tauri-apps/plugin-fs': '@tauri-apps/plugin-fs':
specifier: ^2.5.1 specifier: ^2.5.1
version: 2.5.1 version: 2.5.1
'@tauri-apps/plugin-http':
specifier: ^2.5.9
version: 2.5.9
'@tauri-apps/plugin-os': '@tauri-apps/plugin-os':
specifier: ^2.3.2 specifier: ^2.3.2
version: 2.3.2 version: 2.3.2
'@tauri-apps/plugin-process': '@tauri-apps/plugin-process':
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1 version: 2.3.1
'@tauri-apps/plugin-shell': '@tauri-apps/plugin-updater':
specifier: ^2.3.5 specifier: ^2.10.1
version: 2.3.5 version: 2.10.1
'@tauri-apps/plugin-store':
specifier: ^2.4.3
version: 2.4.3
capacitor-native-biometric: capacitor-native-biometric:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
phosphor-svelte: phosphor-svelte:
specifier: ^3.1.0 specifier: ^3.1.0
version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10) version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1))
devDependencies: devDependencies:
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.5.4 specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10)) version: 5.5.4(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1)))
'@sveltejs/adapter-static': '@sveltejs/adapter-static':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10)) version: 3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1)))
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.57.0 specifier: ^2.57.0
version: 2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10) version: 2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1))
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10) version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1))
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2.1.0 specifier: ^2.0.0
version: 2.1.0 version: 2.11.2
'@types/node':
specifier: ^25.9.1
version: 25.9.1
svelte: svelte:
specifier: ^5.55.2 specifier: ^5.55.2
version: 5.55.5(@typescript-eslint/types@8.57.1) version: 5.55.5(@typescript-eslint/types@8.57.1)
@@ -74,10 +71,15 @@ importers:
version: 6.0.3 version: 6.0.3
vite: vite:
specifier: ^8.0.7 specifier: ^8.0.7
version: 8.0.10 version: 8.0.10(@types/node@25.9.1)
packages: packages:
'@capacitor/app@8.1.0':
resolution: {integrity: sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/browser@8.0.3': '@capacitor/browser@8.0.3':
resolution: {integrity: sha512-WJWPHEPbweiFoHYmVlCbZf5yrqJ2Rchx2Xvbmd+3Lf+Zkpq3nXBThThY2CF69lYEg1NINGF9BcHThIOEU1gZlQ==} resolution: {integrity: sha512-WJWPHEPbweiFoHYmVlCbZf5yrqJ2Rchx2Xvbmd+3Lf+Zkpq3nXBThThY2CF69lYEg1NINGF9BcHThIOEU1gZlQ==}
peerDependencies: peerDependencies:
@@ -86,9 +88,6 @@ packages:
'@capacitor/core@3.9.0': '@capacitor/core@3.9.0':
resolution: {integrity: sha512-j1lL0+/7stY8YhIq1Lm6xixvUqIn89vtyH5ZpJNNmcZ0kwz6K9eLkcG6fvq1UWMDgSVZg9JrRGSFhb4LLoYOsw==} resolution: {integrity: sha512-j1lL0+/7stY8YhIq1Lm6xixvUqIn89vtyH5ZpJNNmcZ0kwz6K9eLkcG6fvq1UWMDgSVZg9JrRGSFhb4LLoYOsw==}
'@capacitor/core@8.3.4':
resolution: {integrity: sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==}
'@capacitor/filesystem@8.1.2': '@capacitor/filesystem@8.1.2':
resolution: {integrity: sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==} resolution: {integrity: sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==}
peerDependencies: peerDependencies:
@@ -450,72 +449,79 @@ packages:
'@tauri-apps/api@2.11.0': '@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
'@tauri-apps/cli-darwin-arm64@2.1.0': '@tauri-apps/cli-darwin-arm64@2.11.2':
resolution: {integrity: sha512-ESc6J6CE8hl1yKH2vJ+ALF+thq4Be+DM1mvmTyUCQObvezNCNhzfS6abIUd3ou4x5RGH51ouiANeT3wekU6dCw==} resolution: {integrity: sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@tauri-apps/cli-darwin-x64@2.1.0': '@tauri-apps/cli-darwin-x64@2.11.2':
resolution: {integrity: sha512-TasHS442DFs8cSH2eUQzuDBXUST4ECjCd0yyP+zZzvAruiB0Bg+c8A+I/EnqCvBQ2G2yvWLYG8q/LI7c87A5UA==} resolution: {integrity: sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.1.0': '@tauri-apps/cli-linux-arm-gnueabihf@2.11.2':
resolution: {integrity: sha512-aP7ZBGNL4ny07Cbb6kKpUOSrmhcIK2KhjviTzYlh+pPhAptxnC78xQGD3zKQkTi2WliJLPmBYbOHWWQa57lQ9w==} resolution: {integrity: sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.1.0': '@tauri-apps/cli-linux-arm64-gnu@2.11.2':
resolution: {integrity: sha512-ZTdgD5gLeMCzndMT2f358EkoYkZ5T+Qy6zPzU+l5vv5M7dHVN9ZmblNAYYXmoOuw7y+BY4X/rZvHV9pcGrcanQ==} resolution: {integrity: sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.1.0': '@tauri-apps/cli-linux-arm64-musl@2.11.2':
resolution: {integrity: sha512-NzwqjUCilhnhJzusz3d/0i0F1GFrwCQbkwR6yAHUxItESbsGYkZRJk0yMEWkg3PzFnyK4cWTlQJMEU52TjhEzA==} resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@tauri-apps/cli-linux-x64-gnu@2.1.0': '@tauri-apps/cli-linux-riscv64-gnu@2.11.2':
resolution: {integrity: sha512-TyiIpMEtZxNOQmuFyfJwaaYbg3movSthpBJLIdPlKxSAB2BW0VWLY3/ZfIxm/G2YGHyREkjJvimzYE0i37PnMA==} resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.1.0': '@tauri-apps/cli-linux-x64-musl@2.11.2':
resolution: {integrity: sha512-/dQd0TlaxBdJACrR72DhynWftzHDaX32eBtS5WBrNJ+nnNb+znM3gON6nJ9tSE9jgDa6n1v2BkI/oIDtypfUXw==} resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.1.0': '@tauri-apps/cli-win32-arm64-msvc@2.11.2':
resolution: {integrity: sha512-NdQJO7SmdYqOcE+JPU7bwg7+odfZMWO6g8xF9SXYCMdUzvM2Gv/AQfikNXz5yS7ralRhNFuW32i5dcHlxh4pDg==} resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.1.0': '@tauri-apps/cli-win32-ia32-msvc@2.11.2':
resolution: {integrity: sha512-f5h8gKT/cB8s1ticFRUpNmHqkmaLutT62oFDB7N//2YTXnxst7EpMIn1w+QimxTvTk2gcx6EcW6bEk/y2hZGzg==} resolution: {integrity: sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.1.0': '@tauri-apps/cli-win32-x64-msvc@2.11.2':
resolution: {integrity: sha512-P/+LrdSSb5Xbho1LRP4haBjFHdyPdjWvGgeopL96OVtrFpYnfC+RctB45z2V2XxqFk3HweDDxk266btjttfjGw==} resolution: {integrity: sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tauri-apps/cli@2.1.0': '@tauri-apps/cli@2.11.2':
resolution: {integrity: sha512-K2VhcKqBhAeS5pNOVdnR/xQRU6jwpgmkSL2ejHXcl0m+kaTggT0WRDQnFtPq6NljA7aE03cvwsbCAoFG7vtkJw==} resolution: {integrity: sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
@@ -525,20 +531,14 @@ packages:
'@tauri-apps/plugin-fs@2.5.1': '@tauri-apps/plugin-fs@2.5.1':
resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==}
'@tauri-apps/plugin-http@2.5.9':
resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==}
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
'@tauri-apps/plugin-process@2.3.1': '@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-shell@2.3.5': '@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@tauri-apps/plugin-store@2.4.3':
resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -549,6 +549,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -848,6 +851,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
vite@8.0.10: vite@8.0.10:
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -904,21 +910,21 @@ packages:
snapshots: snapshots:
'@capacitor/browser@8.0.3(@capacitor/core@8.3.4)': '@capacitor/app@8.1.0(@capacitor/core@3.9.0)':
dependencies: dependencies:
'@capacitor/core': 8.3.4 '@capacitor/core': 3.9.0
'@capacitor/browser@8.0.3(@capacitor/core@3.9.0)':
dependencies:
'@capacitor/core': 3.9.0
'@capacitor/core@3.9.0': '@capacitor/core@3.9.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@capacitor/core@8.3.4': '@capacitor/filesystem@8.1.2(@capacitor/core@3.9.0)':
dependencies: dependencies:
tslib: 2.8.1 '@capacitor/core': 3.9.0
'@capacitor/filesystem@8.1.2(@capacitor/core@8.3.4)':
dependencies:
'@capacitor/core': 8.3.4
'@capacitor/synapse': 1.0.4 '@capacitor/synapse': 1.0.4
'@capacitor/synapse@1.0.4': {} '@capacitor/synapse@1.0.4': {}
@@ -1137,23 +1143,23 @@ snapshots:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10))': '@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1)))':
dependencies: dependencies:
'@rollup/plugin-commonjs': 29.0.2(rollup@4.60.4) '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.4)
'@rollup/plugin-json': 6.1.0(rollup@4.60.4) '@rollup/plugin-json': 6.1.0(rollup@4.60.4)
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.4) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.4)
'@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10) '@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1))
rollup: 4.60.4 rollup: 4.60.4
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10))': '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1)))':
dependencies: dependencies:
'@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10) '@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1))
'@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10)': '@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)))(svelte@5.55.5(@typescript-eslint/types@8.57.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.9.1))':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10) '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1))
'@types/cookie': 0.6.0 '@types/cookie': 0.6.0
acorn: 8.16.0 acorn: 8.16.0
cookie: 0.6.0 cookie: 0.6.0
@@ -1165,63 +1171,67 @@ snapshots:
set-cookie-parser: 3.1.0 set-cookie-parser: 3.1.0
sirv: 3.0.2 sirv: 3.0.2
svelte: 5.55.5(@typescript-eslint/types@8.57.1) svelte: 5.55.5(@typescript-eslint/types@8.57.1)
vite: 8.0.10 vite: 8.0.10(@types/node@25.9.1)
optionalDependencies: optionalDependencies:
typescript: 6.0.3 typescript: 6.0.3
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10)': '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1))':
dependencies: dependencies:
deepmerge: 4.3.1 deepmerge: 4.3.1
magic-string: 0.30.21 magic-string: 0.30.21
obug: 2.1.1 obug: 2.1.1
svelte: 5.55.5(@typescript-eslint/types@8.57.1) svelte: 5.55.5(@typescript-eslint/types@8.57.1)
vite: 8.0.10 vite: 8.0.10(@types/node@25.9.1)
vitefu: 1.1.3(vite@8.0.10) vitefu: 1.1.3(vite@8.0.10(@types/node@25.9.1))
'@tauri-apps/api@2.11.0': {} '@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.1.0': '@tauri-apps/cli-darwin-arm64@2.11.2':
optional: true optional: true
'@tauri-apps/cli-darwin-x64@2.1.0': '@tauri-apps/cli-darwin-x64@2.11.2':
optional: true optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.1.0': '@tauri-apps/cli-linux-arm-gnueabihf@2.11.2':
optional: true optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.1.0': '@tauri-apps/cli-linux-arm64-gnu@2.11.2':
optional: true optional: true
'@tauri-apps/cli-linux-arm64-musl@2.1.0': '@tauri-apps/cli-linux-arm64-musl@2.11.2':
optional: true optional: true
'@tauri-apps/cli-linux-x64-gnu@2.1.0': '@tauri-apps/cli-linux-riscv64-gnu@2.11.2':
optional: true optional: true
'@tauri-apps/cli-linux-x64-musl@2.1.0': '@tauri-apps/cli-linux-x64-gnu@2.11.2':
optional: true optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.1.0': '@tauri-apps/cli-linux-x64-musl@2.11.2':
optional: true optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.1.0': '@tauri-apps/cli-win32-arm64-msvc@2.11.2':
optional: true optional: true
'@tauri-apps/cli-win32-x64-msvc@2.1.0': '@tauri-apps/cli-win32-ia32-msvc@2.11.2':
optional: true optional: true
'@tauri-apps/cli@2.1.0': '@tauri-apps/cli-win32-x64-msvc@2.11.2':
optional: true
'@tauri-apps/cli@2.11.2':
optionalDependencies: optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.1.0 '@tauri-apps/cli-darwin-arm64': 2.11.2
'@tauri-apps/cli-darwin-x64': 2.1.0 '@tauri-apps/cli-darwin-x64': 2.11.2
'@tauri-apps/cli-linux-arm-gnueabihf': 2.1.0 '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.2
'@tauri-apps/cli-linux-arm64-gnu': 2.1.0 '@tauri-apps/cli-linux-arm64-gnu': 2.11.2
'@tauri-apps/cli-linux-arm64-musl': 2.1.0 '@tauri-apps/cli-linux-arm64-musl': 2.11.2
'@tauri-apps/cli-linux-x64-gnu': 2.1.0 '@tauri-apps/cli-linux-riscv64-gnu': 2.11.2
'@tauri-apps/cli-linux-x64-musl': 2.1.0 '@tauri-apps/cli-linux-x64-gnu': 2.11.2
'@tauri-apps/cli-win32-arm64-msvc': 2.1.0 '@tauri-apps/cli-linux-x64-musl': 2.11.2
'@tauri-apps/cli-win32-ia32-msvc': 2.1.0 '@tauri-apps/cli-win32-arm64-msvc': 2.11.2
'@tauri-apps/cli-win32-x64-msvc': 2.1.0 '@tauri-apps/cli-win32-ia32-msvc': 2.11.2
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
'@tauri-apps/plugin-dialog@2.7.1': '@tauri-apps/plugin-dialog@2.7.1':
dependencies: dependencies:
@@ -1231,10 +1241,6 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.11.0 '@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-http@2.5.9':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
dependencies: dependencies:
'@tauri-apps/api': 2.11.0 '@tauri-apps/api': 2.11.0
@@ -1243,11 +1249,7 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.11.0 '@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-shell@2.3.5': '@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-store@2.4.3':
dependencies: dependencies:
'@tauri-apps/api': 2.11.0 '@tauri-apps/api': 2.11.0
@@ -1260,6 +1262,10 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/node@25.9.1':
dependencies:
undici-types: 7.24.6
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
@@ -1405,13 +1411,13 @@ snapshots:
path-parse@1.0.7: {} path-parse@1.0.7: {}
phosphor-svelte@3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10): phosphor-svelte@3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10(@types/node@25.9.1)):
dependencies: dependencies:
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
svelte: 5.55.5(@typescript-eslint/types@8.57.1) svelte: 5.55.5(@typescript-eslint/types@8.57.1)
optionalDependencies: optionalDependencies:
vite: 8.0.10 vite: 8.0.10(@types/node@25.9.1)
picocolors@1.1.1: {} picocolors@1.1.1: {}
@@ -1544,7 +1550,9 @@ snapshots:
typescript@6.0.3: {} typescript@6.0.3: {}
vite@8.0.10: undici-types@7.24.6: {}
vite@8.0.10(@types/node@25.9.1):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@@ -1552,10 +1560,11 @@ snapshots:
rolldown: 1.0.0-rc.17 rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16 tinyglobby: 0.2.16
optionalDependencies: optionalDependencies:
'@types/node': 25.9.1
fsevents: 2.3.3 fsevents: 2.3.3
vitefu@1.1.3(vite@8.0.10): vitefu@1.1.3(vite@8.0.10(@types/node@25.9.1)):
optionalDependencies: optionalDependencies:
vite: 8.0.10 vite: 8.0.10(@types/node@25.9.1)
zimmerframe@1.1.4: {} zimmerframe@1.1.4: {}
+1 -2
View File
@@ -4,8 +4,7 @@
"version": "0.9.4", "version": "0.9.4",
"identifier": "io.github.MokuProject.Moku", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"devUrl": "http://localhost:1420", "frontendDist": "../build",
"frontendDist": "../dist",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm build"
}, },
"app": { "app": {
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"build": { "build": {
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev" "beforeDevCommand": "pnpm dev"
}, },
"app": { "app": {
@@ -12,4 +13,4 @@
"bundle": { "bundle": {
"externalBin": [] "externalBin": []
} }
} }
+12 -114
View File
@@ -1,125 +1,23 @@
@import '$lib/components/settings/Settings.css'; @import './lib/design/index.css';
@import '$lib/styles/themes.css';
:root { :root {
--bg-void: #080808; --ui-zoom: 1;
--bg-base: #0c0c0c; --ui-scale: 1;
--bg-surface: #101010; --visual-vh: 100vh;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
--titlebar-height: 36px;
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
} }
*, *::before, *::after { html,
box-sizing: border-box; body,
margin: 0; #svelte {
padding: 0; width: 100%;
} }
html, body { body {
height: 100%; overscroll-behavior: none;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
} }
#svelte { #svelte {
height: 100%; isolation: isolate;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
} }
a { a {
@@ -200,4 +98,4 @@ body {
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.4s ease infinite; animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
+48
View File
@@ -2,4 +2,52 @@ declare global {
namespace App {} namespace App {}
const __APP_VERSION__: string const __APP_VERSION__: string
} }
declare module '@capacitor/filesystem' {
export const Filesystem: {
readFile(options: { path: string; directory?: string }): Promise<{ data: string | Blob }>;
writeFile(options: { path: string; data: string | Blob; directory?: string }): Promise<void>;
};
export const Directory: {
Data: string;
};
}
declare module '@capacitor/app' {
export const App: {
getInfo(): Promise<{ version: string }>;
};
}
declare module '@capacitor/browser' {
export const Browser: {
open(options: { url: string }): Promise<void>;
};
}
declare module 'capacitor-native-biometric' {
export const NativeBiometric: {
verifyIdentity(options: { reason?: string; title?: string }): Promise<void>;
setCredentials(options: { username: string; password: string; server: string }): Promise<void>;
getCredentials(options: { server: string }): Promise<{ username: string; password: string }>;
};
}
declare module '@tauri-apps/plugin-dialog' {
export function open(options?: { directory?: boolean; multiple?: boolean }): Promise<string | string[] | null>;
}
declare module '@tauri-apps/plugin-fs' {
export function readFile(path: string): Promise<Uint8Array>;
export function writeFile(path: string, data: Uint8Array): Promise<void>;
}
declare module '@tauri-apps/plugin-updater' {
export function check(): Promise<{ available: boolean; version: string; body?: string; downloadAndInstall(): Promise<void> } | null>;
}
declare module '@tauri-apps/plugin-process' {
export function relaunch(): Promise<void>;
}
export {} export {}
+87 -54
View File
@@ -1,42 +1,59 @@
import { initRequestManager } from '$lib/request-manager' import {initRequestManager} from '$lib/request-manager';
import { initPlatformService } from '$lib/platform-service' import {initPlatformService} from '$lib/platform-service';
import { appState } from '$lib/state/app.svelte' import {appState} from '$lib/state/app.svelte';
import { configureAuth, probeServer } from '$lib/core/auth' import {configureAuth, probeServer} from '$lib/core/auth';
import {initHistoryState} from '$lib/state/history.svelte';
import {initSettingsState, settingsState, updateSettings} from '$lib/state/settings.svelte';
const KEY_URL = 'moku_server_url' const SAVED_URL_KEY = 'moku_server_url';
const KEY_AUTH = 'moku_auth_config' const SAVED_AUTH_KEY = 'moku_auth_config';
interface SavedAuth { interface SavedAuth {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN';
user?: string user?: string;
pass?: string pass?: string;
} }
function isTauri(): boolean { return '__TAURI_INTERNALS__' in window } function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
function isCapacitor(): boolean { return 'Capacitor' in window } return mode === 'BASIC_AUTH' ? 'BASIC_AUTH' : mode === 'UI_LOGIN' || mode === 'SIMPLE_LOGIN' ? 'UI_LOGIN' : 'NONE';
}
function detectPlatform(): 'tauri' | 'capacitor' | 'web' { function isTauri(): boolean {
if (isTauri()) return 'tauri' return '__TAURI_INTERNALS__' in window;
if (isCapacitor()) return 'capacitor' }
return 'web'
function isCapacitor(): boolean {
return 'Capacitor' in window;
}
function loadSavedServerUrl(): string {
return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567';
}
function loadSavedAuth(): SavedAuth {
try {
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? {mode: 'NONE'};
} catch {
return {mode: 'NONE'};
}
} }
async function resolvePlatformAdapter() { async function resolvePlatformAdapter() {
if (isTauri()) { if (isTauri()) {
const { TauriAdapter } = await import('$lib/platform-adapters/tauri') const {TauriAdapter} = await import('$lib/platform-adapters/tauri');
return new TauriAdapter() return new TauriAdapter();
} }
if (isCapacitor()) { // if (isCapacitor()) {
const { CapacitorAdapter } = await import('$lib/platform-adapters/capacitor') // const {CapacitorAdapter} = await import('$lib/platform-adapters/capacitor');
return new CapacitorAdapter() // return new CapacitorAdapter();
} // }
const { WebAdapter } = await import('$lib/platform-adapters/web') const {WebAdapter} = await import('$lib/platform-adapters/web');
return new WebAdapter() return new WebAdapter();
} }
async function resolveServerAdapter() { async function resolveServerAdapter() {
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi') const {SuwayomiAdapter} = await import('$lib/server-adapters/suwayomi');
return new SuwayomiAdapter() return new SuwayomiAdapter();
} }
async function boot() { async function boot() {
@@ -44,46 +61,62 @@ async function boot() {
const [serverAdapter, platformAdapter] = await Promise.all([ const [serverAdapter, platformAdapter] = await Promise.all([
resolveServerAdapter(), resolveServerAdapter(),
resolvePlatformAdapter(), resolvePlatformAdapter(),
]) ]);
initRequestManager(serverAdapter) await platformAdapter.init();
initPlatformService(platformAdapter)
appState.platform = detectPlatform() initRequestManager(serverAdapter);
appState.version = await platformAdapter.getVersion() initPlatformService(platformAdapter);
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567' await Promise.all([
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH) initSettingsState(),
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' } initHistoryState(),
]);
appState.serverUrl = savedUrl // appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web';
appState.authMode = savedAuth.mode appState.platform = isTauri() ? 'tauri' : 'web';
appState.version = await platformAdapter.getVersion();
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass) const legacyAuth = loadSavedAuth();
const savedUrl = settingsState.serverUrl || loadSavedServerUrl();
const savedAuth: SavedAuth = {
mode: normalizeAuthMode(settingsState.serverAuthMode || legacyAuth.mode),
user: settingsState.serverAuthUser || legacyAuth.user,
pass: settingsState.serverAuthPass || legacyAuth.pass,
};
await serverAdapter.connect({ updateSettings({
baseUrl: savedUrl, serverUrl: savedUrl,
credentials: serverAuthMode: savedAuth.mode,
savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass serverAuthUser: savedAuth.user ?? '',
? { username: savedAuth.user, password: savedAuth.pass } serverAuthPass: savedAuth.pass ?? '',
: undefined, });
})
const probe = await probeServer() appState.serverUrl = savedUrl;
appState.authMode = savedAuth.mode;
if (probe === 'auth_required') { appState.status = 'auth'; return } configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass);
if (probe === 'unreachable') { await serverAdapter.connect({baseUrl: savedUrl});
appState.error = `Could not reach server at ${savedUrl}`
appState.status = 'error' const probe = await probeServer();
return
if (probe === 'auth_required') {
appState.status = 'auth';
return;
} }
appState.authenticated = true if (probe === 'unreachable') {
appState.status = 'ready' appState.error = `Could not reach server at ${savedUrl}`;
appState.status = 'error';
return;
}
appState.authenticated = true;
appState.status = 'ready';
} catch (e) { } catch (e) {
appState.error = String(e) appState.error = String(e);
appState.status = 'error' appState.status = 'error';
} }
} }
boot() boot();
-151
View File
@@ -1,151 +0,0 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from 'phosphor-svelte'
import { timeAgo } from '$lib/components/home/homeHelpers'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
let {
entries,
libraryManga,
onresume,
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[]
libraryManga: Manga[]
onresume: (entry: HistoryEntry) => void
onviewhistory: () => void
onopenlibrary: () => void
} = $props()
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? ''
}
</script>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if entries.length > 0}
<button class="see-all" onclick={onviewhistory}>
Full History <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}>
<img src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ''}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="placeholder">
{#each Array(5) as _, i}
<div class="row row-sk">
<div class="sk-thumb"></div>
<div class="row-info">
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
</div>
<div class="sk sk-time"></div>
</div>
{/each}
<div class="placeholder-overlay">
<button class="placeholder-cta" onclick={onopenlibrary}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
}
.section-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.see-all {
display: flex; align-items: center; gap: 4px;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0;
transition: color var(--t-base);
}
.see-all:hover { color: var(--accent-fg); }
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 7px var(--sp-2); border-radius: var(--radius-md);
border: 1px solid transparent; background: none;
text-align: left; cursor: pointer; width: 100%;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
.row-thumb {
width: 33px; height: 48px; border-radius: var(--radius-sm);
object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim);
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-sub {
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-time {
font-family: var(--font-ui); font-size: var(--text-sm);
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.row-sk { cursor: default; pointer-events: none; }
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-title { height: 11px; margin-bottom: 5px; }
.sk-sub { height: 9px; }
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
.placeholder { position: relative; }
.placeholder-overlay {
position: absolute; left: 0; right: 0; top: 0; bottom: -1px;
display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4);
pointer-events: none;
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%);
}
.placeholder-cta {
pointer-events: all;
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 16px; border-radius: var(--radius-full);
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.13);
color: rgba(255,255,255,0.62); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
@@ -1,207 +0,0 @@
<script lang="ts">
let {
dailyReadCounts,
}: {
dailyReadCounts: Record<string, number>
} = $props()
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
if (count === 0) return 0
if (count === 1) return 1
if (count <= 3) return 2
if (count <= 6) return 3
return 4
}
let tip: { text: string; x: number; y: number } | null = $state(null)
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const label = cell.count === 0
? `No chapters — ${fmtDate(cell.dateStr)}`
: `${cell.count} chapter${cell.count !== 1 ? 's' : ''}${fmtDate(cell.dateStr)}`
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 }
}
function hideTip() { tip = null }
function fmtDate(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function localDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
let wrapEl: HTMLElement
let cellSize = $state(12)
let numWeeks = $state(26)
const GAP = 3
const DAY_GUTTER = 28
const LEGEND_H = 20
const MONTH_H = 14
const ROWS = 7
$effect(() => {
if (!wrapEl) return
const obs = new ResizeObserver(() => {
const h = wrapEl.clientHeight
const w = wrapEl.clientWidth
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS))
cellSize = cs
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)))
})
obs.observe(wrapEl)
return () => obs.disconnect()
})
const visibleWeeks = $derived((() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = localDateStr(today)
const endDow = today.getDay()
const weekEnd = new Date(today)
weekEnd.setDate(weekEnd.getDate() + (6 - endDow))
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = []
for (let wi = numWeeks - 1; wi >= 0; wi--) {
const week: typeof weeks[0] = []
for (let di = 0; di < 7; di++) {
const d = new Date(weekEnd)
d.setDate(d.getDate() - wi * 7 - (6 - di))
const dateStr = localDateStr(d)
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today })
}
weeks.push(week)
}
return weeks
})())
const monthLabels = $derived((() => {
const labels: { label: string; colIndex: number }[] = []
let lastMonth = -1
visibleWeeks.forEach((week, ci) => {
const first = week[0]
if (!first) return
const m = new Date(first.dateStr + 'T00:00:00').getMonth()
if (m !== lastMonth) {
labels.push({ label: new Date(first.dateStr + 'T00:00:00').toLocaleDateString('en-US', { month: 'short' }), colIndex: ci })
lastMonth = m
}
})
return labels
})())
const DAY_LABELS = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat']
</script>
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
<div class="month-row">
<div class="day-gutter"></div>
<div class="month-cells">
{#each visibleWeeks as _week, ci}
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
<div class="month-label">{lbl?.label ?? ''}</div>
{/each}
</div>
</div>
<div class="grid-row">
<div class="day-labels">
{#each DAY_LABELS as d}
<span class="day-label">{d}</span>
{/each}
</div>
<div class="cell-grid">
{#each visibleWeeks as week}
<div class="week-col">
{#each week as cell}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<button
class="cell intensity-{intensity(cell.count)}"
class:cell-today={cell.isToday}
class:cell-future={cell.isFuture}
onmouseover={(e) => showTip(e, cell)}
onmouseleave={hideTip}
aria-label="{cell.count} chapters on {cell.dateStr}"
></button>
{/each}
</div>
{/each}
</div>
</div>
<div class="legend">
<span class="legend-label">Less</span>
{#each [0, 1, 2, 3, 4] as lvl}
<div class="legend-cell intensity-{lvl}"></div>
{/each}
<span class="legend-label">More</span>
</div>
</div>
{#if tip}
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
{/if}
<style>
.heatmap-wrap {
display: flex; flex-direction: column; justify-content: center;
gap: 4px; width: 100%; height: 100%;
min-width: 0; min-height: 0; overflow: hidden; box-sizing: border-box;
}
.month-row { display: flex; gap: 4px; flex-shrink: 0; }
.day-gutter { width: 28px; flex-shrink: 0; }
.month-cells {
display: grid; grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px; overflow: hidden;
}
.month-label {
font-family: var(--font-ui); font-size: 9px; color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding-left: 1px; white-space: nowrap; overflow: hidden;
}
.grid-row { display: flex; gap: 4px; align-items: flex-start; flex-shrink: 0; }
.day-labels { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; width: 28px; }
.day-label {
font-family: var(--font-ui); font-size: 8px; color: var(--text-faint);
letter-spacing: var(--tracking-wide); height: var(--cell); line-height: var(--cell); text-align: right;
}
.cell-grid {
display: grid; grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px; overflow: visible; padding: 4px; margin: -4px;
}
.week-col { display: flex; flex-direction: column; gap: 3px; }
.cell {
width: var(--cell); height: var(--cell); border-radius: 3px;
border: none; padding: 0; cursor: pointer;
transition: filter var(--t-fast), transform var(--t-fast);
}
.cell:hover:not(.cell-future) { filter: brightness(1.5); transform: scale(1.2); z-index: 1; position: relative; }
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
.legend { display: flex; align-items: center; gap: 3px; justify-content: flex-end; flex-shrink: 0; padding-top: 2px; }
.legend-cell { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.legend-label { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.heatmap-tip {
position: fixed; transform: translate(-50%, -100%);
background: var(--bg-overlay); border: 1px solid var(--border-base);
border-radius: var(--radius-sm); padding: 4px 8px;
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary);
letter-spacing: var(--tracking-wide); white-space: nowrap; pointer-events: none;
z-index: 9999; box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
</style>
@@ -1,129 +0,0 @@
<script lang="ts">
import { MagnifyingGlass, X as XIcon } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
let {
slotIndex,
libraryManga,
loading,
onpin,
onclose,
}: {
slotIndex: 1 | 2 | 3
libraryManga: Manga[]
loading: boolean
onpin: (m: Manga) => void
onclose: () => void
} = $props()
let search = $state('')
function focusEl(node: HTMLElement) { node.focus() }
const results = $derived(
search.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20)
)
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose() }}
onkeydown={(e) => { if (e.key === 'Escape') onclose() }}
>
<div class="modal">
<div class="modal-header">
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
</div>
<div class="list">
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if results.length === 0}
<p class="empty-msg">No results</p>
{:else}
{#each results as m (m.id)}
<button class="list-row" onclick={() => onpin(m)}>
<img src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<div class="row-info">
<span class="row-title">{m.title}</span>
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.62);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.modal {
width: min(460px, calc(100vw - 48px)); max-height: 68vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.modal-close {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.search-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
.search-input::placeholder { color: var(--text-faint); }
.list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.list::-webkit-scrollbar { display: none; }
.empty-msg {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center;
}
.list-row {
display: flex; align-items: center; gap: var(--sp-3); width: 100%;
padding: 8px var(--sp-3); border-radius: var(--radius-md);
border: none; background: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.list-row:hover { background: var(--bg-raised); }
.row-thumb {
height: 50px; width: 35px; aspect-ratio: 1/1.42;
border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0;
border: 1px solid var(--border-dim); background: var(--bg-raised); display: block;
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
-452
View File
@@ -1,452 +0,0 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte'
import { goto } from '$app/navigation'
import { timeAgo } from '$lib/components/home/homeHelpers'
import type { Manga } from '$lib/types'
import type { Chapter } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
interface HeroSlot {
kind: 'continue' | 'pinned' | 'empty'
entry?: HistoryEntry
manga?: Manga
slotIndex: number
}
let {
resolvedSlots,
activeIdx = $bindable(),
heroThumb,
heroTitle,
heroManga,
heroEntry,
heroMangaId,
heroChapters,
heroNewChapter,
loadingHeroChapters,
resuming,
onresume,
onopenchapter,
oncyclenext,
oncycleprev,
ongotoslot,
onopenpicker,
onunpin,
onviewall,
}: {
resolvedSlots: HeroSlot[]
activeIdx: number
heroThumb: string
heroTitle: string
heroManga: Manga | null | undefined
heroEntry: HistoryEntry | null
heroMangaId: number | null
heroChapters: Chapter[]
heroNewChapter: Chapter | null
loadingHeroChapters: boolean
resuming: boolean
onresume: () => void
onopenchapter: (ch: Chapter) => void
oncyclenext: () => void
oncycleprev: () => void
ongotoslot: (i: number) => void
onopenpicker: (i: 1 | 2 | 3) => void
onunpin: (i: 1 | 2 | 3) => void
onviewall: () => void
} = $props()
const activeSlot = $derived(resolvedSlots[activeIdx])
const TOTAL_SLOTS = 4
</script>
<div class="hero-stage">
{#key heroThumb}
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
{/key}
<div class="hero-scrim"></div>
<button
class="hero-cover-col"
onclick={onresume}
disabled={resuming || activeSlot?.kind === 'empty'}
aria-label={heroTitle ? `Resume ${heroTitle}` : 'No manga selected'}
>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === 'continue'}
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
{/if}
{:else}
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
{/if}
</button>
<div class="hero-details">
{#if activeSlot?.kind === 'empty'}
<p class="hero-empty-title">Nothing here yet</p>
<p class="hero-empty-sub">
{activeSlot.slotIndex === 0
? 'Read a manga to see it here'
: 'Pin a manga or keep reading to fill this slot'}
</p>
{#if activeSlot.slotIndex !== 0}
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
<PushPin size={11} weight="fill" /> Pin manga
</button>
{/if}
{:else}
<div class="hero-tags">
{#if activeSlot?.kind === 'continue'}
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
{:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#if heroNewChapter && !heroNewChapter.isRead}
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button
class="hero-tag hero-tag-genre"
onclick={() => goto(`/browse?genre=${encodeURIComponent(g)}`)}
>{g}</button>
{/each}
</div>
<h2 class="hero-title">{heroTitle}</h2>
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
</p>
{/if}
{#if heroManga?.description}
<p class="hero-desc">{heroManga.description}</p>
{/if}
<div class="hero-actions">
{#if activeSlot?.kind === 'continue'}
<button class="hero-cta" onclick={onresume} disabled={resuming}>
<Play size={11} weight="fill" />{resuming ? 'Loading…' : 'Resume'}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => goto(`/series/${heroManga!.id}`)}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
{#if activeSlot?.slotIndex !== 0}
{#if activeSlot?.kind === 'pinned'}
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => onopenpicker(activeSlot!.slotIndex as 1 | 2 | 3)}>
<PushPin size={10} weight="light" /> Pin
</button>
{/if}
{/if}
</div>
{/if}
<div class="hero-nav-row">
<button class="hero-nav-btn" onclick={oncycleprev} aria-label="Previous">
<ArrowLeft size={12} weight="bold" />
</button>
<div class="hero-dots">
{#each resolvedSlots as slot, i}
<button
class="hero-dot"
class:active={activeIdx === i}
class:pinned={slot.kind === 'pinned'}
onclick={() => ongotoslot(i)}
aria-label="Slot {i + 1}"
></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={oncyclenext} aria-label="Next">
<ArrowRight size={12} weight="bold" />
</button>
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
</div>
</div>
<div class="hero-chapters">
<div class="hero-chapters-header">
<ListBullets size={11} weight="bold" /><span>Up Next</span>
</div>
{#if activeSlot?.kind === 'empty'}
<p class="hero-chapters-empty">No chapters to show</p>
{:else if loadingHeroChapters}
{#each Array(4) as _}
<div class="chapter-row-sk">
<div class="sk sk-num"></div>
<div class="sk-info">
<div class="sk sk-name"></div>
<div class="sk sk-meta"></div>
</div>
</div>
{/each}
{:else if heroChapters.length === 0}
<p class="hero-chapters-empty">No chapters available</p>
{:else}
{#each heroChapters as ch (ch.id)}
{@const isCurrent = heroEntry?.chapterId === ch.id}
<button
class="chapter-row"
class:chapter-row-current={isCurrent}
class:chapter-row-read={ch.isRead && !isCurrent}
onclick={() => onopenchapter(ch)}
>
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
<div class="ch-info">
<span class="ch-name">{ch.name}</span>
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
{:else if ch.isRead}
<span class="ch-meta ch-read">Read</span>
{:else if ch.uploadDate}
<span class="ch-meta">
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
{/if}
</div>
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={onviewall}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
<style>
.hero-stage {
position: relative;
display: flex;
align-items: stretch;
height: 374px;
overflow: hidden;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
}
.hero-backdrop {
position: absolute;
inset: -14px;
background-size: cover;
background-position: center 25%;
filter: blur(22px) saturate(2.4) brightness(0.4);
transform: scale(1.07);
pointer-events: none;
z-index: 0;
animation: backdropIn 0.5s ease both;
}
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-cover-col {
position: relative;
z-index: 2;
flex-shrink: 0;
width: 256px;
height: 374px;
overflow: hidden;
cursor: pointer;
background: var(--bg-raised);
padding: 0;
border: none;
border-right: 1px solid rgba(255,255,255,0.07);
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
.hero-cover-empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: var(--bg-overlay); color: var(--text-faint);
}
.cover-resume-hint {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #fff; background: rgba(0,0,0,0.38);
opacity: 0; transition: opacity 0.18s ease; pointer-events: none;
}
.hero-details {
position: relative; z-index: 2;
flex: 1; min-width: 0;
padding: var(--sp-5) var(--sp-5) var(--sp-4);
display: flex; flex-direction: column; gap: var(--sp-2);
overflow: hidden;
border-right: 1px solid rgba(255,255,255,0.05);
}
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 9px;
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
border: 1px solid rgba(255,255,255,0.13);
}
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
.hero-tag-new { background: rgba(74,222,128,0.15); color: #86efac; border-color: rgba(74,222,128,0.25); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
.hero-title {
font-size: var(--text-xl); font-weight: var(--weight-semibold);
color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
text-shadow: 0 2px 12px rgba(0,0,0,0.55); letter-spacing: -0.01em;
}
.hero-author {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.hero-progress {
display: flex; align-items: center; gap: 5px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.55); letter-spacing: var(--tracking-wide);
}
.hero-prog-page { color: rgba(255,255,255,0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.3); }
.hero-desc {
font-size: var(--text-xs); color: rgba(255,255,255,0.38); line-height: 1.6;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0;
}
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.48); flex-shrink: 0; }
.hero-empty-sub {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
}
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
.hero-cta {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 18px; border-radius: var(--radius-md);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
cursor: pointer; transition: filter var(--t-base); white-space: nowrap;
}
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
.hero-cta:disabled { opacity: 0.5; cursor: default; }
.hero-cta-ghost {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 14px; border-radius: var(--radius-md);
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.48); cursor: pointer;
transition: background var(--t-base), color var(--t-base); white-space: nowrap;
}
.hero-cta-ghost:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.82); }
.hero-nav-row {
display: flex; align-items: center; gap: var(--sp-2);
flex-shrink: 0; margin-top: auto; padding-top: var(--sp-3);
border-top: 1px solid rgba(255,255,255,0.07);
}
.hero-nav-btn {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.55); cursor: pointer; flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
}
.hero-nav-btn:hover { background: rgba(255,255,255,0.18); color: #fff; }
.hero-dots { display: flex; gap: 5px; align-items: center; }
.hero-dot {
width: 5px; height: 5px; border-radius: 50%;
background: rgba(255,255,255,0.2); border: none; cursor: pointer; padding: 0;
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
}
.hero-dot:hover { background: rgba(255,255,255,0.48); }
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
.hero-dot.pinned { background: rgba(168,132,232,0.5); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter {
font-family: var(--font-ui); font-size: 10px;
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); margin-left: auto;
}
.hero-chapters {
position: relative; z-index: 2;
width: clamp(180px, 30%, 232px); flex-shrink: 0;
display: flex; flex-direction: column;
padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden;
}
.hero-chapters-header {
display: flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wider); text-transform: uppercase;
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
.hero-chapters-empty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.22); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0;
}
.chapter-row {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 7px var(--sp-2); border-radius: var(--radius-sm);
background: none; border: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.chapter-row:hover { background: rgba(255,255,255,0.07); }
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
.ch-num {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.32); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px;
}
.chapter-row-current .ch-num { color: var(--accent-fg); }
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-row-read .ch-name { color: rgba(255,255,255,0.32); }
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); }
.ch-read { color: rgba(255,255,255,0.18); }
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
.sk-name { height: 11px; width: 85%; }
.sk-meta { height: 9px; width: 50%; }
.ch-view-all {
display: flex; align-items: center; gap: 4px; margin-top: auto;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide);
background: none; border: none; cursor: pointer;
padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base);
}
.ch-view-all:hover { color: var(--accent-fg); }
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
-33
View File
@@ -1,33 +0,0 @@
<script lang="ts">
import { Sparkle } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
let {
libraryManga,
history,
onopenrecommended,
}: {
libraryManga: Manga[]
history: HistoryEntry[]
onopenrecommended: (m: Manga) => void
} = $props()
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><Sparkle size={10} weight="bold" /> Recommended</span>
</div>
<p class="stub">Recommendations coming soon</p>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); flex-shrink: 0; }
.col-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.stub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style>
-102
View File
@@ -1,102 +0,0 @@
<script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from 'phosphor-svelte'
import { formatReadTime } from '$lib/components/home/homeHelpers'
import type { ReadingStats } from '$lib/state/home.svelte'
let {
stats,
updateCount,
}: {
stats: ReadingStats
updateCount: number
} = $props()
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
</div>
<div class="grid">
<div class="card">
<div class="icon-wrap fire"><Fire size={15} weight="fill" /></div>
<div class="body">
<span class="val">{stats.currentStreakDays}</span>
<span class="label">Day streak</span>
</div>
</div>
<div class="card">
<div class="icon-wrap accent"><BookOpen size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalChaptersRead}</span>
<span class="label">Chapters read</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><Clock size={15} weight="light" /></div>
<div class="body">
<span class="val">{formatReadTime(stats.totalMinutesRead)}</span>
<span class="label">Read time</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><TrendUp size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalMangaRead}</span>
<span class="label">Series started</span>
</div>
</div>
<div class="card">
<div class="icon-wrap green"><Bell size={15} weight="light" /></div>
<div class="body">
<span class="val">{updateCount}</span>
<span class="label">New updates</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><CalendarBlank size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.longestStreakDays}d</span>
<span class="label">Best streak</span>
</div>
</div>
</div>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; }
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.col-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.card {
display: flex; align-items: center; gap: var(--sp-3);
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: var(--sp-3);
transition: border-color var(--t-fast);
}
.card:hover { border-color: var(--border-base); }
.icon-wrap {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: var(--radius-sm); flex-shrink: 0;
}
.fire { background: rgba(251,146,60,0.15); color: #fb923c; }
.accent { background: var(--accent-muted); color: var(--accent-fg); }
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
.green { background: rgba(34,197,94,0.12); color: #22c55e; }
.body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.val {
font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1;
}
.label {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap;
}
</style>
-39
View File
@@ -1,39 +0,0 @@
export function timeAgo(ts: number): string {
const diff = Date.now() - ts
const m = Math.floor(diff / 60000)
if (m < 1) return 'Just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 7) return `${d}d ago`
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function timeAgoRefresh(ts: number): string {
if (!ts) return ''
const diff = Date.now() - ts
const m = Math.floor(diff / 60000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function formatReadTime(mins: number): string {
if (mins < 1) return `${Math.round(mins * 60)}s`
if (mins < 60) return `${Math.round(mins)}m`
const h = Math.floor(mins / 60)
const r = Math.round(mins % 60)
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`
const d = Math.floor(h / 24)
const rh = h % 24
return rh === 0 ? `${d}d` : `${d}d ${rh}h`
}
export function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
;(e.currentTarget as HTMLElement).scrollLeft += e.deltaY
e.stopPropagation()
}
@@ -1,170 +0,0 @@
<script lang="ts">
import { Check, Funnel } from 'phosphor-svelte'
import type { MangaStatus } from '$lib/server-adapters/types'
interface Props {
status: MangaStatus | 'all'
unread: boolean
downloaded: boolean
bookmarked: boolean
hasActive: boolean
open: boolean
onToggle: () => void
onStatus: (s: MangaStatus | 'all') => void
onUnread: () => void
onDownloaded: () => void
onBookmarked: () => void
onClear: () => void
}
let {
status, unread, downloaded, bookmarked, hasActive, open,
onToggle, onStatus, onUnread, onDownloaded, onBookmarked, onClear,
}: Props = $props()
const STATUSES: [MangaStatus, string][] = [
['ONGOING', 'Ongoing'],
['COMPLETED', 'Completed'],
['ON_HIATUS', 'Hiatus'],
['CANCELLED', 'Cancelled'],
['PUBLISHING_FINISHED', 'Publishing finished'],
]
</script>
<div class="wrap">
<button
class="icon-btn"
class:active={hasActive}
title="Filter"
onclick={onToggle}
>
<Funnel size={15} weight={hasActive ? 'fill' : 'bold'} />
</button>
{#if open}
<div class="panel" role="menu">
<div class="panel-head">
<span class="panel-title">Filter</span>
{#if hasActive}
<button class="clear-btn" onclick={onClear}>Clear all</button>
{/if}
</div>
<div class="divider"></div>
<p class="section-label">Content</p>
{#each [
{ label: 'Unread', active: unread, handler: onUnread },
{ label: 'Downloaded', active: downloaded, handler: onDownloaded },
{ label: 'Bookmarked', active: bookmarked, handler: onBookmarked },
] as f}
<button
class="item"
class:item-active={f.active}
role="menuitem"
onclick={f.handler}
>
<span class="check" class:check-on={f.active}>
{#if f.active}<Check size={9} weight="bold" />{/if}
</span>
{f.label}
</button>
{/each}
<div class="divider"></div>
<p class="section-label">Status</p>
{#each STATUSES as [s, label]}
<button
class="item"
class:item-active={status === s}
role="menuitem"
onclick={() => onStatus(status === s ? 'all' : s)}
>
<span class="check" class:check-on={status === s}>
{#if status === s}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.wrap { position: relative; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-faint);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
min-width: 220px;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-1);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: fadeIn 0.1s ease both;
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px 4px;
}
.panel-title {
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
font-weight: var(--weight-medium, 500);
}
.clear-btn {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 0;
transition: color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); }
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); padding: 4px 8px 8px;
}
.item {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px 10px;
border-radius: var(--radius-sm); border: none;
background: transparent; color: var(--text-muted);
font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left;
transition: background var(--t-base), color var(--t-base);
}
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.item-active:hover { background: var(--accent-dim); }
.check {
width: 13px; height: 13px; border-radius: 2px;
border: 1px solid var(--border-strong);
background: transparent; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--bg-base);
transition: background var(--t-base), border-color var(--t-base);
}
.check-on { background: var(--accent); border-color: var(--accent); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -1,246 +0,0 @@
<script lang="ts">
import { CheckSquare, Trash } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
interface Props {
items: Manga[]
loading: boolean
selectMode: boolean
selected: Set<number>
tab: string
onCardClick: (e: MouseEvent, m: Manga) => void
onSelectAll: () => void
onExitSelect: () => void
onBulkRemove: () => void
}
let {
items, loading, selectMode, selected, tab,
onCardClick, onSelectAll, onExitSelect, onBulkRemove,
}: Props = $props()
const THUMB_BASE = 'http://127.0.0.1:4567'
function coverUrl(m: Manga) {
const url = m.thumbnailUrl ?? ''
return url.startsWith('http') ? url : `${THUMB_BASE}${url}`
}
</script>
{#if selectMode}
<div class="select-bar">
<span class="sel-count">{selected.size} selected</span>
<button class="sel-text-btn" onclick={onSelectAll}>Select all</button>
<div class="sel-right">
<button
class="sel-action-btn sel-danger"
disabled={selected.size === 0}
onclick={onBulkRemove}
>
<Trash size={13} weight="bold" />
Remove
</button>
</div>
</div>
{/if}
<div
class="content"
role="presentation"
onclick={(e) => {
if (selectMode && !(e.target as HTMLElement).closest('.card')) onExitSelect()
}}
>
{#if loading}
<div class="grid">
{#each Array(12) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if items.length === 0}
<div class="empty">
{tab === 'downloaded'
? 'No downloaded manga.'
: 'No manga saved to library — browse sources to add some.'}
</div>
{:else}
<div class="grid">
{#each items as m (m.id)}
{@const isSelected = selected.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
<button
class="card"
class:card-selected={isSelected}
class:select-mode={selectMode}
onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => {
e.preventDefault()
onCardClick(e, m)
}}
>
<div class="cover-wrap" class:completed={isCompleted}>
<img
class="cover"
src={coverUrl(m)}
alt={m.title}
draggable="false"
loading="lazy"
/>
<div class="overlay">
<div class="badges">
{#if isCompleted}
<span class="badge badge-done">✓ Done</span>
{:else if m.unreadCount}
<span class="badge badge-unread">{m.unreadCount} new</span>
{/if}
{#if m.downloadCount}
<span class="badge badge-dl">{m.downloadCount}</span>
{/if}
</div>
</div>
{#if selectMode}
<div class="select-overlay" aria-hidden="true">
<div class="select-check" class:checked={isSelected}>
{#if isSelected}
<CheckSquare size={20} weight="fill" />
{:else}
<div class="check-empty"></div>
{/if}
</div>
</div>
{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{/if}
</div>
<style>
.content {
flex: 1; overflow-y: auto;
padding: var(--sp-5) var(--sp-6) var(--sp-6);
-webkit-overflow-scrolling: touch;
}
.select-bar {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-6);
background: var(--bg-raised); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; z-index: 10; position: relative;
animation: fadeIn 0.1s ease both;
}
.sel-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.sel-count {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap;
}
.sel-text-btn {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none;
cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm);
transition: color var(--t-base);
}
.sel-text-btn:hover { color: var(--text-primary); }
.sel-action-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-xs);
padding: 5px 10px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-muted); cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-danger:hover:not(:disabled) {
color: var(--color-error, #e05c5c);
border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent);
background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: var(--sp-4);
}
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:not(.select-mode):hover .cover-wrap {
transform: translateY(-3px);
border-color: var(--border-strong);
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
}
.card:not(.select-mode):hover .title { color: var(--text-primary); }
.card.select-mode { cursor: default; }
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
.card.card-selected .title { color: var(--accent-fg); }
.cover-wrap {
position: relative; aspect-ratio: 2/3; overflow: hidden;
border-radius: var(--radius-md); background: var(--bg-raised);
border: 1px solid var(--border-dim); will-change: transform;
transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1);
}
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.cover { width: 100%; height: 100%; object-fit: cover; display: block; }
.overlay {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
padding: 32px 6px 10px;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%);
opacity: 0; pointer-events: none;
transition: opacity 0.18s ease;
}
.card:not(.select-mode):hover .overlay { opacity: 1; }
.badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.badge {
font-family: var(--font-ui); font-size: 9.5px; font-weight: 700;
letter-spacing: 0.04em; line-height: 1; padding: 3px 7px;
border-radius: 20px; white-space: nowrap;
}
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.select-overlay {
position: absolute; inset: 0; z-index: 3;
background: rgba(0,0,0,0.18);
display: flex; align-items: flex-start; justify-content: flex-end;
padding: 6px; pointer-events: none;
}
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
.select-check.checked { color: var(--accent-fg); opacity: 1; }
.check-empty {
width: 20px; height: 20px; border-radius: 4px;
border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3);
}
.title {
margin-top: var(--sp-2); font-size: var(--text-sm);
color: var(--text-secondary); line-height: var(--leading-snug);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; height: 2lh;
transition: color var(--t-base);
}
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
.skeleton { background: var(--bg-raised); animation: pulse 1.4s ease infinite; }
.empty {
display: flex; align-items: center; justify-content: center;
height: 60%; color: var(--text-muted); font-size: var(--text-sm);
text-align: center;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
</style>
@@ -1,263 +0,0 @@
<script lang="ts">
import {
MagnifyingGlass, Books, DownloadSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise,
} from 'phosphor-svelte'
import LibraryFilters from './LibraryFilters.svelte'
import type { LibrarySortOption, LibraryTab } from '$lib/state/library.svelte'
import type { MangaStatus } from '$lib/server-adapters/types'
interface Props {
tab: LibraryTab
savedCount: number
dlCount: number
sort: LibrarySortOption
sortDesc: boolean
status: MangaStatus | 'all'
unread: boolean
downloaded: boolean
bookmarked: boolean
hasActiveFilters: boolean
refreshing: boolean
query: string
onTab: (t: LibraryTab) => void
onQuery: (q: string) => void
onSort: (s: LibrarySortOption) => void
onSortDesc: () => void
onStatus: (s: MangaStatus | 'all') => void
onUnread: () => void
onDownloaded: () => void
onBookmarked: () => void
onFilterClear: () => void
onRefresh: () => void
}
let {
tab, savedCount, dlCount, sort, sortDesc,
status, unread, downloaded, bookmarked, hasActiveFilters, refreshing, query,
onTab, onQuery, onSort, onSortDesc,
onStatus, onUnread, onDownloaded, onBookmarked, onFilterClear, onRefresh,
}: Props = $props()
let sortOpen = $state(false)
let filterOpen = $state(false)
const SORT_LABELS: Record<LibrarySortOption, string> = {
alphabetical: 'AZ',
unread: 'Unread chapters',
lastRead: 'Recently read',
dateAdded: 'Date added',
}
function onDocDown(e: MouseEvent) {
const t = e.target as HTMLElement
if (sortOpen && !t.closest('.sort-wrap')) sortOpen = false
if (filterOpen && !t.closest('.filter-wrap')) filterOpen = false
}
$effect(() => {
document.addEventListener('mousedown', onDocDown, true)
return () => document.removeEventListener('mousedown', onDocDown, true)
})
</script>
<div class="toolbar">
<span class="heading">Library</span>
<div class="tabs">
<button class="tab" class:active={tab === 'saved'} onclick={() => onTab('saved')}>
<Books size={11} weight="bold" />
Saved
<span class="count">{savedCount}</span>
</button>
<button class="tab" class:active={tab === 'downloaded'} onclick={() => onTab('downloaded')}>
<DownloadSimple size={11} weight="bold" />
Downloaded
<span class="count">{dlCount}</span>
</button>
</div>
<div class="right">
<div class="search-wrap">
<MagnifyingGlass size={13} class="search-icon" weight="light" />
<input
class="search"
placeholder="Search"
value={query}
oninput={(e) => onQuery((e.target as HTMLInputElement).value)}
/>
</div>
<button
class="icon-btn"
class:spinning={refreshing}
title={refreshing ? 'Checking for updates…' : 'Check for updates'}
onclick={onRefresh}
disabled={refreshing}
>
<ArrowsClockwise size={15} weight="bold" />
</button>
<div class="sort-wrap">
<button
class="icon-btn"
class:active={sort !== 'alphabetical' || sortDesc}
title="Sort"
onclick={() => { sortOpen = !sortOpen; filterOpen = false }}
>
<SortAscending size={15} weight="bold" />
</button>
{#if sortOpen}
<div class="panel sort-panel" role="menu">
<div class="panel-head">
<span class="panel-title">Sort</span>
</div>
<div class="divider"></div>
<p class="section-label">Order by</p>
{#each Object.entries(SORT_LABELS) as [s, label]}
<button
class="item"
class:item-active={sort === s}
role="menuitem"
onclick={() => { onSort(s as LibrarySortOption); sortOpen = false }}
>
{label}
{#if sort === s}
{#if sortDesc}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
{/if}
</button>
{/each}
<button class="item dir-toggle" role="menuitem" onclick={onSortDesc}>
{sortDesc ? 'Descending' : 'Ascending'}
{#if sortDesc}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
</button>
</div>
{/if}
</div>
<div class="filter-wrap">
<LibraryFilters
{status} {unread} {downloaded} {bookmarked}
hasActive={hasActiveFilters}
open={filterOpen}
onToggle={() => { filterOpen = !filterOpen; sortOpen = false }}
{onStatus} {onUnread} {onDownloaded} {onBookmarked}
onClear={onFilterClear}
/>
</div>
</div>
</div>
<style>
.toolbar {
position: relative; z-index: 100;
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; min-width: 0;
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase; flex-shrink: 0;
}
.tabs {
display: flex; align-items: center; gap: 2px;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 2px;
}
.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: 1px solid transparent; color: var(--text-faint);
white-space: nowrap;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
cursor: pointer;
}
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.count { font-size: var(--text-2xs); opacity: 0.6; }
.right {
display: flex; align-items: center; gap: var(--sp-2);
margin-left: auto; flex-shrink: 0;
}
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
.search {
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 5px 10px 5px 28px;
color: var(--text-primary); font-size: var(--text-sm); width: 180px;
outline: none; transition: border-color var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.5; cursor: default; }
.icon-btn.spinning :global(svg) { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.sort-wrap, .filter-wrap { position: relative; }
.panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
min-width: 220px; background: var(--bg-raised);
border: 1px solid var(--border-base); border-radius: var(--radius-lg);
padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: fadeIn 0.1s ease both;
}
.panel-head { display: flex; align-items: center; padding: 6px 10px 4px; }
.panel-title {
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
font-weight: var(--weight-medium, 500);
}
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); padding: 4px 8px 8px;
}
.item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px 10px; border-radius: var(--radius-sm);
border: none; background: transparent; color: var(--text-muted);
font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left; gap: var(--sp-2);
transition: background var(--t-base), color var(--t-base);
}
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.item-active:hover { background: var(--accent-dim); }
.dir-toggle {
justify-content: flex-start; color: var(--text-secondary);
border-top: 1px solid var(--border-dim);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
margin-top: 2px; padding-top: 9px;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
File diff suppressed because it is too large Load Diff
-190
View File
@@ -1,190 +0,0 @@
<script lang="ts">
import { tick } from 'svelte'
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from 'phosphor-svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
import GeneralSettings from './sections/GeneralSettings.svelte'
import AppearanceSettings from './sections/AppearanceSettings.svelte'
import ReaderSettings from './sections/ReaderSettings.svelte'
import LibrarySettings from './sections/LibrarySettings.svelte'
import AutomationSettings from './sections/AutomationSettings.svelte'
import PerformanceSettings from './sections/PerformanceSettings.svelte'
import KeybindsSettings from './sections/KeybindsSettings.svelte'
import StorageSettings from './sections/StorageSettings.svelte'
import FoldersSettings from './sections/FoldersSettings.svelte'
import TrackingSettings from './sections/TrackingSettings.svelte'
import SecuritySettings from './sections/SecuritySettings.svelte'
import ContentSettings from './sections/ContentSettings.svelte'
import AboutSettings from './sections/AboutSettings.svelte'
import DevtoolsSettings from './sections/DevToolsSettings.svelte'
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
let { onclose, onOpenThemeEditor }: Props = $props()
type Tab = 'general'|'appearance'|'reader'|'library'|'automation'|'performance'|'keybinds'|'storage'|'folders'|'tracking'|'security'|'content'|'about'|'devtools'
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: 'general', label: 'General', icon: Gear },
{ id: 'appearance', label: 'Appearance', icon: PaintBrush },
{ id: 'reader', label: 'Reader', icon: Book },
{ id: 'library', label: 'Library', icon: Image },
{ id: 'automation', label: 'Automation', icon: Robot },
{ id: 'performance', label: 'Performance', icon: Sliders },
{ id: 'keybinds', label: 'Keybinds', icon: Keyboard },
{ id: 'storage', label: 'Storage', icon: HardDrives },
{ id: 'folders', label: 'Folders', icon: FolderSimple },
{ id: 'tracking', label: 'Tracking', icon: ListChecks },
{ id: 'security', label: 'Security', icon: Lock },
{ id: 'content', label: 'Content', icon: ShieldCheck },
{ id: 'about', label: 'About', icon: Info },
{ id: 'devtools', label: 'Dev Tools', icon: Wrench },
]
const anims = $derived(settingsState.settings.qolAnimations ?? true)
let tab: Tab = $state('general')
let prevTabIndex = $state(0)
let tabSlideDir = $state<'up'|'down'>('down')
let tabIconKey = $state(0)
let contentBodyEl: HTMLDivElement
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
function setTab(id: Tab) {
if (anims) {
const next = TABS.findIndex(t => t.id === id)
tabSlideDir = next > prevTabIndex ? 'down' : 'up'
prevTabIndex = next
tabIconKey++
}
tab = id
}
function close() { onclose?.() }
let listeningKey: keyof Keybinds | null = $state(null)
$effect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !listeningKey) { e.stopPropagation(); close() } }
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
})
$effect(() => {
if (!listeningKey) return
const capture = (e: KeyboardEvent) => {
e.preventDefault(); e.stopPropagation()
const bind = eventToKeybind(e)
if (!bind) return
updateSettings({ keybinds: { ...settingsState.settings.keybinds, [listeningKey!]: bind } })
listeningKey = null
}
window.addEventListener('keydown', capture, true)
return () => window.removeEventListener('keydown', capture, true)
})
let selectOpen: string | null = $state(null)
let closingSelect: string | null = $state(null)
const CLOSE_ANIM_MS = 120
function closeSelect() {
if (!selectOpen) return
closingSelect = selectOpen
selectOpen = null
setTimeout(() => { closingSelect = null }, CLOSE_ANIM_MS)
}
function toggleSelect(id: string) {
if (selectOpen === id) { closeSelect() }
else { closingSelect = null; selectOpen = id }
}
$effect(() => {
const handler = (e: MouseEvent) => {
if (!selectOpen) return
const t = e.target as HTMLElement
if (t.closest('.s-select') || t.closest('.s-select-menu')) return
closeSelect()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
})
</script>
<div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close() }}
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
<div class="s-modal" role="dialog" aria-label="Settings">
<div class="s-sidebar">
<p class="s-sidebar-title">Settings</p>
<nav>
{#each TABS as t}
<button class="s-nav-item" class:active={tab === t.id} class:anims onclick={() => setTab(t.id)}>
<span class="s-nav-icon"
class:slide-down={anims && tab === t.id && tabSlideDir === 'down'}
class:slide-up={anims && tab === t.id && tabSlideDir === 'up'}>
{#key anims && tab === t.id ? tabIconKey : 0}
<t.icon size={14} weight={tab === t.id ? 'regular' : 'light'} />
{/key}
</span>
<span>{t.label}</span>
</button>
{/each}
</nav>
</div>
<div class="s-content">
<div class="s-content-header">
<div class="s-content-header-left">
<span class="s-header-icon"
class:slide-down={anims && tabSlideDir === 'down'}
class:slide-up={anims && tabSlideDir === 'up'}>
{#key tabIconKey}
{#each TABS as t}
{#if t.id === tab}
<t.icon size={13} weight="light" />
{/if}
{/each}
{/key}
</span>
<p class="s-content-title">{TABS.find(t => t.id === tab)?.label}</p>
</div>
<button class="s-close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="s-content-body" bind:this={contentBodyEl}>
{#if tab === 'general'}
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'appearance'}
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
{:else if tab === 'reader'}
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'library'}
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'automation'}
<AutomationSettings />
{:else if tab === 'performance'}
<PerformanceSettings />
{:else if tab === 'keybinds'}
<KeybindsSettings bind:listeningKey />
{:else if tab === 'storage'}
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} />
{:else if tab === 'folders'}
<FoldersSettings />
{:else if tab === 'tracking'}
<TrackingSettings />
{:else if tab === 'security'}
<SecuritySettings {selectOpen} {toggleSelect} />
{:else if tab === 'content'}
<ContentSettings />
{:else if tab === 'about'}
<AboutSettings />
{:else if tab === 'devtools'}
<DevtoolsSettings />
{/if}
</div>
</div>
</div>
</div>
@@ -1,508 +0,0 @@
<script lang="ts">
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
import { settingsState, updateSettings } from '$lib/state/settings.svelte';
import type { CustomTheme, ThemeTokens } from "$lib/types/settings";
import { DEFAULT_THEME_TOKENS } from "$lib/types/settings";
interface Props {
editingId?: string | null;
onClose: () => void;
}
let { editingId = $bindable(null), onClose }: Props = $props();
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{ label: "Backgrounds", tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"] },
{ label: "Borders", tokens: ["border-dim", "border-base", "border-strong", "border-focus"] },
{ label: "Text", tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"] },
{ label: "Accent", tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"] },
{ label: "Semantic", tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"] },
];
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
"bg-void": "Void (deepest bg)",
"bg-base": "Base",
"bg-surface": "Surface",
"bg-raised": "Raised",
"bg-overlay": "Overlay",
"bg-subtle": "Subtle",
"border-dim": "Dim border",
"border-base": "Base border",
"border-strong": "Strong border",
"border-focus": "Focus ring",
"text-primary": "Primary text",
"text-secondary": "Secondary text",
"text-muted": "Muted text",
"text-faint": "Faint text",
"text-disabled": "Disabled text",
"accent": "Accent",
"accent-dim": "Accent dim",
"accent-muted": "Accent muted",
"accent-fg": "Accent foreground",
"accent-bright": "Accent bright",
"color-error": "Error",
"color-error-bg": "Error background",
"color-success": "Success",
"color-info": "Info",
"color-info-bg": "Info background",
};
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = settingsState.settings.customThemes.find(t => t.id === editingId);
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
}
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
}
const initial = loadInitial();
let themeName: string = $state(initial.name);
let tokens: ThemeTokens = $state(initial.tokens);
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
function saveCustomTheme(theme: CustomTheme) {
const existing = settingsState.settings.customThemes.findIndex(t => t.id === theme.id);
const next = [...settingsState.settings.customThemes];
if (existing >= 0) next[existing] = theme;
else next.push(theme);
updateSettings({ customThemes: next });
}
function deleteCustomTheme(id: string) {
updateSettings({ customThemes: settingsState.settings.customThemes.filter(t => t.id !== id) });
}
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
saveCustomTheme({ id, name, tokens: { ...tokens } });
updateSettings({ theme: id });
editingId = id;
saveStatus = "saved";
setTimeout(() => (saveStatus = "idle"), 1800);
}
function handleDelete() {
if (!editingId) { onClose(); return; }
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
deleteCustomTheme(editingId);
onClose();
}
async function handleExport() {
const data: CustomTheme = {
id: editingId ?? "custom:export",
name: themeName.trim() || "Untitled Theme",
tokens: { ...tokens },
};
const filename = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
const json = JSON.stringify(data, null, 2);
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: filename,
types: [{ description: "Theme JSON", accept: { "application/json": [".json"] } }],
});
const writable = await handle.createWritable();
await writable.write(json);
await writable.close();
} catch (e: any) {
if (e?.name === "AbortError") return;
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
}
function handleImport() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.onchange = async () => {
const file = inp.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
if (typeof data.name === "string") themeName = data.name;
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
importError = null;
} catch (e: any) {
importError = e.message ?? "Could not parse theme file";
setTimeout(() => (importError = null), 3000);
}
};
inp.click();
}
function resetToDefaults() { tokens = { ...DEFAULT_THEME_TOKENS }; }
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
</script>
<svelte:window onkeydown={onKey} />
<div class="backdrop" role="button" tabindex="-1" aria-label="Close theme editor" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div class="shell" role="dialog" aria-label="Theme editor" tabindex="0" style={toCssVars(tokens)} onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<header class="header">
<div class="header-left">
<button class="icon-btn" onclick={onClose} title="Close editor">
<ArrowLeft size={14} weight="bold" />
</button>
<input bind:value={themeName} class="name-input" placeholder="Theme name" maxlength={40} spellcheck={false} />
</div>
<div class="header-actions">
{#if importError}
<span class="import-err">{importError}</span>
{/if}
<button class="action-btn" onclick={handleImport} title="Import from JSON">
<UploadSimple size={13} /><span>Import</span>
</button>
<button class="action-btn" onclick={handleExport} title="Export as JSON">
<DownloadSimple size={13} /><span>Export</span>
</button>
<button class="action-btn ghost" onclick={resetToDefaults} title="Reset all to dark defaults">Reset</button>
{#if editingId}
<button class="action-btn danger" onclick={handleDelete} title="Delete theme">
<Trash size={13} />
</button>
{/if}
<button class="save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
<FloppyDisk size={13} /><span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
</button>
<button class="icon-btn" onclick={onClose} title="Close">
<X size={14} weight="bold" />
</button>
</div>
</header>
<div class="body">
<aside class="preview-pane">
<div class="pane-label">Live Preview</div>
<div class="preview-ui" style={toCssVars(tokens)}>
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots"><span></span><span></span><span></span></div>
<div class="prv-win-title">Moku</div>
</div>
<div class="prv-content">
<div class="prv-row">
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
</div>
<div class="prv-grid">
{#each Array(6) as _, i}
<div class="prv-card" class:active-card={i === 0}>
<div class="prv-cover"></div>
<div class="prv-card-line"></div>
</div>
{/each}
</div>
<div class="prv-reader"><div class="prv-page"></div></div>
<div class="prv-toast">
<div class="prv-toast-dot"></div>
<div class="prv-toast-lines">
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
</div>
</div>
</div>
</div>
</div>
<div class="swatches" style={toCssVars(tokens)}>
{#each ["bg-base","bg-surface","accent","accent-fg","text-primary","text-muted","color-error"] as v}
<div class="swatch" style="background:var(--{v})" title={v}></div>
{/each}
</div>
</aside>
<div class="editor-pane">
{#each TOKEN_GROUPS as group}
<div class="group">
<div class="group-label">{group.label}</div>
<div class="token-list">
{#each group.tokens as token}
<div class="token-row">
<label class="color-swatch" style="background:{tokens[token]}" title="Pick colour">
<input
type="color"
class="color-picker"
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0, 7)}
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
/>
</label>
<span class="token-name">{TOKEN_LABELS[token]}</span>
<span class="token-key">{token}</span>
<input
type="text"
class="hex-input"
value={tokens[token]}
spellcheck={false}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
}}
onblur={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) (e.target as HTMLInputElement).value = tokens[token];
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: 200;
display: flex; align-items: center; justify-content: center;
animation: backdropIn 0.14s ease both;
}
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
.shell {
width: calc(100% - var(--sp-12)); max-width: 1100px;
height: calc(100% - var(--sp-12)); max-height: 760px;
display: flex; flex-direction: column;
background: var(--bg-base);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
animation: shellIn 0.2s cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes shellIn {
from { transform: translateY(10px) scale(0.99); opacity: 0 }
to { transform: translateY(0) scale(1); opacity: 1 }
}
.header {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); padding: 0 var(--sp-4); height: 46px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-surface);
flex-shrink: 0;
}
.header-left { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-md);
color: var(--text-muted);
transition: color var(--t-base), background var(--t-base);
flex-shrink: 0;
}
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.name-input {
flex: 1; min-width: 0;
background: none; border: none; outline: none;
font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary);
border-bottom: 1px solid transparent;
padding: 3px 0;
transition: border-color var(--t-base);
}
.name-input:focus { border-color: var(--border-focus); }
.name-input::placeholder { color: var(--text-faint); }
.import-err {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--color-error); flex-shrink: 0;
}
.action-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.action-btn.ghost { border-color: transparent; }
.action-btn.ghost:hover { border-color: var(--border-dim); }
.action-btn.danger { color: var(--color-error); border-color: transparent; }
.action-btn.danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
.save-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim);
background: var(--accent-muted); color: var(--accent-fg);
cursor: pointer; flex-shrink: 0;
transition: filter var(--t-base), background var(--t-base);
}
.save-btn:hover { filter: brightness(1.12); }
.save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
.body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
.preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
background: var(--bg-void);
display: flex; flex-direction: column;
padding: var(--sp-4); gap: var(--sp-3);
}
.pane-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); flex-shrink: 0;
}
.preview-ui {
flex: 1; min-height: 0;
border-radius: var(--radius-lg); overflow: hidden;
border: 1px solid var(--border-base);
display: flex; background: var(--bg-void);
}
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
align-items: center; padding: var(--sp-3) 0; gap: var(--sp-2);
}
.prv-sb-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--text-faint); opacity: 0.4;
transition: background var(--t-base), opacity var(--t-base);
}
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.prv-titlebar {
height: 26px; flex-shrink: 0;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
display: flex; align-items: center; padding: 0 var(--sp-2); gap: var(--sp-2);
}
.prv-win-dots { display: flex; gap: var(--sp-1); }
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.prv-win-title { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); color: var(--text-faint); }
.prv-content {
flex: 1; overflow: hidden;
padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2);
background: var(--bg-base);
}
.prv-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.prv-bar { height: 3px; border-radius: 2px; }
.prv-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: var(--sp-1); flex-shrink: 0; }
.prv-card {
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-raised); overflow: hidden;
transition: border-color var(--t-base);
}
.prv-card.active-card { border-color: var(--accent); }
.prv-cover { height: 34px; background: var(--bg-overlay); }
.prv-card-line { height: 3px; margin: 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
.prv-reader {
flex: 1; min-height: 0;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
}
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
.prv-toast {
flex-shrink: 0;
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2);
border-radius: var(--radius-md);
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
}
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
.swatches { display: flex; gap: var(--sp-1); flex-wrap: wrap; flex-shrink: 0; }
.swatch { width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; }
.editor-pane {
flex: 1; overflow-y: auto;
padding: var(--sp-4) var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-6);
}
.group { display: flex; flex-direction: column; gap: var(--sp-1); }
.group-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint);
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid var(--border-dim);
}
.token-list { display: flex; flex-direction: column; gap: 1px; }
.token-row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 5px var(--sp-2); border-radius: var(--radius-md);
transition: background var(--t-base);
}
.token-row:hover { background: var(--bg-raised); }
.color-swatch {
width: 36px; height: 18px; border-radius: var(--radius-md);
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
cursor: pointer; position: relative; overflow: hidden; display: block;
}
.color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
.color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0; cursor: pointer; padding: 0; border: none;
}
.token-name { flex: 1; font-size: var(--text-xs); color: var(--text-secondary); }
.token-key {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); flex-shrink: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px;
}
.hex-input {
width: 82px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 3px var(--sp-2);
outline: none;
transition: border-color var(--t-base), color var(--t-base);
}
.hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
</style>
@@ -1,297 +0,0 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { getVersion } from '@tauri-apps/api/app'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import { autoBackupAppData } from '$lib/core/backup'
import { requestManager } from '$lib/request-manager'
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string }
type UpdatePhase = 'idle' | 'downloading' | 'launching' | 'ready' | 'error'
const IS_WINDOWS = navigator.userAgent.includes('Windows')
interface AboutServer { name: string; version: string; buildType: string; buildTime: number; github: string; discord: string }
interface AboutWebUI { channel: string; tag: string; updateTimestamp: number }
let appVersion = $state('…')
let releases = $state<ReleaseInfo[]>([])
let releasesLoading = $state(false)
let releasesError = $state<string | null>(null)
let expandedTag = $state<string | null>(null)
let updatePhase = $state<UpdatePhase>('idle')
let updateError = $state<string | null>(null)
let dlBytes = $state(0)
let dlTotal = $state<number | null>(null)
let targetTag = $state<string | null>(null)
let releasesLoaded = false
let serverInfo = $state<AboutServer | null>(null)
let webuiInfo = $state<AboutWebUI | null>(null)
$effect(() => {
getVersion().then(v => appVersion = v).catch(() => appVersion = 'unknown')
if (!releasesLoaded) { releasesLoaded = true; loadReleases() }
})
$effect(() => { loadServerInfo() })
$effect(() => {
let unlisten: (() => void) | undefined
listen<{ downloaded: number; total: number | null }>('update-progress', e => {
dlBytes = e.payload.downloaded; dlTotal = e.payload.total ?? null
}).then(fn => { unlisten = fn })
return () => unlisten?.()
})
$effect(() => {
let unlisten: (() => void) | undefined
listen('update-launching', () => { updatePhase = 'launching' }).then(fn => { unlisten = fn })
return () => unlisten?.()
})
async function loadReleases() {
releasesLoading = true; releasesError = null
try {
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Request timed out after 10s')), 10_000))
const all = await Promise.race([invoke<ReleaseInfo[]>('list_releases'), timeout])
releases = all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
} catch (e: any) {
releasesError = e instanceof Error ? e.message : String(e)
} finally { releasesLoading = false }
}
async function loadServerInfo() {
try {
const [s, w] = await Promise.all([
requestManager.meta.getAboutServer(),
requestManager.meta.getAboutWebUI(),
])
serverInfo = s
webuiInfo = w
} catch {}
}
function stripV(v: string) { return v.replace(/^v/, '') }
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion }
function parseSemver(v: string) { return stripV(v).split('.').map(Number) }
function compareSemver(a: string, b: string) {
const pa = parseSemver(a), pb = parseSemver(b)
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
return 0
}
const onLatestVersion = $derived((() => {
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === '…') return false
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name))
return compareSemver(appVersion, sorted[0].tag_name) >= 0
})())
function fmtDate(iso: string) {
if (!iso) return ''
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function fmtBuildTime(unix: number | string) {
if (!unix) return ''
return new Date(Number(unix) * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function fmtBytes(bytes: number) {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
}
function fmtProgress() {
return dlTotal
? `${fmtBytes(dlBytes)} / ${fmtBytes(dlTotal)} (${Math.round((dlBytes / dlTotal) * 100)}%)`
: fmtBytes(dlBytes)
}
async function installUpdate(release: ReleaseInfo) {
if (updatePhase === 'downloading') return
targetTag = release.tag_name; updatePhase = 'downloading'; updateError = null; dlBytes = 0; dlTotal = null
try {
if (IS_WINDOWS) {
await autoBackupAppData()
try { await invoke('kill_server') } catch {}
await invoke('download_and_install_update', { tag: release.tag_name })
updatePhase = 'ready'
} else {
await openUrl(release.html_url)
updatePhase = 'idle'; targetTag = null
}
} catch (e: any) {
updateError = e instanceof Error ? e.message : String(e)
updatePhase = 'error'
}
}
async function restartNow() { await invoke('restart_app') }
function cancelUpdate() { updatePhase = 'idle'; updateError = null; targetTag = null; dlBytes = 0; dlTotal = null }
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Moku</p>
<div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-1)">
<span class="s-label">A manga reader frontend for Suwayomi / Tachidesk.</span>
<span class="s-desc">Built with Tauri + Svelte.</span>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Version</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Installed</span><span class="s-desc">v{appVersion}</span></div>
<button class="s-btn" onclick={() => { releasesError = null; loadReleases() }} disabled={releasesLoading}>
{releasesLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{#if onLatestVersion}
<div class="s-row">
<span class="s-desc" style="color:var(--accent-fg)">✓ You're on the latest version.</span>
</div>
{/if}
{#if updatePhase === 'downloading' && IS_WINDOWS}
<div class="s-update-progress">
<div class="s-update-bar">
<div class="s-update-fill" style="width:{dlTotal ? Math.round((dlBytes / dlTotal) * 100) : 0}%"></div>
</div>
<div class="s-update-labels">
<span>Downloading {targetTag ?? 'update'}</span>
<span>{fmtProgress()}</span>
</div>
</div>
{/if}
{#if updatePhase === 'launching'}
<div class="s-update-ready">
<span class="s-update-ready-label">Launching installer for {targetTag}</span>
</div>
{/if}
{#if updatePhase === 'ready'}
<div class="s-update-ready">
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
<button class="s-btn s-btn-accent" onclick={restartNow}>Restart now</button>
<button class="s-btn-icon" onclick={cancelUpdate} title="Dismiss"></button>
</div>
{/if}
{#if updatePhase === 'error'}
<div class="s-row">
<span class="s-desc" style="color:var(--color-error)">{updateError}</span>
<button class="s-btn" onclick={cancelUpdate}>Dismiss</button>
</div>
{/if}
</div>
</div>
{#if serverInfo}
<div class="s-section">
<p class="s-section-title">Server</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Version</span>
<span class="s-desc">
{serverInfo.version}
{#if serverInfo.buildType}
<span class="s-release-badge">{serverInfo.buildType}</span>
{/if}
</span>
</div>
</div>
{#if serverInfo.buildTime}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Built</span>
<span class="s-desc">{fmtBuildTime(serverInfo.buildTime)}</span>
</div>
</div>
{/if}
{#if webuiInfo?.channel}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Channel</span>
<span class="s-desc">{webuiInfo.channel}</span>
</div>
</div>
{/if}
</div>
</div>
{/if}
<div class="s-section">
<p class="s-section-title">Releases</p>
<div class="s-section-body">
{#if releasesError}
<p class="s-empty" style="color:var(--color-error)">{releasesError}</p>
{:else if releasesLoading}
<p class="s-empty">Fetching releases…</p>
{:else if releases.length === 0}
<p class="s-empty">No releases found.</p>
{:else}
<div class="s-release-scroll">
{#each releases as release}
{@const isCurrent = isCurrentVersion(release.tag_name)}
{@const isExpanded = expandedTag === release.tag_name}
{@const isTarget = targetTag === release.tag_name}
{@const isInstalling = isTarget && updatePhase === 'downloading'}
<div class="s-release-row" class:current={isCurrent}>
<div class="s-release-header">
<div class="s-release-meta">
<span class="s-release-tag">{release.tag_name}</span>
{#if isCurrent}<span class="s-release-badge">installed</span>{/if}
{#if release.published_at}<span class="s-release-date">{fmtDate(release.published_at)}</span>{/if}
</div>
<div class="s-btn-row">
{#if release.body.trim()}
<button class="s-btn" onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
{isExpanded ? 'Hide' : 'Changelog'}
</button>
{/if}
{#if !isCurrent}
{#if IS_WINDOWS}
<button class="s-btn" class:s-btn-accent={!isInstalling}
disabled={updatePhase === 'downloading'} onclick={() => installUpdate(release)}>
{isInstalling ? 'Downloading…' : 'Install'}
</button>
{:else}
<button class="s-btn" onclick={() => installUpdate(release)}>Open on GitHub</button>
{/if}
{/if}
</div>
</div>
{#if isExpanded && release.body.trim()}
<div class="s-release-body">
<pre class="s-release-body pre">{release.body.trim()}</pre>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Links</p>
<div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
<a href="https://github.com/moku-project/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
{#if serverInfo?.github && serverInfo.github !== 'https://github.com/moku-project/Moku'}
<a href={serverInfo.github} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi GitHub →</a>
{/if}
{#if serverInfo?.discord && serverInfo.discord !== 'https://discord.gg/Jq3pwuNqPp'}
<a href={serverInfo.discord} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi Discord →</a>
{/if}
</div>
</div>
</div>
</div>
@@ -1,161 +0,0 @@
<script lang="ts">
import { Pencil, Trash, Plus } from 'phosphor-svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
interface Props {
selectOpen: string | null
closingSelect: string | null
toggleSelect: (id: string) => void
anims: boolean
onOpenThemeEditor?: (id?: string | null) => void
}
let { selectOpen, closingSelect, toggleSelect, anims, onOpenThemeEditor }: Props = $props()
const THEMES = [
{ id: 'original', label: 'Original', description: 'Default near-black', swatches: ['#101010','#151515','#a8c4a8','#f0efec'] },
{ id: 'dark', label: 'Dark', description: 'Darker base, sharper text', swatches: ['#080808','#111111','#bcd8bc','#ffffff'] },
{ id: 'light', label: 'Light', description: 'Warm off-white', swatches: ['#f4f2ee','#faf8f4','#2a5a2a','#1a1916'] },
{ id: 'midnight', label: 'Midnight', description: 'Deep blue-black tint', swatches: ['#0c1020','#101428','#a8b4e8','#eeeef8'] },
{ id: 'warm', label: 'Warm', description: 'Amber and sepia tones', swatches: ['#16130c','#1c1810','#e0b860','#f5f0e0'] },
]
const allThemeOptions = $derived([
...THEMES.map(t => ({ id: t.id, label: t.label })),
...(settingsState.settings.customThemes ?? []).map(t => ({ id: t.id, label: t.name })),
])
let triggerDark = $state<HTMLButtonElement>(null!)
let triggerLight = $state<HTMLButtonElement>(null!)
function deleteCustomTheme(id: string) {
updateSettings({ customThemes: (settingsState.settings.customThemes ?? []).filter(t => t.id !== id) })
if (settingsState.settings.theme === id) updateSettings({ theme: 'dark' })
}
</script>
<div class="s-panel">
<div class="s-section">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Match system theme</span>
<span class="s-desc">Automatically switch theme when your OS switches between light and dark</span>
</div>
<button class="s-toggle" class:on={settingsState.settings.systemThemeSync}
onclick={() => updateSettings({ systemThemeSync: !settingsState.settings.systemThemeSync })}
role="switch" aria-label="Match system theme" aria-checked={settingsState.settings.systemThemeSync}>
<span class="s-toggle-thumb"></span>
</button>
</div>
{#if settingsState.settings.systemThemeSync}
<div class="s-sync-pair">
<div class="s-sync-item">
<span class="s-sync-label">Dark theme</span>
<div class="s-select">
<button bind:this={triggerDark} class="s-select-btn" onclick={() => toggleSelect('sync-dark')}>
<span>{allThemeOptions.find(o => o.id === (settingsState.settings.systemThemeDark ?? 'dark'))?.label ?? 'Dark'}</span>
<svg class="s-select-caret" class:open={selectOpen === 'sync-dark'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'sync-dark' || closingSelect === 'sync-dark'}
<div class="s-select-menu" class:anims class:closing={closingSelect === 'sync-dark'}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (settingsState.settings.systemThemeDark ?? 'dark')}
onclick={() => { updateSettings({ systemThemeDark: opt.id }); toggleSelect('sync-dark') }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-sync-item">
<span class="s-sync-label">Light theme</span>
<div class="s-select">
<button bind:this={triggerLight} class="s-select-btn" onclick={() => toggleSelect('sync-light')}>
<span>{allThemeOptions.find(o => o.id === (settingsState.settings.systemThemeLight ?? 'light'))?.label ?? 'Light'}</span>
<svg class="s-select-caret" class:open={selectOpen === 'sync-light'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'sync-light' || closingSelect === 'sync-light'}
<div class="s-select-menu" class:anims class:closing={closingSelect === 'sync-light'}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (settingsState.settings.systemThemeLight ?? 'light')}
onclick={() => { updateSettings({ systemThemeLight: opt.id }); toggleSelect('sync-light') }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section">
<p class="s-section-title">Theme</p>
<div class="s-theme-grid">
{#each THEMES as theme}
{@const active = (settingsState.settings.theme ?? 'dark') === theme.id}
<div class="s-theme-card" class:active>
<button class="s-theme-card" style="border:none;padding:0;width:100%;display:block" onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
<div class="s-theme-preview">
<div class="s-theme-preview-bg" style="background:{theme.swatches[0]}">
<div class="s-theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
<div class="s-theme-preview-content">
<div class="s-theme-preview-accent" style="background:{theme.swatches[2]}"></div>
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}55"></div>
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
</div>
</div>
</div>
<div class="s-theme-info">
<span class="s-theme-name">{theme.label}</span>
<span class="s-theme-desc">{theme.description}</span>
</div>
{#if active}<span class="s-theme-check"></span>{/if}
</button>
</div>
{/each}
{#each settingsState.settings.customThemes ?? [] as custom}
{@const active = settingsState.settings.theme === custom.id}
<div class="s-theme-card" class:active>
<div class="s-theme-actions">
<button class="s-theme-action-btn edit" onclick={() => onOpenThemeEditor?.(custom.id)} title="Edit theme"><Pencil size={10} /></button>
<button class="s-theme-action-btn delete"
onclick={() => { if (confirm(`Delete theme "${custom.name}"?`)) deleteCustomTheme(custom.id) }}
title="Delete theme"><Trash size={10} /></button>
</div>
<button style="border:none;padding:0;width:100%;display:block;background:none;cursor:pointer"
onclick={() => updateSettings({ theme: custom.id })} title="Apply {custom.name}">
<div class="s-theme-preview">
<div class="s-theme-preview-bg" style="background:{custom.tokens['bg-base']}">
<div class="s-theme-preview-sidebar" style="background:{custom.tokens['bg-surface']}"></div>
<div class="s-theme-preview-content">
<div class="s-theme-preview-accent" style="background:{custom.tokens['accent']}"></div>
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}55"></div>
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}33;width:60%"></div>
</div>
</div>
</div>
<div class="s-theme-info">
<span class="s-theme-name">{custom.name}</span>
<span class="s-theme-desc" style="color:var(--accent-fg)">Custom</span>
</div>
</button>
{#if active}<span class="s-theme-check"></span>{/if}
</div>
{/each}
<button class="s-theme-card s-theme-new" onclick={() => onOpenThemeEditor?.(null)} title="Create a custom theme">
<Plus size={18} weight="light" />
<div class="s-theme-info">
<span class="s-theme-name">New Theme</span>
<span class="s-theme-desc">Create custom</span>
</div>
</button>
</div>
</div>
</div>
@@ -1,143 +0,0 @@
<script lang="ts">
import { ArrowCounterClockwise, LockSimple, Warning } from 'phosphor-svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import type { MangaPrefs } from '$lib/types/settings'
const DOWNLOAD_AHEAD_OPTIONS = [{ value: 0, label: 'Off' },{ value: 2, label: '2' },{ value: 5, label: '5' },{ value: 10, label: '10' }]
const MAX_KEEP_OPTIONS = [{ value: 0, label: 'Off' },{ value: 5, label: '5' },{ value: 10, label: '10' },{ value: 25, label: '25' }]
const DELETE_DELAY_OPTIONS = [{ value: 0, label: 'Now' },{ value: 24, label: '1 day' },{ value: 168, label: '1 week' }]
const REFRESH_INTERVAL_OPTIONS = [{ value: 'daily', label: 'Daily' },{ value: 'weekly', label: 'Weekly' },{ value: 'manual', label: 'Manual' }]
type GlobalDefaults = Omit<MangaPrefs, 'refreshInterval'> & { refreshInterval: 'daily' | 'weekly' | 'manual' }
const fallback: GlobalDefaults = {
autoDownload: false, downloadAhead: 0, maxKeepChapters: 0, deleteOnRead: false,
deleteDelayHours: 0, pauseUpdates: false, refreshInterval: 'weekly',
preferredScanlator: '', scanlatorFilter: [], scanlatorBlacklist: [],
scanlatorForce: false, autoDownloadScanlators: [],
}
function getGlobal<K extends keyof GlobalDefaults>(key: K): GlobalDefaults[K] {
return (settingsState.settings.automationDefaults as GlobalDefaults | undefined)?.[key] ?? fallback[key]
}
function setGlobal<K extends keyof GlobalDefaults>(key: K, value: GlobalDefaults[K]) {
updateSettings({ automationDefaults: { ...(settingsState.settings.automationDefaults ?? fallback), [key]: value } })
}
const enforceGlobal = $derived(settingsState.settings.automationEnforceGlobal ?? false)
const customCount = $derived(
Object.values(settingsState.settings.mangaPrefs ?? {}).filter(p => p && Object.keys(p).length > 0).length
)
let confirmReset = $state(false)
function resetAllCustoms() {
if (!confirmReset) { confirmReset = true; return }
updateSettings({ mangaPrefs: {} })
confirmReset = false
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Behaviour</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable automation</span><span class="s-desc">Allow per-series and global automation rules to run</span></div>
<button role="switch" aria-checked={settingsState.settings.automationEnabled ?? false} aria-label="Enable automation" class="s-toggle" class:on={settingsState.settings.automationEnabled ?? false} onclick={() => updateSettings({ automationEnabled: !(settingsState.settings.automationEnabled ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enforce global defaults</span><span class="s-desc">Ignore per-series overrides — all series use the global settings below</span></div>
<button role="switch" aria-checked={enforceGlobal} aria-label="Enforce global defaults" class="s-toggle" class:on={enforceGlobal} onclick={() => updateSettings({ automationEnforceGlobal: !enforceGlobal })}><span class="s-toggle-thumb"></span></button>
</label>
{#if enforceGlobal}
<div class="s-banner s-banner-info enforce-banner"><LockSimple size={12} weight="fill" /><span>Per-series overrides are paused.</span></div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Global Defaults</p>
<div class="s-section-body">
<p class="sub-head">Downloads</p>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Auto-download new chapters</span><span class="s-desc">Queue new chapters when a series refreshes</span></div>
<button role="switch" aria-checked={getGlobal('autoDownload')} aria-label="Auto-download new chapters" class="s-toggle" class:on={getGlobal('autoDownload')} onclick={() => setGlobal('autoDownload', !getGlobal('autoDownload'))}><span class="s-toggle-thumb"></span></button>
</div>
<div class="s-row chip-row">
<div class="s-row-info"><span class="s-label">Download ahead</span><span class="s-desc">Pre-fetch chapters while reading</span></div>
<div class="chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('downloadAhead') === opt.value} onclick={() => setGlobal('downloadAhead', opt.value)}>{opt.label}</button>
{/each}
</div>
</div>
<div class="s-row chip-row">
<div class="s-row-info"><span class="s-label">Max chapters to keep</span><span class="s-desc">Delete oldest downloads when limit is exceeded</span></div>
<div class="chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('maxKeepChapters') === opt.value} onclick={() => setGlobal('maxKeepChapters', opt.value)}>{opt.label}</button>
{/each}
</div>
</div>
<p class="sub-head sub-head-rule">On Read</p>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Delete after reading</span><span class="s-desc">Remove download when a chapter is marked read</span></div>
<button role="switch" aria-checked={getGlobal('deleteOnRead')} aria-label="Delete after reading" class="s-toggle" class:on={getGlobal('deleteOnRead')} onclick={() => setGlobal('deleteOnRead', !getGlobal('deleteOnRead'))}><span class="s-toggle-thumb"></span></button>
</div>
{#if getGlobal('deleteOnRead')}
<div class="s-row chip-row sub-row">
<span class="s-label">Delete delay</span>
<div class="chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('deleteDelayHours') === opt.value} onclick={() => setGlobal('deleteDelayHours', opt.value)}>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<p class="sub-head sub-head-rule">Updates</p>
<div class="s-row chip-row">
<div class="s-row-info"><span class="s-label">Default refresh interval</span><span class="s-desc">How often series check for new chapters by default</span></div>
<div class="chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('refreshInterval') === opt.value} onclick={() => setGlobal('refreshInterval', opt.value as GlobalDefaults['refreshInterval'])}>{opt.label}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Custom Overrides</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Series with custom rules</span><span class="s-desc">Per-series settings set via the series automation panel</span></div>
<span class="s-pill" class:on={customCount > 0}>{customCount}</span>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reset all custom rules</span><span class="s-desc">Revert every series to the global defaults above</span></div>
{#if confirmReset}
<div class="s-btn-row">
<button class="s-btn s-btn-danger" onclick={resetAllCustoms}><Warning size={11} weight="fill" /> Confirm reset</button>
<button class="s-btn" onclick={() => confirmReset = false}>Cancel</button>
</div>
{:else}
<button class="s-btn" disabled={customCount === 0} onclick={resetAllCustoms}><ArrowCounterClockwise size={11} /> Reset</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.enforce-banner { display: flex; align-items: center; gap: var(--sp-2); }
.sub-head { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-widest); text-transform: uppercase; color: var(--text-faint); margin: 0; padding: var(--sp-2) var(--sp-4) 0; }
.sub-head-rule { border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); margin-top: var(--sp-1); }
.chip-row { align-items: flex-start; padding-top: 8px; padding-bottom: 8px; }
.chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.sub-row { padding-left: calc(var(--sp-4) + var(--sp-2)); border-left: 2px solid var(--border-dim); }
</style>
@@ -1,212 +0,0 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { requestManager } from '$lib/request-manager'
import { platformService } from '$lib/platform-service'
import type { ContentLevel } from '$lib/types/settings'
import type { Source } from '$lib/types'
let contentSources = $state<Source[]>([])
let contentSourcesLoading = $state(false)
let sourceSearch = $state('')
$effect(() => {
if (settingsState.settings.sourceOverridesEnabled && contentSources.length === 0 && !contentSourcesLoading)
loadContentSources()
})
async function loadContentSources() {
contentSourcesLoading = true
try {
const d = await requestManager.extensions.getSources()
contentSources = d.filter(s => s.id !== '0')
} catch (e) { console.error(e) }
finally { contentSourcesLoading = false }
}
function toggleSourceAllowed(ids: string[]) {
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
const allAllowed = ids.every(id => allowed.includes(id))
if (allAllowed) {
updateSettings({ nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)) })
} else {
updateSettings({
nsfwAllowedSourceIds: [...allowed.filter(x => !ids.includes(x)), ...ids],
nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)),
})
}
}
function toggleSourceBlocked(ids: string[]) {
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
const allBlocked = ids.every(id => blocked.includes(id))
if (allBlocked) {
updateSettings({ nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)) })
} else {
updateSettings({
nsfwBlockedSourceIds: [...blocked.filter(x => !ids.includes(x)), ...ids],
nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)),
})
}
}
interface ContentSourceGroup { name: string; iconUrl: string; isNsfw: boolean; sources: Source[] }
const contentSourcesFiltered = $derived.by(() => {
const q = sourceSearch.trim().toLowerCase()
const filtered = q
? contentSources.filter(s => s.displayName.toLowerCase().includes(q) || s.lang.toLowerCase().includes(q))
: contentSources
const map = new Map<string, ContentSourceGroup>()
for (const s of filtered) {
if (!map.has(s.name)) map.set(s.name, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] })
map.get(s.name)!.sources.push(s)
}
return Array.from(map.values())
})
const LEVELS: { value: ContentLevel; label: string; desc: string }[] = [
{ value: 'strict', label: 'Strict', desc: 'Hides all adult, sexual, and graphic violent content' },
{ value: 'moderate', label: 'Moderate', desc: 'Allows violence and gore, filters sexual content' },
{ value: 'unrestricted', label: 'Unrestricted', desc: 'No content filtering applied' },
]
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Content Level</p>
<div class="s-section-body">
<div class="s-row" style="border-bottom: none; padding-bottom: 0;">
<span class="s-desc">Controls what content is visible across library, search, and discover.</span>
</div>
<div class="s-level-group">
{#each LEVELS as lvl}
{@const active = settingsState.settings.contentLevel === lvl.value}
<button class="s-level-btn" class:active onclick={() => updateSettings({ contentLevel: lvl.value })}>
<span class="s-level-dot" class:active></span>
<div class="s-level-text">
<span class="s-level-label">{lvl.label}</span>
<span class="s-level-desc">{lvl.desc}</span>
</div>
</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Source Overrides</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Per-source overrides</span>
<span class="s-desc">Allow a source through even if flagged NSFW, or always block it. Allowed sources still respect the active content level.</span>
</div>
<button
role="switch"
aria-checked={settingsState.settings.sourceOverridesEnabled}
aria-label="Enable source overrides"
class="s-toggle"
class:on={settingsState.settings.sourceOverridesEnabled}
onclick={() => updateSettings({ sourceOverridesEnabled: !settingsState.settings.sourceOverridesEnabled })}
><span class="s-toggle-thumb"></span></button>
</label>
{#if settingsState.settings.sourceOverridesEnabled}
<div class="s-search-wrap">
<input class="s-input full" placeholder="Filter sources…" bind:value={sourceSearch} />
</div>
{#if contentSourcesLoading}
<p class="s-empty">Loading sources…</p>
{:else if contentSources.length === 0}
<p class="s-empty">No sources found — check your server connection.</p>
{:else}
<div class="s-source-list">
{#each contentSourcesFiltered as group (group.name)}
{@const ids = group.sources.map(s => s.id)}
{@const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []}
{@const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []}
{@const isAllowed = ids.every(id => allowed.includes(id))}
{@const isBlocked = ids.every(id => blocked.includes(id))}
<div class="s-source-row" class:allowed={isAllowed} class:blocked={isBlocked}>
<img src={platformService.thumbUrl(group.iconUrl)} alt="" class="s-source-icon" loading="lazy" decoding="async" />
<div class="s-source-info">
<span class="s-source-name">{group.name}</span>
<span class="s-source-meta">
{group.sources[0].isNsfw ? 'NSFW · ' : ''}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()}
</span>
</div>
<div class="s-source-actions">
<button class="s-source-action-btn" class:allow={isAllowed} onclick={() => toggleSourceAllowed(ids)}>Allow</button>
<button class="s-source-action-btn" class:block={isBlocked} onclick={() => toggleSourceBlocked(ids)}>Block</button>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
<style>
.s-level-group {
display: flex;
flex-direction: column;
padding: var(--sp-2) var(--sp-4) var(--sp-3);
gap: var(--sp-1);
}
.s-level-btn {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 10px var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-surface);
cursor: pointer;
text-align: left;
transition: border-color var(--t-base), background var(--t-base);
width: 100%;
}
.s-level-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.s-level-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); }
.s-level-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
background: none;
flex-shrink: 0;
transition: border-color var(--t-base), background var(--t-base);
}
.s-level-dot.active { border-color: var(--accent); background: var(--accent); }
.s-level-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.s-level-label {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.3;
}
.s-level-btn.active .s-level-label { color: var(--accent-fg); }
.s-level-desc {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
}
.s-level-btn.active .s-level-desc { color: var(--accent-fg); opacity: 0.7; }
</style>
@@ -1,241 +0,0 @@
<script lang="ts">
import ThreeDCard from '$lib/components/shared/manga/ThreeDCard.svelte'
import { appState } from '$lib/state/app.svelte'
import { toast } from '$lib/state/notifications.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { cache } from '$lib/core/cache/queryCache'
import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from '$lib/core/auth'
import { invoke } from '@tauri-apps/api/core'
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null }
let perfSnapshot = $state<PerfSnapshot | null>(null)
let splashTriggered = $state(false)
let expOpen = $state(false)
let appVersion = $state('…')
let helloAvailable = $state<boolean | null>(null)
let helloBusy = $state(false)
let authStatus = $state<UiAuthDebugStatus | null>(null)
let authRefreshBusy = $state(false)
$effect(() => {
import('@tauri-apps/api/app').then(m => m.getVersion()).then(v => appVersion = v).catch(() => {})
refreshPerfMetrics()
refreshAuthStatus()
invoke<boolean>('windows_hello_available').then(v => helloAvailable = v).catch(() => helloAvailable = false)
const timer = setInterval(() => refreshAuthStatus(), 1000)
return () => clearInterval(timer)
})
function refreshAuthStatus() { authStatus = getUiAuthDebugStatus() }
function fmtCountdown(ms: number | null): string {
if (ms === null) return '—'
if (ms <= 0) return 'expired'
const total = Math.floor(ms / 1000)
const month = 30 * 24 * 60 * 60
const day = 24 * 60 * 60
const hour = 60 * 60
const minute = 60
const months = Math.floor(total / month)
const days = Math.floor((total % month) / day)
const hours = Math.floor(total / 3600)
const remainingHours = Math.floor((total % day) / hour)
const mins = Math.floor((total % hour) / minute)
const secs = total % 60
if (months > 0) return days > 0 ? `${months}mo ${days}d` : `${months}mo`
if (days > 0) return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`
if (hours > 0) return `${hours}h ${mins}m ${secs}s`
if (mins > 0) return `${mins}m ${secs}s`
return `${secs}s`
}
function fmtTime(ts: number | null): string {
if (ts === null) return '—'
return new Date(ts).toLocaleString([], { dateStyle: 'medium', timeStyle: 'medium' })
}
async function forceTokenRefresh() {
authRefreshBusy = true
try {
const token = await refreshUiAccessToken(true)
toast({
kind: token ? 'success' : 'info',
title: 'UI auth refresh',
body: token ? 'Refresh succeeded' : 'No refreshed token available',
})
} catch (e: any) {
toast({ kind: 'error', title: 'UI auth refresh', body: String(e?.message ?? e) })
} finally {
authRefreshBusy = false
refreshAuthStatus()
}
}
function refreshPerfMetrics() {
let entries = 0, oldest: number | null = null, newest: number | null = null
const foundKeys: string[] = []
const checkKey = (k: string) => {
const age = cache.ageOf(k)
if (age !== undefined) {
entries++
foundKeys.push(k)
const ts = Date.now() - age
if (oldest === null || ts < oldest) oldest = ts
if (newest === null || ts > newest) newest = ts
}
}
['library', 'sources', 'popular'].forEach(checkKey)
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest }
}
function fmtAge(ts: number | null) {
if (ts === null) return '—'
const secs = Math.floor((Date.now() - ts) / 1000)
if (secs < 60) return `${secs}s ago`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ago`
return `${Math.floor(mins / 60)}h ago`
}
function triggerSplash() {
splashTriggered = true
setTimeout(() => splashTriggered = false, 200)
;(window as any).__mokuShowSplash?.()
}
async function testWindowsHello() {
helloBusy = true
try {
await invoke('windows_hello_authenticate', { reason: 'Moku devtools test' })
toast({ kind: 'success', title: 'Windows Hello', body: 'Verified successfully' })
} catch (e: any) {
toast({ kind: 'error', title: 'Windows Hello', body: String(e) })
} finally { helloBusy = false }
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Toasts</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
<div class="s-dev-pill-group">
{#each (['success', 'error', 'info', 'download'] as const) as kind (kind)}
{@const label = kind === 'success' ? 'S' : kind === 'error' ? 'E' : kind === 'info' ? 'I' : 'D'}
{@const title = kind === 'success' ? 'Library updated' : kind === 'error' ? 'Could not reach server' : kind === 'info' ? 'Already up to date' : 'Download complete'}
{@const body = kind === 'success' ? '3 new chapters across 2 series' : kind === 'error' ? 'Connection refused on port 4567' : kind === 'info' ? 'No new chapters found' : 'Berserk · Ch. 372 ready to read'}
<button class="s-dev-pill {kind}" onclick={() => toast({ kind, title, body })}>{label}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Previews</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Idle splash</span><span class="s-desc">Dismiss with any click or key</span></div>
<button class="s-btn" class:s-btn-accent={splashTriggered} onclick={triggerSplash}>Show</button>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Biometrics</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Windows Hello</span>
<span class="s-desc">Available: {helloAvailable === null ? '…' : helloAvailable ? 'yes' : 'no'}</span>
</div>
<button class="s-btn" disabled={!helloAvailable || helloBusy} onclick={testWindowsHello}>
{helloBusy ? '…' : 'Test'}
</button>
</div>
</div>
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => expOpen = !expOpen} aria-expanded={expOpen}>
<span class="s-label">Experimental</span>
<svg class="s-collapsible-caret" class:open={expOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if expOpen}
<div class="s-collapsible-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
<span class="s-desc">3D tilt cards — hover to preview</span>
<div style="display:flex;gap:var(--sp-3)">
{#each [{ title: 'Berserk', sub: 'Ch. 372', hue: '265' }, { title: 'Vinland Saga', sub: 'Ch. 208', hue: '200' }, { title: 'Dungeon Meshi', sub: 'Ch. 97', hue: '140' }] as card (card.title)}
<ThreeDCard>
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
<span style="font-size:10px;color:var(--text-faint)">{card.sub}</span>
</div>
</ThreeDCard>
{/each}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section">
<p class="s-section-title">Runtime</p>
<div class="s-section-body">
<div class="s-dev-grid">
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history.length} entries</span>
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
</div>
<div class="s-row">
<div class="s-row-info">
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<span class="s-desc">{perfSnapshot.cacheKeys.join(', ')}</span>
<span class="s-desc">Oldest: {fmtAge(perfSnapshot.oldestEntryMs)} · Newest: {fmtAge(perfSnapshot.newestEntryMs)}</span>
{/if}
</div>
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh cache stats"></button>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Auth (UI Login)</p>
<div class="s-section-body">
<div class="s-dev-grid">
<span class="s-dev-key">Mode</span> <span class="s-dev-val">{authStatus?.mode ?? '—'}</span>
<span class="s-dev-key">Session</span> <span class="s-dev-val">{authStatus?.hasSession ? 'present' : 'none'}</span>
<span class="s-dev-key">Refresh token</span> <span class="s-dev-val">{authStatus?.hasRefreshToken ? 'present' : 'none'}</span>
<span class="s-dev-key">Access expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.accessExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.refreshExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh window</span> <span class="s-dev-val">{authStatus?.shouldRefreshSoon ? 'open' : 'not yet'}</span>
<span class="s-dev-key">Refresh in-flight</span> <span class="s-dev-val">{authStatus?.refreshInFlight ? 'yes' : 'no'}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-desc">Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)}</span>
<span class="s-desc">Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)}</span>
<span class="s-desc">Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry</span>
</div>
<div class="s-btn-row">
<button class="s-btn" onclick={refreshAuthStatus}>Refresh</button>
<button class="s-btn s-btn-accent" onclick={forceTokenRefresh}
disabled={authRefreshBusy || authStatus?.mode !== 'UI_LOGIN' || !authStatus?.hasRefreshToken}>
{authRefreshBusy ? 'Refreshing…' : 'Force refresh'}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -1,368 +0,0 @@
<script lang="ts">
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from 'phosphor-svelte'
import { getAdapter } from '$lib/request-manager'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import type { Category } from '$lib/types'
let categories = $state<Category[]>([])
let catsLoading = $state(false)
let catsError = $state<string | null>(null)
let newFolderName = $state('')
let editingId = $state<number | null>(null)
let editingName = $state('')
let dragStrId = $state<string | null>(null)
let dragOverStrId = $state<string | null>(null)
let dropPosition = $state<'above' | 'below' | null>(null)
const completedCat = $derived(categories.find(c => c.name === 'Completed' && c.id !== 0) ?? null)
const completedId = $derived(completedCat ? String(completedCat.id) : null)
const sortedCatIds = $derived(categories.filter(c => c.id !== 0).map(c => String(c.id)))
const orderedAllIds = $derived.by(() => {
const order = settingsState.settings.libraryPinnedTabOrder ?? []
const allIds = ['library', 'downloaded', ...sortedCatIds]
const known = new Set(allIds)
return [...new Set([...order.filter(id => known.has(id)), ...allIds])]
})
function isHidden(id: string) {
return (settingsState.settings.hiddenLibraryTabs ?? []).includes(id)
}
function toggleHidden(id: string) {
const current = settingsState.settings.hiddenLibraryTabs ?? []
updateSettings({ hiddenLibraryTabs: current.includes(id) ? current.filter(x => x !== id) : [...current, id] })
}
async function loadCategories() {
catsLoading = true; catsError = null
try {
const fresh = await getAdapter().getCategories()
const zeroCat = categories.filter(c => c.id === 0)
const merged = fresh.filter((c: Category) => c.id !== 0).map((f: Category) => {
const existing = categories.find(c => c.id === f.id)
return existing ? { ...existing, ...f } : f
})
categories = [...zeroCat, ...merged]
} catch (e: any) {
catsError = e?.message ?? 'Failed to load folders'
} finally { catsLoading = false }
}
async function createFolder() {
const name = newFolderName.trim()
if (!name) return
try {
const cat = await getAdapter().createCategory({ name })
categories = [...categories, cat]
newFolderName = ''
} catch (e: any) { catsError = e?.message ?? 'Failed to create folder' }
}
function startEdit(id: number, name: string) { editingId = id; editingName = name }
async function commitEdit() {
if (editingId !== null && editingName.trim()) {
try {
await getAdapter().updateCategory({ id: editingId, name: editingName.trim() })
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)
} catch (e: any) { catsError = e?.message ?? 'Failed to rename' }
}
editingId = null; editingName = ''
}
async function deleteFolder(id: number) {
try {
await getAdapter().deleteCategory({ id })
categories = categories.filter(c => c.id !== id)
} catch (e: any) { catsError = e?.message ?? 'Failed to delete folder' }
}
async function toggleCategoryFlag(id: number, flag: 'includeInUpdate' | 'includeInDownload') {
const cat = categories.find(c => c.id === id)
if (!cat) return
const next = !cat[flag]
categories = categories.map(c => c.id === id ? { ...c, [flag]: next } : c)
try {
await getAdapter().updateCategories({ ids: [id], patch: { [flag]: next ? 'INCLUDE' : 'EXCLUDE' } })
} catch (e: any) {
categories = categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)
catsError = e?.message ?? 'Failed to update folder'
}
}
function applyReorder(fromStrId: string, toStrId: string) {
const catIds = categories.filter(c => c.id !== 0).map(c => String(c.id))
const allIds = ['library', 'downloaded', ...catIds]
const current = settingsState.settings.libraryPinnedTabOrder ?? []
const base = [...new Set([...current.filter(id => allIds.includes(id)), ...allIds])]
const fromIdx = base.indexOf(fromStrId)
const toIdx = base.indexOf(toStrId)
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return
base.splice(fromIdx, 1)
base.splice(toIdx, 0, fromStrId)
updateSettings({ libraryPinnedTabOrder: base })
const fromNumId = Number(fromStrId)
if (!isNaN(fromNumId) && fromStrId !== 'library' && fromStrId !== 'downloaded') {
const zeroCat = categories.filter(c => c.id === 0)
const sortable = categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order)
const sFromIdx = sortable.findIndex(c => c.id === fromNumId)
const sToIdx = sortable.findIndex(c => String(c.id) === toStrId)
if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
const reordered = [...sortable]
const [moved] = reordered.splice(sFromIdx, 1)
reordered.splice(sToIdx, 0, moved)
categories = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
getAdapter().updateCategoryOrder({ id: fromNumId, position: sToIdx + 1 })
.then((updated: Category[]) => {
categories = [
...zeroCat,
...updated.sort((a: Category, b: Category) => a.order - b.order).map((fresh: Category) => {
const existing = categories.find(c => c.id === fresh.id)
return existing ? { ...existing, ...fresh } : fresh
}),
]
})
.catch(async (e: any) => {
catsError = e?.message ?? 'Failed to reorder'
await loadCategories()
})
}
}
}
function onDragStart(e: DragEvent, id: string) {
dragStrId = id
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id) }
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
if (dragStrId === id) return
dragOverStrId = id
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
dropPosition = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
}
function onDrop(e: DragEvent, id: string) {
e.preventDefault()
if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id)
dragStrId = null; dragOverStrId = null; dropPosition = null
}
function onDragEnd() { dragStrId = null; dragOverStrId = null; dropPosition = null }
function focusInput(node: HTMLElement) { node.focus() }
$effect(() => {
if (!categories.length && !catsLoading) loadCategories()
})
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Manage Folders</p>
<div class="s-section-body">
<div class="s-row">
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
</div>
{#if catsError}
<div class="s-banner s-banner-error">{catsError}</div>
{/if}
{#if catsLoading}
<p class="s-empty">Loading folders…</p>
{:else}
<div class="s-folder-list" class:is-dragging={dragStrId !== null}>
{#each orderedAllIds as id}
{@const isBuiltin = id === 'library' || id === 'downloaded'}
{@const isCompleted = id === completedId}
{@const cat = isBuiltin ? null : (categories.find(c => String(c.id) === id) ?? null)}
{@const hidden = isHidden(id)}
{#if isBuiltin || cat}
<div
class="s-folder-row"
class:dragging={dragStrId === id}
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === 'above'}
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === 'below'}
draggable="true"
ondragstart={(e) => onDragStart(e, id)}
ondragover={(e) => onDragOver(e, id)}
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null } }}
ondrop={(e) => onDrop(e, id)}
ondragend={onDragEnd}
>
{#if isCompleted}
<span class="s-folder-icon">
<CheckSquare size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if isBuiltin}
<span class="s-folder-icon">
{#if id === 'library'}<BookmarkSimple size={14} weight="light" />{:else}<DownloadSimple size={14} weight="light" />{/if}
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{id === 'library' ? 'Saved' : 'Downloaded'}</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if cat}
{#if editingId === cat.id}
<input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') { editingId = null } }}
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<div class="s-folder-identity" draggable="true"
ondragstart={(e) => onDragStart(e, id)}
ondragend={onDragEnd}>
<span class="s-folder-icon">
<FolderSimple size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</span>
</div>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<div class="s-folder-actions">
<button class="s-btn-icon"
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
onclick={() => updateSettings({ defaultLibraryCategoryId: (settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
title={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? 'Remove as default folder' : 'Set as default folder'}>
<Star size={13} weight={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? 'fill' : 'light'} />
</button>
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show in library' : 'Hide from library'}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon"
class:active={cat.includeInUpdate !== false}
class:inactive={cat.includeInUpdate === false}
onclick={() => toggleCategoryFlag(cat.id, 'includeInUpdate')}
title={cat.includeInUpdate !== false ? 'Included in updates — click to exclude' : 'Excluded from updates — click to include'}>
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon"
class:active={cat.includeInDownload !== false}
class:inactive={cat.includeInDownload === false}
onclick={() => toggleCategoryFlag(cat.id, 'includeInDownload')}
title={cat.includeInDownload !== false ? 'Included in auto-downloads — click to exclude' : 'Excluded from auto-downloads — click to include'}>
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? 'bold' : 'light'} />
</button>
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
<Trash size={12} weight="light" />
</button>
</div>
{/if}
{/if}
</div>
{/if}
{/each}
</div>
{#if categories.filter(c => c.id !== 0 && c.name !== 'Completed').length === 0}
<p class="s-empty">No custom folders yet. Create one below.</p>
{/if}
{/if}
<div class="s-folder-create">
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
onkeydown={(e) => e.key === 'Enter' && createFolder()} />
<button class="s-btn s-btn-accent" onclick={createFolder} disabled={!newFolderName.trim()}>
<Plus size={13} weight="bold" /> Create
</button>
</div>
</div>
</div>
</div>
<style>
.s-folder-list { display: contents; }
.s-folder-list.is-dragging,
.s-folder-list.is-dragging * { user-select: none; -webkit-user-select: none; }
.s-folder-row { transition: opacity 0.15s, background 0.1s; position: relative; }
.s-folder-row.dragging { opacity: 0.35; }
.s-folder-row.drop-above::before,
.s-folder-row.drop-below::after {
content: '';
position: absolute;
left: 8px; right: 8px;
height: 2px;
background: var(--color-success, #4ade80);
border-radius: 2px;
pointer-events: none;
z-index: 10;
}
.s-folder-row.drop-above::before { top: -1px; }
.s-folder-row.drop-below::after { bottom: -1px; }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-icon {
display: grid;
flex-shrink: 0;
}
.s-folder-icon > :global(*) { grid-area: 1 / 1; transition: opacity 0.12s; }
.s-folder-icon > :global(*:last-child) { opacity: 0; }
.s-folder-row:hover .s-folder-icon > :global(*:first-child) { opacity: 0; }
.s-folder-row:hover .s-folder-icon > :global(*:last-child) { opacity: 1; }
.s-folder-name {
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
.s-folder-name:hover { text-decoration: underline; text-underline-offset: 3px; }
.s-folder-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; flex-shrink: 0; }
.s-folder-badge {
font-size: 10px;
letter-spacing: 0.04em;
color: var(--text-faint);
background: var(--bg-subtle);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
margin-left: 6px;
}
.s-btn-icon.active { color: var(--accent, #6c8ef5); }
.s-btn-icon.inactive { color: var(--color-error, #f87171); opacity: 0.75; }
.s-btn-icon.inactive:hover { opacity: 1; }
.s-btn-icon.muted { color: var(--text-faint); opacity: 0.5; }
.s-btn-icon-lock { opacity: 0.25; cursor: not-allowed; }
.s-btn-icon-lock:hover { opacity: 0.25; color: inherit; }
</style>
@@ -1,204 +0,0 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { platformService } from '$lib/platform-service'
interface Props {
selectOpen: string | null
closingSelect: string | null
toggleSelect: (id: string) => void
anims: boolean
}
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props()
let triggerIdleTimeout = $state<HTMLButtonElement>(null!)
let serverAdvancedOpen = $state(false)
async function pickServerBinary() {
const path = await platformService.pickFolder()
if (path) updateSettings({ serverBinary: path })
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Interface Scale</p>
<div class="s-section-body">
<div class="s-slider-row">
<input type="range" min={50} max={200} step={5}
value={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
class="s-slider" />
<input type="number" min={50} max={200} step={1} class="s-slider-val"
value={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }) }}
onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = '50' } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = '200' } }}
/>
<span class="s-slider-unit">%</span>
<button class="s-btn-icon" onclick={() => updateSettings({ uiZoom: 1.0 })} disabled={(settingsState.settings.uiZoom ?? 1.0) === 1.0} title="Reset to 100%"></button>
</div>
<div class="s-presets">
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="s-preset" class:active={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Server</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Server URL</span>
<span class="s-desc">Base URL of your Suwayomi instance</span>
</div>
<div class="srv-url-group">
<input class="s-input" value={settingsState.settings.serverUrl ?? 'http://localhost:4567'}
oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })}
placeholder="http://localhost:4567" spellcheck="false" />
<button class="srv-adv-btn" class:open={serverAdvancedOpen}
onclick={() => serverAdvancedOpen = !serverAdvancedOpen}
title="Server launch options" aria-expanded={serverAdvancedOpen}>
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={settingsState.settings.autoStartServer} aria-label="Auto-start server"
class="s-toggle" class:on={settingsState.settings.autoStartServer}
onclick={() => updateSettings({ autoStartServer: !settingsState.settings.autoStartServer })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Suwayomi Web UI</span><span class="s-desc">Enable the built-in Suwayomi web interface alongside Moku</span></div>
<button role="switch" aria-checked={settingsState.settings.suwayomiWebUI ?? false} aria-label="Suwayomi Web UI"
class="s-toggle" class:on={settingsState.settings.suwayomiWebUI ?? false}
onclick={() => updateSettings({ suwayomiWebUI: !(settingsState.settings.suwayomiWebUI ?? false) })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
{#if serverAdvancedOpen}
<div class="srv-adv-panel">
<div class="srv-adv-row">
<div class="s-row-info">
<span class="s-label">Server binary</span>
<span class="s-desc">Path to server executable — leave blank to use bundled</span>
</div>
<div class="srv-file-group">
<input class="s-input srv-path-input" value={settingsState.settings.serverBinary ?? ''}
oninput={(e) => updateSettings({ serverBinary: e.currentTarget.value })}
placeholder="auto-detect" spellcheck="false" />
<button class="srv-file-btn" onclick={pickServerBinary} title="Browse">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1.5 4.5h11v7a1 1 0 01-1 1h-9a1 1 0 01-1-1v-7z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M1.5 4.5l1.8-2.5h3.4l1.3 2.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Window</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
<div class="s-seg">
{#each [['ask','Ask'],['tray','Tray'],['quit','Quit']] as [v, l]}
<button class="s-seg-btn" class:active={(settingsState.settings.closeAction ?? 'ask') === v} onclick={() => updateSettings({ closeAction: v as 'ask' | 'tray' | 'quit' })}>{l}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Inactivity</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Idle screen timeout</span><span class="s-desc">Show the Moku idle splash after this much inactivity</span></div>
<div class="s-select">
<button bind:this={triggerIdleTimeout} class="s-select-btn" onclick={() => toggleSelect('idle-timeout')}>
<span>{{ '0':'Never','1':'1 minute','2':'2 minutes','5':'5 minutes','10':'10 minutes','15':'15 minutes','30':'30 minutes' }[String(settingsState.settings.idleTimeoutMin ?? 5)] ?? `${settingsState.settings.idleTimeoutMin} min`}</span>
<svg class="s-select-caret" class:open={selectOpen === 'idle-timeout'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'}
<div class="s-select-menu" class:anims class:closing={closingSelect === 'idle-timeout'}>
{#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]}
<button class="s-select-option" class:active={String(settingsState.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect('idle-timeout') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Integrations</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Discord Rich Presence</span><span class="s-desc">Show what you're reading in your Discord status</span></div>
<button role="switch" aria-checked={settingsState.settings.discordRpc} aria-label="Discord Rich Presence" class="s-toggle" class:on={settingsState.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !settingsState.settings.discordRpc })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Animations</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">QOL Animations</span><span class="s-desc">Hover lifts, active-tab transitions, and icon micro-animations</span></div>
<button role="switch" aria-checked={settingsState.settings.qolAnimations ?? true} aria-label="QOL Animations" class="s-toggle" class:on={settingsState.settings.qolAnimations ?? true} onclick={() => updateSettings({ qolAnimations: !(settingsState.settings.qolAnimations ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Language</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Preferred source language</span>
<span class="s-desc">Used to pre-select languages in Search and deduplicate sources</span>
</div>
<input class="s-input" style="width:72px;text-align:center;text-transform:uppercase"
value={settingsState.settings.preferredExtensionLang ?? ''}
oninput={(e) => updateSettings({ preferredExtensionLang: e.currentTarget.value.trim().toLowerCase() })}
placeholder="en" spellcheck="false" />
</div>
</div>
</div>
</div>
<style>
.s-seg { display: flex; border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; }
.s-seg-btn { flex: 1; padding: var(--sp-1) var(--sp-3); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); background: transparent; cursor: pointer; transition: background var(--t-base), color var(--t-base); border: none; }
.s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); }
.s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); }
.s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); }
.srv-url-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.srv-adv-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-faint); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.srv-adv-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
.srv-adv-btn.open { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
.srv-adv-btn svg { transition: transform var(--t-base); }
.srv-adv-btn.open svg { transform: rotate(180deg); }
.srv-adv-panel { border-top: 1px solid var(--border-dim); background: var(--bg-base); }
.srv-adv-row { display: flex; align-items: center; justify-content: space-between; padding: 10px var(--sp-4); gap: var(--sp-4); }
.srv-file-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.srv-path-input { width: 160px; }
.srv-file-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-faint); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.srv-file-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
</style>
@@ -1,55 +0,0 @@
<script lang="ts">
export { eventToKeybind, matchesKeybind } from './keybindEngine'
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
export type { Keybinds } from './defaultBinds'
let listeningKey: keyof Keybinds | null = $state(null)
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key
}
function onKeyCapture(e: KeyboardEvent) {
if (!listeningKey) return
e.preventDefault(); e.stopPropagation()
const bind = eventToKeybind(e)
if (!bind) return
updateSettings({ keybinds: { ...settingsState.settings.keybinds, [listeningKey]: bind } })
listeningKey = null
}
$effect(() => {
if (listeningKey) {
window.addEventListener('keydown', onKeyCapture, true)
return () => window.removeEventListener('keydown', onKeyCapture, true)
}
})
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">
Keyboard Shortcuts
<button class="s-btn" onclick={resetKeybinds}>Reset all</button>
</p>
<p class="s-kb-hint">Click a binding to rebind, then press the new key combination.</p>
<div class="s-section-body">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
{@const isListening = listeningKey === k}
{@const isDefault = settingsState.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
<div class="s-kb-row">
<span class="s-kb-label">{KEYBIND_LABELS[k]}</span>
<div class="s-kb-right">
<button class="s-kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
{isListening ? 'Press key…' : settingsState.settings.keybinds[k]}
</button>
<button class="s-btn-icon"
onclick={() => updateSettings({ keybinds: { ...settingsState.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })}
disabled={isDefault} title="Reset"></button>
</div>
</div>
{/each}
</div>
</div>
</div>
@@ -1,92 +0,0 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { homeState } from '$lib/state/home.svelte'
import type { Settings } from '$lib/types/settings'
interface Props {
selectOpen: string | null
closingSelect?: string | null
toggleSelect: (id: string) => void
anims: boolean
}
let { selectOpen, toggleSelect, anims }: Props = $props()
let triggerSortDir = $state<HTMLButtonElement>(null!)
function clearHistory() {
homeState.history = []
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Display</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Always show card stats</span><span class="s-desc">Show unread and download counts without needing to hover</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryStatsAlways ?? false} aria-label="Always show card stats" class="s-toggle" class:on={settingsState.settings.libraryStatsAlways ?? false} onclick={() => updateSettings({ libraryStatsAlways: !(settingsState.settings.libraryStatsAlways ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={settingsState.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !settingsState.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={settingsState.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(settingsState.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
{#if settingsState.settings.libraryShowAllInSaved ?? true}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Hide completed in Saved tab</span><span class="s-desc">Keep manga in the Completed folder out of the Saved view</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryHideCompletedInSaved ?? false} aria-label="Hide completed manga in Saved tab" class="s-toggle" class:on={settingsState.settings.libraryHideCompletedInSaved ?? false} onclick={() => updateSettings({ libraryHideCompletedInSaved: !(settingsState.settings.libraryHideCompletedInSaved ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Chapters</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default sort direction</span><span class="s-desc">Initial chapter list order when opening a manga</span></div>
<div class="s-select">
<button bind:this={triggerSortDir} class="s-select-btn" onclick={() => toggleSelect('sort-dir')}>
<span>{{ 'desc':'Newest first','asc':'Oldest first' }[settingsState.settings.chapterSortDir]}</span>
<svg class="s-select-caret" class:open={selectOpen === 'sort-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'sort-dir'}
<div class="s-select-menu" class:anims>
{#each [['desc','Newest first'],['asc','Oldest first']] as [v, l]}
<button class="s-select-option" class:active={settingsState.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings['chapterSortDir'] }); toggleSelect('sort-dir') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Series</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-link on open</span><span class="s-desc">When opening a manga, automatically link it to similarly-titled entries</span></div>
<button role="switch" aria-checked={settingsState.settings.autoLinkOnOpen ?? false} aria-label="Auto-link on open" class="s-toggle" class:on={settingsState.settings.autoLinkOnOpen ?? false} onclick={() => updateSettings({ autoLinkOnOpen: !(settingsState.settings.autoLinkOnOpen ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Disable auto-complete</span><span class="s-desc">Don't move manga to the Completed folder when all chapters are read</span></div>
<button role="switch" aria-checked={settingsState.settings.disableAutoComplete} aria-label="Disable auto-complete" class="s-toggle" class:on={settingsState.settings.disableAutoComplete} onclick={() => updateSettings({ disableAutoComplete: !settingsState.settings.disableAutoComplete })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history.length} entries</span></div>
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={homeState.history.length === 0}>Clear</button>
</div>
</div>
</div>
</div>
@@ -1,161 +0,0 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { cache } from '$lib/core/cache/queryCache'
interface PerfSnapshot {
cacheEntries: number
cacheKeys: string[]
oldestEntryMs: number | null
newestEntryMs: number | null
}
let perfSnapshot = $state<PerfSnapshot | null>(null)
let clearing = $state(false)
let cleared = $state(false)
function refreshPerfMetrics() {
let entries = 0, oldest: number | null = null, newest: number | null = null
const foundKeys: string[] = []
const checkKey = (k: string) => {
const age = cache.ageOf(k)
if (age !== undefined) {
entries++
foundKeys.push(k)
const ts = Date.now() - age
if (oldest === null || ts < oldest) oldest = ts
if (newest === null || ts > newest) newest = ts
}
}
['library', 'sources', 'popular'].forEach(checkKey)
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest }
}
function fmtAge(ts: number | null): string {
if (ts === null) return '—'
const secs = Math.floor((Date.now() - ts) / 1000)
if (secs < 60) return `${secs}s ago`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ago`
return `${Math.floor(mins / 60)}h ago`
}
function handleClearCache() {
clearing = true
caches.keys()
.then(names => Promise.all(names.map(n => caches.delete(n))))
.catch(() => {})
.finally(() => {
clearing = false
cleared = true
setTimeout(() => cleared = false, 2500)
refreshPerfMetrics()
})
}
$effect(() => { refreshPerfMetrics() })
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Render Limit</p>
<div class="s-section-body">
<div class="s-slider-row">
<div class="s-row-info">
<span class="s-label">Items per page</span>
<span class="s-desc">Lower = faster on large libraries</span>
</div>
<div class="s-stepper">
<button class="s-step-btn"
onclick={() => updateSettings({ renderLimit: Math.max(12, (settingsState.settings.renderLimit ?? 48) - 12) })}
disabled={(settingsState.settings.renderLimit ?? 48) <= 12}></button>
<span class="s-step-val">{settingsState.settings.renderLimit ?? 48}</span>
<button class="s-step-btn"
onclick={() => updateSettings({ renderLimit: Math.min(200, (settingsState.settings.renderLimit ?? 48) + 12) })}
disabled={(settingsState.settings.renderLimit ?? 48) >= 200}>+</button>
</div>
</div>
<div class="s-presets">
{#each [12, 24, 48, 96, 200] as v}
<button class="s-preset" class:active={(settingsState.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Rendering</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">GPU acceleration</span><span class="s-desc">Uses the GPU for rendering; disable if you see visual glitches</span></div>
<button role="switch" aria-checked={settingsState.settings.gpuAcceleration} aria-label="GPU acceleration"
class="s-toggle" class:on={settingsState.settings.gpuAcceleration}
onclick={() => updateSettings({ gpuAcceleration: !settingsState.settings.gpuAcceleration })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Idle / Splash Screen</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Animated card background</span><span class="s-desc">Shows cover art cards floating in the background on the idle screen</span></div>
<button role="switch" aria-checked={settingsState.settings.splashCards ?? true} aria-label="Animated card background"
class="s-toggle" class:on={settingsState.settings.splashCards ?? true}
onclick={() => updateSettings({ splashCards: !(settingsState.settings.splashCards ?? true) })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Session Cache</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Cache entries</span>
<span class="s-desc">In-memory, cleared on restart</span>
</div>
<div class="s-btn-row">
<span class="s-step-val">{perfSnapshot?.cacheEntries ?? 0} entries</span>
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh"></button>
</div>
</div>
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Oldest entry</span></div>
<span class="s-step-val">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Newest entry</span></div>
<span class="s-step-val">{fmtAge(perfSnapshot.newestEntryMs)}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Cached keys</span>
<span class="s-desc">{perfSnapshot.cacheKeys.join(', ')}</span>
</div>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Cache</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Image cache</span><span class="s-desc">Webview page image cache</span></div>
<button class="s-btn s-btn-danger" onclick={handleClearCache} disabled={clearing}>
{cleared ? 'Cleared' : clearing ? 'Clearing…' : 'Clear'}
</button>
</div>
</div>
</div>
</div>
@@ -1,128 +0,0 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import type { Settings, FitMode } from '$lib/types/settings'
interface Props {
selectOpen: string | null
closingSelect?: string | null
toggleSelect: (id: string) => void
anims: boolean
}
let { selectOpen, toggleSelect, anims }: Props = $props()
let triggerPageStyle = $state<HTMLButtonElement>(null!)
let triggerReadingDir = $state<HTMLButtonElement>(null!)
let triggerFitMode = $state<HTMLButtonElement>(null!)
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Page Layout</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default layout</span><span class="s-desc">How chapters open by default</span></div>
<div class="s-select">
<button bind:this={triggerPageStyle} class="s-select-btn" onclick={() => toggleSelect('page-style')}>
<span>{{ 'single':'Single page','longstrip':'Long strip' }[settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle]}</span>
<svg class="s-select-caret" class:open={selectOpen === 'page-style'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'page-style'}
<div class="s-select-menu" class:anims>
{#each [['single','Single page'],['longstrip','Long strip']] as [v, l]}
<button class="s-select-option" class:active={(settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings['pageStyle'] }); toggleSelect('page-style') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reading direction</span><span class="s-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
<div class="s-select">
<button bind:this={triggerReadingDir} class="s-select-btn" onclick={() => toggleSelect('reading-dir')}>
<span>{{ 'ltr':'Left to right','rtl':'Right to left' }[settingsState.settings.readingDirection]}</span>
<svg class="s-select-caret" class:open={selectOpen === 'reading-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'reading-dir'}
<div class="s-select-menu" class:anims>
{#each [['ltr','Left to right'],['rtl','Right to left']] as [v, l]}
<button class="s-select-option" class:active={settingsState.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings['readingDirection'] }); toggleSelect('reading-dir') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Page gap</span><span class="s-desc">Adds spacing between pages in single-page mode</span></div>
<button role="switch" aria-checked={settingsState.settings.pageGap} aria-label="Page gap" class="s-toggle" class:on={settingsState.settings.pageGap} onclick={() => updateSettings({ pageGap: !settingsState.settings.pageGap })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Overlay bars</span><span class="s-desc">Floats the nav and chapter bars over the page instead of pushing content</span></div>
<button role="switch" aria-checked={settingsState.settings.overlayBars ?? false} aria-label="Overlay bars" class="s-toggle" class:on={settingsState.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(settingsState.settings.overlayBars ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Tap to toggle bar</span><span class="s-desc">Double-tap the center of the reader to show or hide the bars</span></div>
<button role="switch" aria-checked={settingsState.settings.tapToToggleBar ?? false} aria-label="Tap to toggle bar" class="s-toggle" class:on={settingsState.settings.tapToToggleBar ?? false} onclick={() => updateSettings({ tapToToggleBar: !(settingsState.settings.tapToToggleBar ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Fit &amp; Zoom</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default fit mode</span><span class="s-desc">How pages are scaled to fill the reader on open</span></div>
<div class="s-select">
<button bind:this={triggerFitMode} class="s-select-btn" onclick={() => toggleSelect('fit-mode')}>
<span>{{ 'width':'Fit width','height':'Fit height','screen':'Fit screen','original':'Original (1:1)' }[settingsState.settings.fitMode ?? 'width']}</span>
<svg class="s-select-caret" class:open={selectOpen === 'fit-mode'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'fit-mode'}
<div class="s-select-menu" class:anims>
{#each [['width','Fit width'],['height','Fit height'],['screen','Fit screen'],['original','Original (1:1)']] as [v, l]}
<button class="s-select-option" class:active={(settingsState.settings.fitMode ?? 'width') === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); toggleSelect('fit-mode') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Optimize contrast</span><span class="s-desc">Sharpens dark lines on light pages; best for black-and-white manga</span></div>
<button role="switch" aria-checked={settingsState.settings.optimizeContrast} aria-label="Optimize contrast" class="s-toggle" class:on={settingsState.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !settingsState.settings.optimizeContrast })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Behaviour</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-mark read</span><span class="s-desc">Marks a chapter as read when you reach the last page</span></div>
<button role="switch" aria-checked={settingsState.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="s-toggle" class:on={settingsState.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !settingsState.settings.autoMarkRead })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-advance chapters</span><span class="s-desc">Automatically loads the next chapter when you pass the last page</span></div>
<button role="switch" aria-checked={settingsState.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="s-toggle" class:on={settingsState.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(settingsState.settings.autoNextChapter ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
{#if !(settingsState.settings.autoNextChapter ?? false)}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Mark read when skipping</span><span class="s-desc">Marks the current chapter read when you manually jump to the next</span></div>
<button role="switch" aria-checked={settingsState.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="s-toggle" class:on={settingsState.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(settingsState.settings.markReadOnNext ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
{/if}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-bookmark</span><span class="s-desc">Automatically saves your page position as you read</span></div>
<button role="switch" aria-checked={settingsState.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="s-toggle" class:on={settingsState.settings.autoBookmark ?? true} onclick={() => updateSettings({ autoBookmark: !(settingsState.settings.autoBookmark ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Pages to preload</span><span class="s-desc">How many pages ahead to fetch in the background while reading</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, settingsState.settings.preloadPages - 1) })} disabled={settingsState.settings.preloadPages <= 0}></button>
<span class="s-step-val">{settingsState.settings.preloadPages}</span>
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, settingsState.settings.preloadPages + 1) })} disabled={settingsState.settings.preloadPages >= 10}>+</button>
</div>
</div>
</div>
</div>
</div>
@@ -1,340 +0,0 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { requestManager } from '$lib/request-manager'
import { authSession, loginUI } from '$lib/core/auth'
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
let { selectOpen, toggleSelect }: Props = $props()
let showAuthPass = $state(false)
let showSocksPass = $state(false)
let secLoading = $state(false)
let secError = $state<string | null>(null)
let secSaved = $state<string | null>(null)
let secLoaded = $state(false)
let authMode = $state(settingsState.settings.serverAuthMode ?? 'NONE')
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
let authPassword = $state('')
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
let socksPort = $state(settingsState.settings.socksProxyPort ?? '1080')
let socksVersion = $state(settingsState.settings.socksProxyVersion ?? 5)
let socksUsername = $state(settingsState.settings.socksProxyUsername ?? '')
let socksPassword = $state(settingsState.settings.socksProxyPassword ?? '')
let flareEnabled = $state(settingsState.settings.flareSolverrEnabled ?? false)
let flareUrl = $state(settingsState.settings.flareSolverrUrl ?? 'http://localhost:8191')
let flareTimeout = $state(settingsState.settings.flareSolverrTimeout ?? 60)
let flareSession = $state(settingsState.settings.flareSolverrSessionName ?? 'moku')
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
return 'NONE'
}
function showSaved(key: string) {
secSaved = key; secError = null
setTimeout(() => { if (secSaved === key) secSaved = null }, 2000)
}
$effect(() => {
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() }
})
async function loadServerSecurity() {
try {
const s = await requestManager.extensions.getServerSecurity()
const serverMode = normalizeAuthMode(s.authMode)
if (serverMode !== 'UI_LOGIN') authSession.clearTokens()
authMode = serverMode
authUsername = s.authUsername || ''
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername })
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
socksUsername = s.socksProxyUsername
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
})
} catch {}
}
async function saveAuth() {
if (authMode === 'NONE') { await clearAuth(); return }
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
secLoading = true; secError = null
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try {
const newUser = authUsername.trim()
const newPass = authPassword.trim()
authSession.clearTokens()
if (authMode === 'UI_LOGIN') {
await loginUI(newUser, newPass)
updateSettings({ serverAuthMode: 'UI_LOGIN', serverAuthUser: newUser, serverAuthPass: '' })
} else {
updateSettings({ serverAuthMode: 'BASIC_AUTH', serverAuthUser: newUser, serverAuthPass: newPass })
}
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
authPassword = ''
showSaved('auth')
} catch (e: any) {
const msg = e?.message ?? 'Failed to save authentication settings'
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg)
if (!authMismatch) {
authSession.clearTokens()
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
}
secError = authMismatch
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.'
: msg
} finally { secLoading = false }
}
async function clearAuth() {
secLoading = true; secError = null
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try {
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
authMode = 'NONE'; authUsername = ''; authPassword = ''
authSession.clearTokens(); showSaved('auth')
} catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
secError = e?.message ?? 'Failed to disable authentication'
} finally { secLoading = false }
}
async function saveSocksProxy() {
secLoading = true; secError = null
try {
await requestManager.extensions.setSocksProxy({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
})
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
})
showSaved('socks')
} catch (e: any) {
secError = e?.message ?? 'Failed to save SOCKS proxy'
} finally { secLoading = false }
}
async function saveFlareSolverr() {
secLoading = true; secError = null
try {
await requestManager.extensions.setFlareSolverr({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
})
updateSettings({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
})
showSaved('flare')
} catch (e: any) {
secError = e?.message ?? 'Failed to save FlareSolverr'
} finally { secLoading = false }
}
function forceResetAuth() {
authSession.clearTokens()
authMode = 'NONE'
authUsername = ''
authPassword = ''
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
showSaved('auth')
}
const EyeOpen = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`
const EyeClose = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`
</script>
<div class="s-panel">
{#if secError}
<div class="s-banner s-banner-error">{secError}</div>
{/if}
<div class="s-section">
<p class="s-section-title">
Server Authentication
<span class="s-pill" class:on={settingsState.settings.serverAuthMode === 'BASIC_AUTH' || settingsState.settings.serverAuthMode === 'UI_LOGIN'}>
{settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Basic Auth' :
settingsState.settings.serverAuthMode === 'UI_LOGIN' ? 'UI Login' : 'Disabled'}
</span>
</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Moku authenticates with the server</span></div>
<div class="s-segment">
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
<button class="s-segment-btn" class:active={authMode === opt.value}
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
{/each}
</div>
</div>
{#if authMode !== 'NONE'}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span></div>
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
</div>
</div>
{/if}
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
<div class="s-row">
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
</div>
{/if}
<div class="s-row">
<div class="s-row-info">
<button class="s-ghost-btn" onclick={forceResetAuth} disabled={secLoading} title="Force reset local auth state">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset
</button>
</div>
<div class="s-btn-row">
{#if settingsState.settings.serverAuthMode !== 'NONE'}
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
{secLoading ? 'Saving…' : 'Disable'}
</button>
{/if}
<button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}>
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'}
</button>
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">SOCKS Proxy</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable SOCKS proxy</span><span class="s-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span></div>
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="s-toggle" class:on={socksEnabled}
onclick={() => { socksEnabled = !socksEnabled; saveSocksProxy() }}><span class="s-toggle-thumb"></span></button>
</label>
{#if socksEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Version</span></div>
<div class="s-select" id="socks-ver">
<button class="s-select-btn" onclick={() => toggleSelect('socks-ver')}>
<span>SOCKS{socksVersion}</span>
<svg class="s-select-caret" class:open={selectOpen === 'socks-ver'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'socks-ver'}
<div class="s-select-menu">
{#each [[4, 'SOCKS4'], [5, 'SOCKS5']] as [v, l]}
<button class="s-select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; toggleSelect('socks-ver') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Host</span></div>
<input class="s-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Port</span></div>
<input class="s-input" style="width:80px" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span><span class="s-desc">Optional</span></div>
<input class="s-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span><span class="s-desc">Optional</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showSocksPass ? 'text' : 'password'} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
<button class="s-eye-btn" onclick={() => showSocksPass = !showSocksPass} tabindex="-1" aria-label={showSocksPass ? 'Hide password' : 'Show password'}>{@html showSocksPass ? EyeClose : EyeOpen}</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveSocksProxy} disabled={secLoading}>
{secLoading ? 'Saving…' : secSaved === 'socks' ? 'Saved ✓' : 'Save'}
</button>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">FlareSolverr</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable FlareSolverr</span><span class="s-desc">Bypass Cloudflare challenges for sources that require it</span></div>
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="s-toggle" class:on={flareEnabled}
onclick={() => { flareEnabled = !flareEnabled; saveFlareSolverr() }}><span class="s-toggle-thumb"></span></button>
</label>
{#if flareEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">URL</span><span class="s-desc">FlareSolverr instance address</span></div>
<input class="s-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Timeout</span><span class="s-desc">Max wait per request, in seconds</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}></button>
<span class="s-step-val">{flareTimeout}s</span>
<button class="s-step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session name</span><span class="s-desc">Reuse browser session across requests</span></div>
<input class="s-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session TTL</span><span class="s-desc">Minutes before session is refreshed</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}></button>
<span class="s-step-val">{flareTtl}m</span>
<button class="s-step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Response fallback</span><span class="s-desc">Use FlareSolverr's response when the direct request fails</span></div>
<button role="switch" aria-checked={flareFallback} aria-label="Response fallback" class="s-toggle" class:on={flareFallback}
onclick={() => flareFallback = !flareFallback}><span class="s-toggle-thumb"></span></button>
</label>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveFlareSolverr} disabled={secLoading}>
{secLoading ? 'Saving…' : secSaved === 'flare' ? 'Saved ✓' : 'Save'}
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
</style>
@@ -1,869 +0,0 @@
<script lang="ts">
import { Trash, ClockCounterClockwise } from 'phosphor-svelte'
import { invoke } from '@tauri-apps/api/core'
import { untrack } from 'svelte'
import { appState } from '$lib/state/app.svelte'
import { toast } from '$lib/state/notifications.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { exportAppData, importAppData } from '$lib/core/backup'
import { loadBackups, persistBackups, persistSettings, persistLibrary } from '$lib/core/persistence/persist'
import type { BackupEntry } from '$lib/core/persistence/persist'
import { DEFAULT_SETTINGS } from '$lib/types/settings'
import { DEFAULT_READING_STATS } from '$lib/types/history'
import { clearBlobCache } from '$lib/core/cache/imageCache'
import { clearPageCache } from '$lib/request-manager'
import { cache as queryCache } from '$lib/core/cache/queryCache'
type ResetState = 'idle' | 'busy' | 'done' | 'error'
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean }
let resetItems = $state<ResetItem[]>([
{ key: 'all-cache', label: 'Clear all caches', desc: 'Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.', state: 'idle', error: null, confirm: false },
{ key: 'reading-history', label: 'Clear reading history', desc: 'Erases chapter history, read log, reading stats, and daily read counts.', state: 'idle', error: null, confirm: true },
{ key: 'moku-settings', label: 'Reset Moku settings', desc: 'Restores all app settings to their defaults. Does not affect library data.', state: 'idle', error: null, confirm: true },
{ key: 'suwayomi-data', label: 'Reset Suwayomi data', desc: 'Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.', state: 'idle', error: null, confirm: true },
])
let confirming = $state<string | null>(null)
function patchReset(key: string, update: Partial<ResetItem>) {
resetItems = resetItems.map(i => i.key === key ? { ...i, ...update } : i)
}
function showExitCountdown(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement('div')
backdrop.className = 's-backdrop'
backdrop.style.cssText = 'z-index:99999'
const modal = document.createElement('div')
modal.style.cssText = 'background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7);width:min(400px,calc(100vw - 40px));display:flex;flex-direction:column;overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both'
const header = document.createElement('div')
header.style.cssText = 'padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)'
const title = document.createElement('p')
title.style.cssText = 'margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em'
title.textContent = 'Reset complete'
header.appendChild(title)
const body = document.createElement('div')
body.style.cssText = 'padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)'
const sub = document.createElement('p')
sub.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)'
sub.textContent = 'Moku will close so you can relaunch with the reset applied.'
const counter = document.createElement('p')
counter.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)'
counter.textContent = 'Closing in 3…'
body.append(sub, counter)
const footer = document.createElement('div')
footer.style.cssText = 'padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end'
const btn = document.createElement('button')
btn.className = 's-btn s-btn-danger'
btn.textContent = 'Close now'
footer.appendChild(btn)
modal.append(header, body, footer)
backdrop.appendChild(modal)
document.body.appendChild(backdrop)
let secs = 3
const tick = setInterval(() => {
secs--
counter.textContent = secs > 0 ? `Closing in ${secs}…` : 'Closing…'
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve() }
}, 1000)
btn.addEventListener('click', () => { clearInterval(tick); backdrop.remove(); resolve() })
})
}
async function clearAllCaches(): Promise<void> {
clearBlobCache()
clearPageCache()
queryCache.clearAll()
await Promise.all([
invoke('clear_moku_cache'),
invoke('clear_suwayomi_cache'),
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
])
}
async function runReset(key: string) {
confirming = null
patchReset(key, { state: 'busy', error: null })
try {
switch (key) {
case 'all-cache':
await clearAllCaches()
break
case 'reading-history':
await persistLibrary({ history: [], bookmarks: [], markers: [], readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} })
break
case 'moku-settings':
localStorage.clear()
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 })
patchReset(key, { state: 'done' })
await showExitCountdown()
invoke('exit_app')
return
case 'suwayomi-data':
localStorage.clear()
await invoke('reset_suwayomi_data')
patchReset(key, { state: 'done' })
await showExitCountdown()
invoke('exit_app')
return
}
patchReset(key, { state: 'done' })
setTimeout(() => patchReset(key, { state: 'idle' }), 3000)
} catch (e: any) {
patchReset(key, { state: 'error', error: e?.message ?? String(e) })
}
}
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string }
const isExternalServer = $derived.by(() => {
const url = (settingsState.settings.serverUrl ?? 'http://localhost:4567').toLowerCase().trim()
try {
const host = new URL(url).hostname
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
} catch { return false }
})
let storageInfo = $state<StorageInfo | null>(null)
let storageLoading = $state(false)
let storageError = $state<string | null>(null)
let downloadsPathInput = $state(settingsState.settings.serverDownloadsPath ?? '')
let localSourcePathInput = $state(settingsState.settings.serverLocalSourcePath ?? '')
let pathsSaving = $state(false)
let pathsError = $state<string | null>(null)
let pathsFieldError = $state<{ dl?: string; loc?: string }>({})
let pathsSaved = $state(false)
let defaultDownloadsPath = $state('')
$effect(() => {
if (!isExternalServer) {
invoke<string>('get_default_downloads_path').then(p => { defaultDownloadsPath = p })
} else {
defaultDownloadsPath = ''
}
})
let confirmedDownloadsPath = $state(settingsState.settings.serverDownloadsPath ?? '')
let confirmedLocalSourcePath = $state(settingsState.settings.serverLocalSourcePath ?? '')
let migrateFrom = $state<string | null>(null)
let migrateTo = $state<string | null>(null)
let migrating = $state(false)
let migrateProgress = $state<{ done: number; total: number; current: string } | null>(null)
let migrateError = $state<string | null>(null)
let migrateUnlisten: (() => void) | null = null
let extraScanDirs = $state<string[]>([...(settingsState.settings.extraScanDirs ?? [])])
let newScanDir = $state('')
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([])
let advStorageOpen = $state(false)
let backupSectionOpen = $state(false)
let resetSectionOpen = $state(false)
async function fetchStorage() {
storageLoading = true; storageError = null
try {
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
`{ downloadsPath localSourcePath }`
)
const dl = pathData.downloadsPath ?? ''
const loc = pathData.localSourcePath ?? ''
downloadsPathInput = dl; localSourcePathInput = loc
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
if (isExternalServer) { multiStorageInfos = []; storageInfo = null; return }
const effectiveDl = dl || defaultDownloadsPath
const dirsToScan: { path: string; label: string }[] = []
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? 'Downloads' : 'Downloads (default)' })
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: 'Local source' })
for (const p of extraScanDirs) {
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p })
}
if (dirsToScan.length === 0) { multiStorageInfos = []; storageInfo = null; return }
const results = await Promise.allSettled(
dirsToScan.map(d => invoke<StorageInfo>('get_storage_info', { downloadsPath: d.path }).then(info => ({ ...info, label: d.label })))
)
multiStorageInfos = results
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === 'fulfilled')
.map(r => r.value)
storageInfo = multiStorageInfos[0] ?? null
} catch (e: any) {
storageError = e instanceof Error ? e.message : String(e)
} finally { storageLoading = false }
}
async function validatePath(path: string): Promise<string | null> {
if (!path.trim()) return null
if (isExternalServer) return null
try {
const exists = await invoke<boolean>('check_path_exists', { path: path.trim() })
return exists ? null : 'Directory does not exist'
} catch { return 'Could not check path' }
}
async function createDirectory(path: string): Promise<void> {
if (isExternalServer) throw new Error('Cannot create directories on an external server')
await invoke('create_directory', { path })
}
async function savePaths() {
const dl = downloadsPathInput.trim()
const loc = localSourcePathInput.trim()
pathsError = null; pathsFieldError = {}
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)])
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
pathsSaving = true
try {
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
if (!isExternalServer) {
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
const newDl = dl || defaultDownloadsPath
if (newDl && oldDl && newDl !== oldDl) {
const hadContent = await invoke<boolean>('check_path_exists', { path: oldDl })
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl }
}
}
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
pathsSaved = true; setTimeout(() => pathsSaved = false, 2000)
await fetchStorage()
} catch (e: any) {
pathsError = e?.message ?? 'Failed to save paths'
} finally { pathsSaving = false }
}
async function startMigration() {
if (!migrateFrom || !migrateTo) return
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: '' }
const { listen: tauriListen } = await import('@tauri-apps/api/event')
migrateUnlisten = await tauriListen<{ done: number; total: number; current: string }>(
'migrate_progress', e => { migrateProgress = e.payload }
)
try {
await invoke('migrate_downloads', { src: migrateFrom, dst: migrateTo })
migrateFrom = null; migrateTo = null; migrateProgress = null
await fetchStorage()
} catch (e: any) {
migrateError = e?.message ?? 'Migration failed'
} finally { migrating = false; migrateUnlisten?.(); migrateUnlisten = null }
}
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null }
async function browseDownloadsFolder() {
const picked = await invoke<string | null>('pick_downloads_folder')
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths() }
}
async function browseLocalSourceFolder() {
const picked = await invoke<string | null>('pick_downloads_folder')
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths() }
}
async function browseExtraScanDir() {
const picked = await invoke<string | null>('pick_downloads_folder')
if (picked) { newScanDir = picked; addExtraScanDir() }
}
function addExtraScanDir() {
const dir = newScanDir.trim()
if (!dir || extraScanDirs.includes(dir)) return
extraScanDirs = [...extraScanDirs, dir]
updateSettings({ extraScanDirs }); newScanDir = ''; fetchStorage()
}
function removeExtraScanDir(path: string) {
extraScanDirs = extraScanDirs.filter(d => d !== path)
updateSettings({ extraScanDirs }); fetchStorage()
}
function fmtBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
}
let backupLoading = $state(false)
let backupError = $state<string | null>(null)
let backupList = $state<(BackupEntry & { deleting?: boolean })[]>([])
async function loadBackupList() {
backupList = (await loadBackups()).map(b => ({ ...b }))
}
async function saveBackupList() {
await persistBackups(backupList.map(({ url, name }) => ({ url, name })))
}
async function createBackup() {
backupLoading = true; backupError = null
try {
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
const { url } = data.createBackup
const name = url.split('/').pop() ?? url
backupList = [{ url, name }, ...backupList]
await saveBackupList()
} catch (e: any) { backupError = e?.message ?? 'Failed to create backup' }
finally { backupLoading = false }
}
async function deleteBackup(url: string) {
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
try {
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
backupList = backupList.filter(b => b.url !== url)
await saveBackupList()
} catch (e: any) {
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b)
backupError = e?.message ?? 'Failed to delete backup'
}
}
async function downloadBackup(backup: BackupEntry) {
try {
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
const blob = await resp.blob()
if ('showSaveFilePicker' in window) {
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: backup.name,
types: [{ description: 'Backup file', accept: { 'application/octet-stream': ['.tachibk', '.proto.gz'] } }],
})
const writable = await handle.createWritable()
await writable.write(blob); await writable.close()
toast({ kind: 'success', message: 'Backup saved', detail: backup.name }); return
} catch (pickerErr: any) { if (pickerErr?.name === 'AbortError') return }
}
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl; a.download = backup.name
document.body.appendChild(a); a.click(); document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000)
toast({ kind: 'download', message: 'Backup downloaded', detail: backup.name })
} catch (e: any) { backupError = e?.message ?? 'Failed to download backup' }
}
let restoreLoading = $state(false)
let restoreError = $state<string | null>(null)
let restoreJobId = $state<string | null>(null)
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
let validateLoading = $state(false)
let validateError = $state<string | null>(null)
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
let restoreFile = $state<File | null>(null)
function stopRestorePoll() {
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null }
}
async function pollRestoreStatus(id: string) {
try {
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
{ id }
)
const status = data.restoreStatus
restoreStatus = status
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
} catch {}
}
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
const form = new FormData()
form.append('operations', JSON.stringify({ query, variables }))
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
form.append('0', file, file.name)
return form
}
function buildAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' }
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
return headers
}
function serverUrl(): string {
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
}
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch(`${serverUrl()}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
body: JSON.stringify({ query, variables }),
})
const json = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
return json.data as T
}
async function submitRestore() {
if (!restoreFile) return
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
stopRestorePoll()
try {
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
const json = await resp.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
const result = json.data.restoreBackup
restoreJobId = result.id; restoreStatus = result.status
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
finally { restoreLoading = false }
}
async function submitValidate() {
if (!restoreFile) return
validateLoading = true; validateError = null; validateResult = null
try {
const form = buildBackupFormData(
restoreFile,
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
{ backup: null }
)
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
const json = await resp.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
validateResult = json.data.validateBackup
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
finally { validateLoading = false }
}
let appDataExporting = $state(false)
let appDataImporting = $state(false)
let appDataError = $state<string | null>(null)
let appDataMsg = $state<string | null>(null)
let appDataBackupDir = $state<string | null>(null)
$effect(() => {
invoke<string>('get_auto_backup_dir').then(d => { appDataBackupDir = d }).catch(() => {})
})
async function handleExportAppData() {
appDataExporting = true; appDataError = null; appDataMsg = null
try {
await exportAppData()
appDataMsg = 'Backup saved.'
setTimeout(() => appDataMsg = null, 3000)
} catch (e: any) {
if (String(e).includes('Cancelled')) return
appDataError = e?.message ?? String(e)
} finally { appDataExporting = false }
}
async function handleImportAppData() {
appDataImporting = true; appDataError = null; appDataMsg = null
try {
await importAppData()
} catch (e: any) {
if (String(e).includes('Cancelled')) { appDataImporting = false; return }
appDataError = e?.message ?? String(e)
appDataImporting = false
}
}
$effect(() => { untrack(() => { loadBackupList(); fetchStorage() }) })
$effect(() => { return () => stopRestorePoll() })
</script>
<div class="s-panel">
{#if migrateFrom && !isExternalServer}
<div class="s-migrate-banner">
<div class="s-migrate-body">
<span class="s-migrate-title">Manga found at previous path — move to new location?</span>
<span class="s-migrate-paths">{migrateFrom}{migrateTo}</span>
{#if migrateProgress && migrateProgress.total > 0}
<div class="s-migrate-bar"><div class="s-migrate-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
<span class="s-migrate-paths">{migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total}</span>
{/if}
{#if migrateError}<span class="s-desc" style="color:var(--color-error)">{migrateError}</span>{/if}
</div>
<div class="s-migrate-actions">
<button class="s-btn s-btn-accent" onclick={startMigration} disabled={migrating}>
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : 'Starting…') : 'Move files'}
</button>
<button class="s-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
</div>
</div>
{/if}
<div class="s-section">
<p class="s-section-title">
Disk Usage
<button class="s-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? '…' : '↻'}</button>
</p>
<div class="s-section-body">
{#if storageLoading}
<p class="s-empty">Reading filesystem…</p>
{:else if storageError}
<p class="s-empty" style="color:var(--color-error)">{storageError}</p>
{:else if isExternalServer}
<p class="s-empty">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
{:else if multiStorageInfos.length > 0}
{#each multiStorageInfos as info}
{@const limitGb = settingsState.settings.storageLimitGb ?? null}
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
{@const available = info.manga_bytes + info.free_bytes}
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
<div class="s-storage-wrap">
<div class="s-storage-header">
<span class="s-storage-label">{info.label}</span>
<span class="s-storage-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
</div>
<div class="s-storage-bar">
<div class="s-storage-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
</div>
<div class="s-storage-footer">
<span>{info.path}</span>
<span>{fmtBytes(info.free_bytes)} free</span>
</div>
</div>
{/each}
{:else}
<p class="s-empty">No download path configured.</p>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Downloads Path</p>
<div class="s-section-body">
{#if isExternalServer}
<div class="s-row">
<span class="s-desc">Connected to an external server. The path below is read from the server — changes here will update the server's config directly.</span>
</div>
{/if}
<div class="s-row" style="gap:var(--sp-2)">
<input class="s-input full" class:error={!!pathsFieldError.dl}
bind:value={downloadsPathInput}
placeholder={isExternalServer ? 'Server default' : (defaultDownloadsPath || 'Default location')}
spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined } }} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
{/if}
</div>
<div class="s-row">
<div class="s-row-info">
{#if pathsFieldError.dl}
<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.dl}</span>
{/if}
{#if pathsError}
<span class="s-desc" style="color:var(--color-error)">{pathsError}</span>
{/if}
</div>
<div class="s-btn-row">
{#if pathsFieldError.dl && !isExternalServer}
<button class="s-btn" onclick={async () => {
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined } }
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? 'Failed' } }
}}>Create</button>
{/if}
{#if downloadsPathInput.trim() !== confirmedDownloadsPath}
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
{pathsSaved ? 'Saved ✓' : pathsSaving ? 'Saving…' : 'Save'}
</button>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Storage Limit</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Warn when limit is reached</span>
<span class="s-desc">{settingsState.settings.storageLimitGb === null ? 'No limit set' : `Warn above ${settingsState.settings.storageLimitGb} GB`}</span>
</div>
{#if settingsState.settings.storageLimitGb === null}
<button class="s-btn" onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
{:else}
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: Math.max(1, (settingsState.settings.storageLimitGb ?? 10) - 1) })} disabled={(settingsState.settings.storageLimitGb ?? 10) <= 1}></button>
<input type="number" min="1" step="1" class="s-slider-val" style="width:52px"
value={settingsState.settings.storageLimitGb}
oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }) }} />
<span class="s-slider-unit">GB</span>
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: (settingsState.settings.storageLimitGb ?? 10) + 1 })}>+</button>
<button class="s-btn-icon" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
</div>
{/if}
</div>
</div>
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => advStorageOpen = !advStorageOpen}>
<span class="s-label">Advanced</span>
<svg class="s-collapsible-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if advStorageOpen}
<div class="s-collapsible-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Local source path</span>
<span class="s-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
<div class="s-btn-row">
<input class="s-input mono" class:error={!!pathsFieldError.loc}
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined } }} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
{/if}
{#if pathsFieldError.loc && !isExternalServer}
<button class="s-btn" onclick={async () => {
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined } }
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? 'Failed' } }
}}>Create</button>
{/if}
</div>
{#if pathsFieldError.loc}<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
</div>
</div>
{#each extraScanDirs as dir}
<div class="s-row">
<div class="s-row-info">
<span class="s-label mono" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
<span class="s-desc">Extra scan directory</span>
</div>
<button class="s-btn s-btn-danger" onclick={() => removeExtraScanDir(dir)}>Remove</button>
</div>
{/each}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Additional scan path</span>
<span class="s-desc">Include an extra directory in disk usage readings</span>
</div>
<div class="s-btn-row">
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && addExtraScanDir()} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => backupSectionOpen = !backupSectionOpen}>
<span class="s-label">Backup</span>
<svg class="s-collapsible-caret" class:open={backupSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if backupSectionOpen}
<div class="s-collapsible-body">
<p class="s-subsection-title">Library backup</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Create backup</span>
<span class="s-desc">Snapshot your library, categories, and tracker links</span>
</div>
<button class="s-btn s-btn-accent" onclick={createBackup} disabled={backupLoading}>
{backupLoading ? 'Creating…' : 'Create backup'}
</button>
</div>
{#if backupError}
<div class="s-banner s-banner-error">{backupError}</div>
{/if}
{#if backupList.length === 0}
<p class="s-empty">No backups yet — create one above.</p>
{:else}
{#each backupList as backup}
<div class="s-folder-row">
<ClockCounterClockwise size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="s-folder-name" style="font-family:monospace;font-size:var(--text-xs)">{backup.name}</span>
<button class="s-btn-icon" onclick={() => downloadBackup(backup)} title="Download"></button>
<button class="s-btn-icon danger" onclick={() => deleteBackup(backup.url)} disabled={backup.deleting} title="Delete">
<Trash size={12} weight="light" />
</button>
</div>
{/each}
{/if}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Restore from file</span>
<span class="s-desc">{restoreFile ? restoreFile.name : 'Select a .tachibk file'}</span>
</div>
<label class="s-btn" style="cursor:pointer">
Browse
<input type="file" accept=".tachibk,.proto.gz" style="display:none"
onchange={(e) => {
const f = (e.currentTarget as HTMLInputElement).files?.[0] ?? null
restoreFile = f; restoreStatus = null; restoreError = null; validateResult = null; validateError = null
}} />
</label>
</div>
{#if restoreFile}
<div class="s-row">
<div class="s-row-info"></div>
<div class="s-btn-row">
<button class="s-btn" onclick={submitValidate} disabled={validateLoading || restoreLoading}>
{validateLoading ? 'Checking…' : 'Validate'}
</button>
<button class="s-btn s-btn-accent" onclick={submitRestore} disabled={restoreLoading || validateLoading}>
{restoreLoading ? 'Restoring…' : 'Restore'}
</button>
</div>
</div>
{/if}
{#if validateError}
<div class="s-banner s-banner-error">{validateError}</div>
{/if}
{#if validateResult}
{#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0}
<div class="s-row"><span class="s-desc" style="color:var(--color-success,#4caf50)">✓ All sources and trackers present</span></div>
{:else}
{#if validateResult.missingSources.length > 0}
<div class="s-row">
<div class="s-row-info">
<span class="s-label" style="color:var(--color-error)">Missing sources</span>
<span class="s-desc">{validateResult.missingSources.map(s => s.name).join(', ')}</span>
</div>
</div>
{/if}
{#if validateResult.missingTrackers.length > 0}
<div class="s-row">
<div class="s-row-info">
<span class="s-label" style="color:var(--color-error)">Missing trackers</span>
<span class="s-desc">{validateResult.missingTrackers.map(t => t.name).join(', ')}</span>
</div>
</div>
{/if}
{/if}
{/if}
{#if restoreError}
<div class="s-banner s-banner-error">{restoreError}</div>
{/if}
{#if restoreStatus}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">
{restoreStatus.state === 'SUCCESS' ? '✓ Restore complete' :
restoreStatus.state === 'FAILURE' ? '✗ Restore failed' : 'Restoring…'}
</span>
{#if restoreStatus.totalManga > 0}
<span class="s-desc">{restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga</span>
{/if}
</div>
{#if restoreStatus.state !== 'SUCCESS' && restoreStatus.state !== 'FAILURE' && restoreStatus.totalManga > 0}
<div class="s-storage-bar" style="width:160px;flex-shrink:0">
<div class="s-storage-fill" style="width:{Math.round((restoreStatus.mangaProgress / restoreStatus.totalManga) * 100)}%"></div>
</div>
{/if}
</div>
{/if}
<p class="s-subsection-title">App data backup</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Export settings</span>
<span class="s-desc">Save all Moku app settings to a .zip via a native save dialog.</span>
</div>
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
{appDataExporting ? 'Saving…' : 'Export'}
</button>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Import settings</span>
<span class="s-desc">Restore from a previously exported .zip file. Reloads the app immediately.</span>
</div>
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
{appDataImporting ? 'Importing…' : 'Import'}
</button>
</div>
{#if appDataError}
<div class="s-banner s-banner-error">{appDataError}</div>
{/if}
{#if appDataMsg}
<div class="s-row">
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
</div>
{/if}
{#if appDataBackupDir}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Auto-backup location</span>
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
</div>
<button class="s-btn" onclick={() => invoke('open_path', { path: appDataBackupDir })}>Open folder</button>
</div>
{/if}
</div>
{/if}
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => resetSectionOpen = !resetSectionOpen}>
<span class="s-label">Reset</span>
<svg class="s-collapsible-caret" class:open={resetSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if resetSectionOpen}
<div class="s-collapsible-body">
{#each resetItems as item}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">{item.label}</span>
<span class="s-desc">{item.desc}</span>
{#if item.error}<span class="s-desc" style="color:var(--color-error)">{item.error}</span>{/if}
</div>
<div class="s-btn-row">
{#if item.state === 'done'}
<span class="s-pill on">Done</span>
{:else if item.state === 'busy'}
<button class="s-btn" disabled>Working…</button>
{:else if confirming === item.key}
<span class="s-desc" style="color:var(--text-muted)">Sure?</span>
<button class="s-btn s-btn-danger" onclick={() => runReset(item.key)}>Confirm</button>
<button class="s-btn" onclick={() => confirming = null}>Cancel</button>
{:else}
<button
class="s-btn"
class:s-btn-danger={item.confirm}
onclick={() => item.confirm ? (confirming = item.key) : runReset(item.key)}
>Reset</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
@@ -1,274 +0,0 @@
<script lang="ts">
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { toast } from "$lib/state/notifications.svelte";
import { getAdapter } from "$lib/request-manager";
import { syncBackFromTracker } from "$lib/state/tracking.svelte";
import type { Tracker, TrackRecord } from "$lib/types/index";
let trackers = $state<Tracker[]>([]);
let trackersLoading = $state(false);
let trackersError = $state<string | null>(null);
let oauthTrackerId = $state<number | null>(null);
let oauthCallbackInput = $state("");
let oauthSubmitting = $state(false);
let oauthError = $state<string | null>(null);
let credsTrackerId = $state<number | null>(null);
let credsUsername = $state("");
let credsPassword = $state("");
let credsSubmitting = $state(false);
let credsError = $state<string | null>(null);
let loggingOut = $state<number | null>(null);
let syncing = $state(false);
$effect(() => {
if (trackers.length === 0 && !trackersLoading) loadTrackers();
});
async function loadTrackers() {
trackersLoading = true; trackersError = null;
try {
trackers = await getAdapter().getTrackers();
} catch (e: any) {
trackersError = e?.message ?? "Failed to load trackers";
} finally { trackersLoading = false; }
}
async function startOAuth(tracker: Tracker) {
if (!tracker.authUrl) return;
oauthTrackerId = tracker.id; oauthCallbackInput = "";
window.open(tracker.authUrl, "_blank");
}
async function submitOAuth() {
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
oauthSubmitting = true;
try {
await getAdapter().loginTrackerOAuth(oauthTrackerId, oauthCallbackInput.trim());
await loadTrackers();
oauthTrackerId = null; oauthCallbackInput = "";
} catch (e: any) {
oauthError = e?.message ?? "Login failed";
} finally { oauthSubmitting = false; }
}
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; oauthError = null; }
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
async function submitCredentials() {
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
credsSubmitting = true;
try {
await getAdapter().loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword.trim());
await loadTrackers();
credsTrackerId = null; credsUsername = ""; credsPassword = "";
} catch (e: any) {
credsError = e?.message ?? "Login failed";
} finally { credsSubmitting = false; }
}
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; credsError = null; }
async function logoutTracker(trackerId: number) {
loggingOut = trackerId;
try {
await getAdapter().logoutTracker(trackerId);
await loadTrackers();
} catch (e: any) {
trackersError = e?.message ?? "Logout failed";
} finally { loggingOut = null; }
}
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
async function runSyncAll() {
syncing = true;
try {
const adapter = getAdapter();
const allTrackers = await adapter.getTrackersWithRecords();
const loggedIn = allTrackers.filter((t: any) => t.isLoggedIn);
const settings = settingsState.settings;
let totalMarked = 0;
for (const tracker of loggedIn) {
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
if (!record.manga?.id) continue;
const mangaId = record.manga.id;
const chapters = await adapter.getChapters(mangaId);
const prefs = settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker(
[record],
chapters,
{
threshold: settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
adapter.markChaptersRead.bind(adapter),
);
totalMarked += marked.length;
}
}
toast({ kind: "success", message: "Sync complete", detail: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
} catch (e: any) {
toast({ kind: "error", message: "Sync failed", detail: e?.message });
} finally { syncing = false; }
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Connected Trackers</p>
<div class="s-section-body">
{#if trackersError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}</div>
{/if}
{#if trackersLoading}
<p class="s-empty">Loading trackers…</p>
{:else}
{#each trackers as tracker}
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
<div class="s-tracker-identity">
<img src={tracker.icon} alt={tracker.name} class="s-tracker-logo" />
<div class="s-row-info">
<span class="s-label">{tracker.name}</span>
<div class="s-tracker-status-row">
<span class="s-pill" class:on={tracker.isLoggedIn && !tracker.isTokenExpired}>
{tracker.isLoggedIn ? "Connected" : "Not connected"}
</span>
{#if tracker.isLoggedIn && tracker.isTokenExpired}
<span class="s-pill s-pill-warn">Token expired — reconnect</span>
{/if}
</div>
</div>
</div>
<div class="s-tracker-action">
{#if tracker.isLoggedIn && tracker.isTokenExpired}
<button class="s-btn s-btn-accent" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
Reconnect
</button>
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button>
{:else if tracker.isLoggedIn}
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button>
{:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id}
<button class="s-btn" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
{tracker.authUrl ? "Connect via browser →" : "Connect"}
</button>
{/if}
</div>
{#if oauthTrackerId === tracker.id}
<div class="s-tracker-expand">
{#if oauthError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}</div>
{/if}
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
bind:value={oauthCallbackInput}
onkeydown={(e) => { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }}
use:focusEl />
<div class="s-oauth-btns">
<button class="s-btn s-btn-accent" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
{oauthSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="s-btn" onclick={cancelOAuth}>Cancel</button>
</div>
</div>
{/if}
{#if credsTrackerId === tracker.id}
<div class="s-tracker-expand">
{#if credsError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}</div>
{/if}
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} />
<div class="s-oauth-btns">
<button class="s-btn s-btn-accent" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
{credsSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="s-btn" onclick={cancelCredentials}>Cancel</button>
</div>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Sync back from tracker</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Enable sync back</span>
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
</div>
<button class="s-toggle" class:on={settings.trackerSyncBack}
onclick={() => updateSettings({ trackerSyncBack: !settings.trackerSyncBack })}
role="switch" aria-checked={settings.trackerSyncBack} aria-label="Enable sync back">
<span class="s-toggle-thumb"></span>
</button>
</div>
{#if settings.trackerSyncBack}
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Chapter number tolerance</span>
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
</div>
<button role="switch" aria-checked={settings.trackerSyncBackThreshold !== null} aria-label="Chapter number tolerance" class="s-toggle" class:on={settings.trackerSyncBackThreshold !== null}
onclick={() => updateSettings({ trackerSyncBackThreshold: settings.trackerSyncBackThreshold !== null ? null : 20 })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
{#if settings.trackerSyncBackThreshold !== null}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (120)</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.max(1, (settings.trackerSyncBackThreshold ?? 20) - 1) })}></button>
<span class="s-step-val">{settings.trackerSyncBackThreshold}</span>
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.min(20, (settings.trackerSyncBackThreshold ?? 20) + 1) })}>+</button>
</div>
</div>
{/if}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Respect scanlator filter</span>
<span class="s-desc">Only mark chapters matching the series' active scanlator filter</span>
</div>
<button class="s-toggle" class:on={settings.trackerRespectScanlatorFilter}
onclick={() => updateSettings({ trackerRespectScanlatorFilter: !settings.trackerRespectScanlatorFilter })}
role="switch" aria-checked={settings.trackerRespectScanlatorFilter} aria-label="Respect scanlator filter">
<span class="s-toggle-thumb"></span>
</button>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Sync now</span>
<span class="s-desc">Apply tracker progress to all linked manga in your library</span>
</div>
<button class="s-btn" onclick={runSyncAll} disabled={syncing}>
{syncing ? "Syncing…" : "Sync all"}
</button>
</div>
{/if}
</div>
</div>
<style>
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
.s-banner-dismissible:hover { opacity: 0.85; }
</style>
@@ -1,839 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
} from "phosphor-svelte";
import Thumbnail from "$lib/components/manga/Thumbnail.svelte";
import { appState } from "$lib/state/app.svelte";
import { settings } from "$lib/state/settings.svelte";
import { requestManager } from "$lib/request-manager/index";
import { queryCache } from "$lib/core/cache/queryCache";
import { resolvedCover } from "$lib/core/cover/coverResolver";
import { autoLinkLibrary } from "$lib/core/cover/autoLink";
import { toast } from "$lib/state/notifications.svelte";
import { addBookmark } from "$lib/state/app.svelte";
import CoverPickerPanel from "$lib/components/series/CoverPickerPanel.svelte";
import SeriesLinkPanel from "$lib/components/series/SeriesLinkPanel.svelte";
import type { Manga, Chapter, Category } from "$lib/types/index";
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let allCategories: Category[] = $state([]);
let mangaCategories: Category[] = $state([]);
let catsLoading = $state(false);
let queueingAll = $state(false);
let fetchError: string | null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
let linkPickerOpen = $state(false);
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
let coverPickerOpen = $state(false);
let originNavPage = appState.navPage;
const linkedIds = $derived(
appState.previewManga ? (settings.mangaLinks?.[appState.previewManga.id] ?? []) : [],
);
const hasCoverOverride = $derived(
!!settings.mangaPrefs?.[appState.previewManga?.id ?? -1]?.coverUrl
);
const displayManga = $derived(manga ?? appState.previewManga);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.isRead).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? appState.previewManga?.inLibrary ?? false);
const scanlators = $derived(
[...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))],
);
const uploadDates = $derived(
chapters
.map((c) => (c.uploadDate ? new Date(c.uploadDate).getTime() : null))
.filter((d): d is number => d !== null && !isNaN(d)),
);
const statusLabel = $derived(
displayManga?.status
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
: null,
);
const assignedFolders = $derived(mangaCategories.filter((c) => c.id !== 0));
const continueChapter = $derived.by(() => {
if (!chapters.length) return null;
const asc = [...chapters];
const anyRead = asc.some((c) => c.isRead);
const bookmark = displayManga
? appState.bookmarks.find((b) => b.mangaId === displayManga!.id)
: null;
if (bookmark) {
const ch = asc.find((c) => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every((c) => c.isRead);
if (!(isLastChapter && allRead))
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
}
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { ch: asc[0], type: "reread" as const, resumePage: null };
});
const continueLabel = $derived.by(() => {
if (!continueChapter) return "";
const { ch, type, resumePage } = continueChapter;
if (type === "reread") return "Read again";
if (type === "start") return `Start · Ch.${ch.chapterNumber}`;
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
});
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort();
chapterAbort?.abort();
appState.previewManga = null;
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
async function openLinkPicker() {
linkPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
requestManager.getAllManga()
.then((d) => { allMangaForLink = d; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
async function openCoverPicker() {
coverPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
requestManager.getAllManga()
.then((d) => { allMangaForLink = d; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
$effect(() => {
const focal = appState.previewManga;
if (focal) {
originNavPage = appState.navPage;
load(focal.id);
loadCategories(focal.id);
if (settings.autoLinkOnOpen) {
if (allMangaForLink.length) {
autoLinkLibrary(focal, allMangaForLink)
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
} else {
loadingLinkList = true;
requestManager.getAllManga()
.then((nodes) => {
allMangaForLink = nodes;
return autoLinkLibrary(focal, nodes);
})
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
}
}
});
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
manga = appState.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
requestManager.fetchManga(id, dCtrl.signal)
.then((fullManga) => {
if (dCtrl.signal.aborted) return;
manga = fullManga; loadingDetail = false;
})
.catch((e) => {
if (e?.name === "AbortError") return;
manga = appState.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
requestManager.getChapters(id, cCtrl.signal)
.then(async (nodes) => {
if (cCtrl.signal.aborted) return;
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (sorted.length === 0) {
try {
const fetched = await requestManager.fetchChapters(id, cCtrl.signal);
if (!cCtrl.signal.aborted)
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}
if (!cCtrl.signal.aborted) {
chapters = sorted;
if (sorted.length > 0) checkAndMarkCompleted(id, sorted);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
}
async function toggleLibrary() {
if (!manga) return;
togglingLib = true;
const next = !manga.inLibrary;
await requestManager.updateManga(manga.id, { inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
queryCache.clear(`manga:${manga.id}`);
queryCache.clear("library");
togglingLib = false;
toast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
}
async function downloadAll() {
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
if (!ids.length) return;
queueingAll = true;
await requestManager.enqueueChaptersDownload(ids).catch(console.error);
toast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
queueingAll = false;
}
function openSeriesDetail() {
if (!displayManga) return;
appState.activeManga = displayManga;
appState.navPage = originNavPage;
close();
}
function loadCategories(mangaId: number) {
catsLoading = true;
requestManager.getCategories()
.then((cats) => {
allCategories = cats.filter((c: Category) => c.id !== 0);
mangaCategories = allCategories.filter((c: Category) => c.mangas?.nodes.some((m: Manga) => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
const mangaStatus = (manga ?? displayManga)?.status;
const isOngoing = mangaStatus === "ONGOING";
if (chaps.length && !isOngoing) {
const allRead = chaps.every((c) => c.isRead);
const completed = allCategories.find((c) => c.name === "Completed");
if (completed) {
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
}
}
}
async function toggleCategory(cat: Category) {
if (!appState.previewManga) return;
const mangaId = appState.previewManga.id;
const inCat = mangaCategories.some((c) => c.id === cat.id);
await requestManager.updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
if (!inCat && !inLibrary) {
await requestManager.updateManga(mangaId, { inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
queryCache.clear("library");
}
mangaCategories = inCat
? mangaCategories.filter((c) => c.id !== cat.id)
: [...mangaCategories, cat];
}
async function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !appState.previewManga) return;
try {
const cat = await requestManager.createCategory(name);
allCategories = [...allCategories, cat];
await requestManager.updateMangaCategories(appState.previewManga.id, [cat.id], []);
if (!inLibrary) {
await requestManager.updateManga(appState.previewManga.id, { inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
queryCache.clear("library");
}
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
newFolderName = ""; creatingFolder = false;
}
function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) {
folderOpen = false; creatingFolder = false; newFolderName = "";
}
}
$effect(() => {
if (folderOpen) {
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => document.removeEventListener("mousedown", handleFolderOutside);
}
});
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => {
window.removeEventListener("keydown", onKey);
detailAbort?.abort();
chapterAbort?.abort();
});
function focusAction(node: HTMLElement) { node.focus(); }
</script>
{#if appState.previewManga}
<div
class="backdrop"
role="button"
tabindex="-1"
aria-label="Close preview"
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
onkeydown={(e) => { if (e.key === "Escape") close(); }}
>
<div class="modal" role="dialog" aria-label="Manga preview">
<div class="cover-col">
<div class="cover-wrap">
<Thumbnail src={resolvedCover(appState.previewManga.id, appState.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
{#if loadingDetail}
<div class="cover-spinner">
<CircleNotch size={18} weight="light" class="anim-spin" />
</div>
{/if}
</div>
<div class="cover-actions">
<button
class="action-btn"
class:active={inLibrary}
onclick={toggleLibrary}
disabled={togglingLib || loadingDetail}
>
<span class="action-icon">
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
</span>
<span class="action-label">
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
</span>
</button>
<button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span>
</button>
<div class="folder-wrap" bind:this={folderRef}>
<button
class="action-btn"
class:active={assignedFolders.length > 0}
onclick={() => folderOpen = !folderOpen}
>
<span class="action-icon">
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
</span>
<span class="action-label">
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
</span>
</button>
{#if folderOpen}
<div class="folder-menu">
{#if catsLoading}
<p class="folder-empty">Loading…</p>
{:else if allCategories.length === 0 && !creatingFolder}
<p class="folder-empty">No folders yet</p>
{/if}
{#each allCategories as cat}
{@const isIn = mangaCategories.some((c) => c.id === cat.id)}
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
<Folder size={12} weight={isIn ? "fill" : "light"} />
{isIn ? "✓ " : ""}{cat.name}
</button>
{/each}
<div class="folder-divider"></div>
{#if creatingFolder}
<div class="folder-create-row">
<input
class="folder-input"
placeholder="Folder name…"
bind:value={newFolderName}
onkeydown={(e) => {
if (e.key === "Enter") handleFolderCreate();
if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; }
}}
use:focusAction
/>
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>
Add
</button>
</div>
{:else}
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<button
class="action-btn"
class:active={linkedIds.length > 0}
onclick={openLinkPicker}
>
<span class="action-icon">
<LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} />
</span>
<span class="action-label">
{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}
</span>
</button>
<button
class="action-btn"
class:active={hasCoverOverride}
onclick={openCoverPicker}
>
<span class="action-icon">
<Image size={13} weight={hasCoverOverride ? "fill" : "light"} />
</span>
<span class="action-label">Cover Image</span>
</button>
</div>
</div>
<div class="content">
<div class="content-header">
<div class="title-block">
<h2 class="title">{displayManga?.title}</h2>
{#if loadingDetail}
<div class="sk-byline"></div>
{:else if displayManga?.author || displayManga?.artist}
<p class="byline">
{[displayManga?.author, displayManga?.artist]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join(" · ")}
</p>
{/if}
</div>
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body">
{#if fetchError}
<div class="error-banner">{fetchError}</div>
{/if}
{#if loadingDetail}
<div class="sk-row">
<div class="sk-badge"></div>
<div class="sk-badge" style="width:72px"></div>
</div>
{:else}
<div class="badges">
{#if statusLabel}
<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>
{/if}
{#if displayManga?.source}
<span class="badge">{displayManga.source.displayName}</span>
{/if}
{#if inLibrary}
<span class="badge badge-accent">In Library</span>
{/if}
{#if !loadingChapters && unreadCount > 0}
<span class="badge badge-unread">{unreadCount} unread</span>
{/if}
{#if !loadingChapters && bookmarkCount > 0}
<span class="badge">{bookmarkCount} bookmarked</span>
{/if}
</div>
{/if}
<div class="chapter-box">
{#if loadingChapters}
<div class="chapter-loading">
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="chapter-loading-label">Loading chapters…</span>
</div>
{:else if totalCount > 0}
<div class="chapter-meta">
<span class="chapter-label">
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
{readCount > 0 ? ` · ${readCount} read` : ""}
{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}
{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
</span>
{#if unreadCount > 0}
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
{/if}
</div>
{#if readCount > 0}
<div class="progress-track">
<div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div>
</div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => {
const { ch, type, resumePage } = continueChapter!;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = appState.bookmarks.find((b) => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga!.id,
mangaTitle: displayManga!.title,
thumbnailUrl: displayManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
appState.openReader(ch, chapters, displayManga);
close();
}}>
<Play size={12} weight="fill" />{continueLabel}
</button>
{/if}
{:else if !loadingDetail}
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
{/if}
</div>
{#if loadingDetail}
<div class="sk-desc">
<div class="sk-line" style="width:100%"></div>
<div class="sk-line" style="width:88%"></div>
<div class="sk-line" style="width:70%"></div>
</div>
{:else if displayManga?.description}
<div class="desc-block">
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
{#if displayManga.description.length > 220}
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown
size={10}
weight="light"
style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease"
/>
</button>
{/if}
</div>
{/if}
{#if !loadingDetail && displayManga?.genre?.length}
<div class="genres">
{#each displayManga.genre as g}
<button
class="genre-tag"
onclick={() => { appState.genreFilter = g; appState.navPage = "search"; close(); }}
>{g}</button>
{/each}
</div>
{/if}
{#if !loadingDetail}
<div class="meta-table">
<div class="meta-grid">
<div class="meta-col">
<div class="meta-row">
<span class="meta-key">Status</span>
<span class="meta-val">{statusLabel ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Source</span>
<span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Link</span>
{#if displayManga?.realUrl}
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">
Open <ArrowSquareOut size={11} weight="light" />
</a>
{:else}
<span class="meta-val">N/A</span>
{/if}
</div>
</div>
<div class="meta-col">
<div class="meta-row">
<span class="meta-key">Author</span>
<span class="meta-val">{displayManga?.author ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Artist</span>
<span class="meta-val">
{displayManga?.artist && displayManga.artist !== displayManga.author
? displayManga.artist
: (displayManga?.author ?? "N/A")}
</span>
</div>
<div class="meta-row">
<span class="meta-key">Scanlator</span>
<span class="meta-val">
{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}
</span>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#if linkPickerOpen && appState.previewManga}
<SeriesLinkPanel
manga={displayManga ?? appState.previewManga}
allManga={allMangaForLink}
onClose={() => linkPickerOpen = false}
/>
{/if}
{#if coverPickerOpen && appState.previewManga}
<CoverPickerPanel
manga={displayManga ?? appState.previewManga}
allManga={allMangaForLink}
onClose={() => coverPickerOpen = false}
/>
{/if}
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(800px, calc(100vw - 48px));
height: min(560px, calc(100vh - 80px));
display: flex; flex-direction: row;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.16s ease both;
}
.cover-col {
width: 190px; flex-shrink: 0;
background: var(--bg-raised);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
padding: var(--sp-5) var(--sp-4) var(--sp-4);
gap: var(--sp-3); overflow: hidden;
}
.cover-wrap { position: relative; width: 100%; }
:global(.cover) {
width: 100%; aspect-ratio: 2/3;
object-fit: cover; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); display: block;
}
.cover-spinner {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.35); border-radius: var(--radius-md);
color: var(--text-faint);
}
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px var(--sp-3);
border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
cursor: pointer; text-align: left;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.folder-wrap { position: relative; width: 100%; }
.folder-menu {
position: absolute; bottom: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md);
padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10;
animation: scaleIn 0.1s ease both; transform-origin: bottom center;
}
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
.folder-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folder-item.folder-item-on { color: var(--accent-fg); }
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
.folder-input {
flex: 1; min-width: 0;
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;
}
.folder-input:focus { border-color: var(--border-focus); }
.folder-ok {
font-family: var(--font-ui); font-size: var(--text-xs);
padding: 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
}
.folder-ok:disabled { opacity: 0.4; cursor: default; }
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folder-new {
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
background: none; border: none; cursor: pointer; text-align: left; width: 100%;
transition: color var(--t-fast);
}
.folder-new:hover { color: var(--accent-fg); }
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.content-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; flex-shrink: 0;
border-radius: var(--radius-sm); color: var(--text-faint);
background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body {
flex: 1; min-height: 0; overflow-y: auto;
padding: var(--sp-5) var(--sp-6);
display: flex; flex-direction: column; gap: var(--sp-4);
scrollbar-width: none;
}
.content-body::-webkit-scrollbar { display: none; }
.error-banner {
font-family: var(--font-ui); font-size: var(--text-xs);
color: #f59e0b; background: rgba(245,158,11,0.1);
border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm);
padding: 6px var(--sp-3);
}
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.badge {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
}
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.dl-all-btn {
display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
}
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.read-btn {
display: flex; align-items: center; gap: var(--sp-2); align-self: flex-start;
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; transition: filter var(--t-base);
}
.read-btn:hover { filter: brightness(1.1); }
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.desc-toggle {
display: flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base);
}
.desc-toggle:hover { color: var(--accent-fg); }
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre-tag {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
.meta-col { display: flex; flex-direction: column; }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; }
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.8 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -1,195 +0,0 @@
<script lang="ts">
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import Thumbnail from "$lib/components/manga/Thumbnail.svelte";
import { appState } from "$lib/state/app.svelte";
import { requestManager } from "$lib/request-manager/index";
import { toast } from "$lib/state/notifications.svelte";
import type { Manga, Category } from "$lib/types/index";
import ContextMenu, { type MenuEntry } from "$lib/components/common/ContextMenu.svelte";
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
let mangas: Manga[] = $state([]);
let loading = $state(true);
let page = $state(1);
let hasNextPage = $state(false);
let browseType: BrowseType = $state("POPULAR");
let search = $state("");
let searchInput = $state("");
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
async function fetchMangas(type: BrowseType, p: number, q: string) {
if (!appState.activeSource) return;
loading = true; mangas = [];
requestManager.fetchSourceManga(appState.activeSource.id, type, p, q || null)
.then((d) => { mangas = d.mangas; hasNextPage = d.hasNextPage; })
.catch(console.error)
.finally(() => loading = false);
}
$effect(() => { if (appState.activeSource) fetchMangas(browseType, page, search); });
function submitSearch() { search = searchInput.trim(); browseType = "SEARCH"; page = 1; }
function setMode(mode: BrowseType) {
if (mode === browseType) return;
browseType = mode; search = ""; searchInput = ""; page = 1;
}
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
requestManager.getCategories()
.then(d => { categories = d.filter((c: Category) => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
disabled: m.inLibrary,
onClick: () => requestManager.updateManga(m.id, { inLibrary: true })
.then(() => {
mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
toast({ kind: "success", title: "Added to library", body: m.title });
})
.catch((e) => {
toast({ kind: "error", title: "Failed to add to library", body: m.title });
console.error(e);
}),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some((x: Manga) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
onClick: () => requestManager.updateMangaCategories(m.id, [cat.id], []).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const cat = await requestManager.createCategory(name.trim()).catch(console.error);
if (cat) {
categories = [...categories, cat];
await requestManager.updateMangaCategories(m.id, [cat.id], []).catch(console.error);
}
},
},
];
}
</script>
{#if appState.activeSource}
<div class="root">
<div class="header">
<button class="back" onclick={() => appState.activeSource = null}>
<ArrowLeft size={13} weight="light" /><span>Sources</span>
</button>
<span class="source-name">{appState.activeSource.displayName}</span>
</div>
<div class="toolbar">
<div class="tabs">
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
{mode.charAt(0) + mode.slice(1).toLowerCase()}
</button>
{/each}
{#if search}<button class="tab active">Search</button>{/if}
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search source…" bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
</div>
</div>
{#if loading}
<div class="loading-grid">
{#each Array(18) as _}
<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>
{/each}
</div>
{:else if mangas.length === 0}
<div class="empty">No results.</div>
{:else}
<div class="grid">
{#each mangas as m (m.id)}
<button class="card" onclick={() => { appState.activeManga = m; appState.navPage = "library"; }}
oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{/if}
{#if !loading && (page > 1 || hasNextPage)}
<div class="pagination">
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<Prev size={13} weight="light" /> Prev
</button>
<span class="page-num">{page}</span>
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
Next <Next size={13} weight="light" />
</button>
</div>
{/if}
</div>
{/if}
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; gap: var(--sp-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; transition: color var(--t-base); flex-shrink: 0; }
.back:hover { color: var(--text-secondary); }
.source-name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.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); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 200px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.grid, .loading-grid { 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; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 12px; background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.page-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-raised); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -1,130 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
}
let { children, class: cls = "" }: Props = $props();
</script>
<div class="hover-3d {cls}">
<div class="hover-3d-content">
{@render children()}
<div class="shine"></div>
<div class="edge-top"></div>
<div class="edge-left"></div>
</div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<style>
.hover-3d {
display: inline-grid;
perspective: 600px;
--tx: 0;
--ty: 0;
--shine-x: 50%;
--shine-y: 50%;
--shadow-x: 0px;
--shadow-y: 0px;
--ease-out: linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
--ease-hover: linear(0, 0.708 15.2%, 0.927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);
}
.hover-3d > :nth-child(n + 2) {
isolation: isolate;
z-index: 1;
scale: 1.2;
}
.hover-3d > :nth-child(2) { grid-area: 1/1/2/2; }
.hover-3d > :nth-child(3) { grid-area: 1/2/2/3; }
.hover-3d > :nth-child(4) { grid-area: 1/3/2/4; }
.hover-3d > :nth-child(5) { grid-area: 2/1/3/2; }
.hover-3d > :nth-child(6) { grid-area: 2/3/3/4; }
.hover-3d > :nth-child(7) { grid-area: 3/1/4/2; }
.hover-3d > :nth-child(8) { grid-area: 3/2/4/3; }
.hover-3d > :nth-child(9) { grid-area: 3/3/4/4; }
.hover-3d-content {
grid-area: 1/1/4/4;
overflow: hidden;
border-radius: inherit;
position: relative;
transform: rotate3d(var(--tx), var(--ty), 0, 12deg);
transform-style: preserve-3d;
transition:
transform var(--ease-out) 500ms,
scale var(--ease-out) 500ms,
box-shadow ease-out 450ms;
box-shadow:
calc(var(--shadow-x) * 0.6) calc(var(--shadow-y) * 0.6) 8px rgba(0,0,0,0.18),
calc(var(--shadow-x) * 1.0) calc(var(--shadow-y) * 1.0) 18px rgba(0,0,0,0.14),
calc(var(--shadow-x) * 1.6) calc(var(--shadow-y) * 1.6) 36px rgba(0,0,0,0.10),
0 2px 6px rgba(0,0,0,0.12);
}
.hover-3d:hover > .hover-3d-content {
--ease-out: var(--ease-hover);
scale: 1.055;
}
.shine {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 2;
opacity: 0;
border-radius: inherit;
background-image: radial-gradient(
ellipse 80% 60% at var(--shine-x) var(--shine-y),
rgba(255,255,255,0.22) 0%,
rgba(255,255,255,0.08) 30%,
transparent 65%
);
transition: opacity ease-out 350ms;
mix-blend-mode: screen;
}
.hover-3d:hover .shine { opacity: 1; }
.edge-top {
pointer-events: none;
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
z-index: 3;
background: linear-gradient(90deg, transparent 10%, rgba(255,255,255,0.18) 50%, transparent 90%);
opacity: 0;
transition: opacity ease-out 350ms;
}
.edge-left {
pointer-events: none;
position: absolute;
top: 0; left: 0; bottom: 0;
width: 1px;
z-index: 3;
background: linear-gradient(180deg, transparent 10%, rgba(255,255,255,0.12) 50%, transparent 90%);
opacity: 0;
transition: opacity ease-out 350ms;
}
.hover-3d:hover .edge-top,
.hover-3d:hover .edge-left { opacity: 1; }
.hover-3d:has(> :nth-child(2):hover) { --tx: -1; --ty: 1; --shine-x: 15%; --shine-y: 15%; --shadow-x: -8px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(3):hover) { --tx: -1; --ty: 0; --shine-x: 50%; --shine-y: 10%; --shadow-x: 0px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(4):hover) { --tx: -1; --ty: -1; --shine-x: 85%; --shine-y: 15%; --shadow-x: 8px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(5):hover) { --tx: 0; --ty: 1; --shine-x: 10%; --shine-y: 50%; --shadow-x: -8px; --shadow-y: 0px; }
.hover-3d:has(> :nth-child(6):hover) { --tx: 0; --ty: -1; --shine-x: 90%; --shine-y: 50%; --shadow-x: 8px; --shadow-y: 0px; }
.hover-3d:has(> :nth-child(7):hover) { --tx: 1; --ty: 1; --shine-x: 15%; --shine-y: 85%; --shadow-x: -8px; --shadow-y: 8px; }
.hover-3d:has(> :nth-child(8):hover) { --tx: 1; --ty: 0; --shine-x: 50%; --shine-y: 90%; --shadow-x: 0px; --shadow-y: 8px; }
.hover-3d:has(> :nth-child(9):hover) { --tx: 1; --ty: -1; --shine-x: 85%; --shine-y: 85%; --shadow-x: 8px; --shadow-y: 8px; }
</style>
@@ -1,52 +0,0 @@
<script lang="ts">
import { settings } from "$lib/state/settings.svelte";
import { getBlobUrl } from "$lib/core/cache/imageCache";
import { platformService } from "$lib/platform-service/index";
let {
src,
alt = "",
class: cls = "",
loading = "lazy",
decoding = "async",
priority = 0,
onerror = undefined,
...rest
}: {
src: string;
alt?: string;
class?: string;
loading?: string;
decoding?: string;
priority?: number;
onerror?: ((e: Event) => void) | undefined;
[key: string]: any;
} = $props();
const isAuth = $derived((settings.serverAuthMode ?? "NONE") !== "NONE");
let blobUrl = $state("");
let reqId = 0;
$effect(() => {
const _src = src;
const _priority = priority;
const _isAuth = isAuth;
if (!_isAuth || !_src) { blobUrl = ""; return; }
const id = ++reqId;
const bareUrl = _src.startsWith("http") ? _src : `${platformService.getServerUrl()}${_src}`;
getBlobUrl(bareUrl, _priority)
.then(u => { if (id === reqId) blobUrl = u; })
.catch(() => { if (id === reqId) blobUrl = ""; });
});
const resolved = $derived(
isAuth
? (blobUrl || undefined)
: (src ? platformService.plainThumbUrl(src) : undefined)
);
</script>
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
+1
View File
@@ -0,0 +1 @@
export * from './selectPortal';
+40
View File
@@ -0,0 +1,40 @@
import type {Attachment} from 'svelte/attachments';
export function selectPortal(triggerEl: HTMLElement & {__selectMenuEl?: HTMLElement | null;}): Attachment<Element> {
return (menuEl: Element) => {
const menu = menuEl as HTMLElement;
function position() {
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
const rect = triggerEl.getBoundingClientRect();
const top = rect.bottom / zoom + 4;
const right = rect.right / zoom;
const width = menu.offsetWidth;
const left = Math.max(8, right - width);
menu.style.position = 'fixed';
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
}
menu.style.visibility = 'hidden';
document.body.appendChild(menu);
triggerEl.__selectMenuEl = menu;
requestAnimationFrame(() => {
position();
menu.style.visibility = '';
});
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
return () => {
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
triggerEl.__selectMenuEl = null;
menu.remove();
};
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from '$lib/core/util' export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from '$lib/core/util';
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean { export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return item => predicates.every(p => p(item)) return (item) => predicates.every((p) => p(item));
} }
+15 -9
View File
@@ -1,11 +1,11 @@
export interface PaginationState { export interface PaginationState {
visible: number visible: number;
} }
export interface PaginationResult<T> { export interface PaginationResult<T> {
items: T[] items: T[];
hasMore: boolean hasMore: boolean;
remaining: number remaining: number;
} }
export function createPaginator<T>(pageSize: number) { export function createPaginator<T>(pageSize: number) {
@@ -15,9 +15,15 @@ export function createPaginator<T>(pageSize: number) {
items: all.slice(0, visible), items: all.slice(0, visible),
hasMore: all.length > visible, hasMore: all.length > visible,
remaining: Math.max(0, all.length - visible), remaining: Math.max(0, all.length - visible),
} };
}, },
nextVisible(current: number): number { return current + pageSize },
reset(): number { return pageSize }, nextVisible(current: number): number {
} return current + pageSize;
} },
reset(): number {
return pageSize;
},
};
}
+19 -16
View File
@@ -1,26 +1,29 @@
export interface AsyncQueue<T> { export interface AsyncQueue<T> {
enqueue(item: T): void enqueue(item: T): void;
drain(): void drain(): void;
clear(): void clear(): void;
size(): number size(): number;
} }
export function createAsyncQueue<T>(worker: (item: T) => Promise<void>, concurrency = 1): AsyncQueue<T> { export function createAsyncQueue<T>(
const queue: T[] = [] worker: (item: T) => Promise<void>,
let active = 0 concurrency = 1,
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
function next() { function next() {
while (active < concurrency && queue.length > 0) { while (active < concurrency && queue.length > 0) {
const item = queue.shift()! const item = queue.shift()!;
active++ active++;
worker(item).finally(() => { active--; next() }) worker(item).finally(() => { active--; next(); });
} }
} }
return { return {
enqueue(item) { queue.push(item); next() }, enqueue(item) { queue.push(item); next(); },
drain() { next() }, drain() { next(); },
clear() { queue.length = 0 }, clear() { queue.length = 0; },
size() { return queue.length }, size() { return queue.length; },
} };
} }
+24 -15
View File
@@ -1,24 +1,33 @@
export interface SearchResult<T> { export interface SearchResult<T> {
item: T item: T;
score: number score: number;
} }
export function searchItems<T>(items: T[], query: string, getField: (item: T) => string): T[] { export function searchItems<T>(
const q = query.trim().toLowerCase() items: T[],
if (!q) return items query: string,
return items.filter(item => getField(item).toLowerCase().includes(q)) getField: (item: T) => string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
} }
export function searchWithScore<T>(items: T[], query: string, getField: (item: T) => string): SearchResult<T>[] { export function searchWithScore<T>(
const q = query.trim().toLowerCase() items: T[],
if (!q) return items.map(item => ({ item, score: 0 })) query: string,
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
return items return items
.map(item => { .map(item => {
const field = getField(item).toLowerCase() const field = getField(item).toLowerCase();
if (!field.includes(q)) return null if (!field.includes(q)) return null;
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0 const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
return { item, score } return { item, score };
}) })
.filter((r): r is SearchResult<T> => r !== null) .filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score);
} }
+17 -16
View File
@@ -1,31 +1,32 @@
export type SortDir = 'asc' | 'desc' export type SortDir = "asc" | "desc";
export interface SortField<T> { export interface SortField<T> {
key: string key: string;
comparator: (a: T, b: T, context?: Record<string, unknown>) => number comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
} }
export interface SortConfig<T> { export interface SortConfig<T> {
fields: SortField<T>[] fields: SortField<T>[];
defaultField: string defaultField: string;
defaultDir: SortDir defaultDir: SortDir;
} }
export interface Sorter<T> { export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[] sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
} }
export function createSorter<T>(config: SortConfig<T>): Sorter<T> { export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
const fieldMap = new Map(config.fields.map(f => [f.key, f])) const fieldMap = new Map(config.fields.map(f => [f.key, f]));
return { return {
sort(items, field, dir, context) { sort(items, field, dir, context) {
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField) const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
if (!f) return [...items] if (!f) return [...items];
const d = dir ?? config.defaultDir const d = dir ?? config.defaultDir;
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
const cmp = f.comparator(a, b, context) const cmp = f.comparator(a, b, context);
return d === 'asc' ? cmp : -cmp return d === "asc" ? cmp : -cmp;
}) });
}, },
} };
} }
+32 -28
View File
@@ -1,35 +1,39 @@
const _inflight = new Map<string, Promise<unknown>>()
export async function runConcurrent<T>( export async function runConcurrent<T>(
items: T[], items: T[],
fn: (item: T) => Promise<void>, fn: (item: T) => Promise<void>,
signal: AbortSignal, signal: AbortSignal,
concurrency = 6, concurrency = 6,
): Promise<void> { ): Promise<void> {
let i = 0 let index = 0;
async function worker() {
while (i < items.length) { async function worker() {
if (signal.aborted) return while (index < items.length) {
const item = items[i++] if (signal.aborted) return;
await fn(item).catch(() => {}) const item = items[index++];
await fn(item).catch(() => {});
}
} }
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)) await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, worker));
} }
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T> const inflight = new Map<string, Promise<unknown>>();
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
export function dedupeRequest<T>( export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>), keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>, factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) { ): Promise<T> | ((key: string) => Promise<T>) {
if (typeof keyOrFn === 'function') { if (typeof keyOrFn === 'function') {
const fn = keyOrFn const fn = keyOrFn;
return (key: string) => dedupeRequest(key, () => fn(key)) return (key: string) => dedupeRequest(key, () => fn(key));
} }
const key = keyOrFn
if (_inflight.has(key)) return _inflight.get(key) as Promise<T> const key = keyOrFn;
const p = factory!().finally(() => _inflight.delete(key)) if (inflight.has(key)) return inflight.get(key) as Promise<T>;
_inflight.set(key, p)
return p const request = factory!().finally(() => inflight.delete(key));
} inflight.set(key, request);
return request;
}
+20 -15
View File
@@ -1,22 +1,27 @@
export interface PaginatedQuery<T> { export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]> fetchPage(page: number): Promise<T[]>;
reset(): void reset(): void;
hasMore(): boolean hasMore(): boolean;
} }
export interface PaginatedQueryConfig<T> { export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }> fetcher: (page: number) => Promise<{items: T[]; hasNextPage: boolean;}>;
} }
export function createPaginatedQuery<T>(config: PaginatedQueryConfig<T>): PaginatedQuery<T> { export function createPaginatedQuery<T>(config: PaginatedQueryConfig<T>): PaginatedQuery<T> {
let _hasMore = true let hasMore = true;
return {
async fetchPage(page) { return {
const { items, hasNextPage } = await config.fetcher(page) async fetchPage(page) {
_hasMore = hasNextPage const {items, hasNextPage} = await config.fetcher(page);
return items hasMore = hasNextPage;
}, return items;
reset() { _hasMore = true }, },
hasMore() { return _hasMore }, reset() {
} hasMore = true;
} },
hasMore() {
return hasMore;
},
};
}
+29 -24
View File
@@ -1,31 +1,36 @@
export interface RetryOptions { export interface RetryOptions {
maxAttempts?: number maxAttempts?: number;
baseDelayMs?: number baseDelayMs?: number;
maxDelayMs?: number maxDelayMs?: number;
shouldRetry?: (err: unknown, attempt: number) => boolean shouldRetry?: (error: unknown, attempt: number) => boolean;
} }
export async function fetchWithRetry<T>( export async function fetchWithRetry<T>(
fetcher: () => Promise<T>, fetcher: () => Promise<T>,
options: RetryOptions = {}, options: RetryOptions = {},
): Promise<T> { ): Promise<T> {
const { const {
maxAttempts = 3, maxAttempts = 3,
baseDelayMs = 500, baseDelayMs = 500,
maxDelayMs = 10_000, maxDelayMs = 10_000,
shouldRetry = () => true, shouldRetry = () => true,
} = options } = options;
let lastErr: unknown let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
return await fetcher() try {
} catch (err) { return await fetcher();
lastErr = err } catch (error) {
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err lastError = error;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs) if (attempt === maxAttempts || !shouldRetry(error, attempt)) {
await new Promise(r => setTimeout(r, delay)) throw error;
}
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
} }
}
throw lastErr throw lastError;
} }
+3
View File
@@ -0,0 +1,3 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
+365 -164
View File
@@ -1,187 +1,388 @@
const DEFAULT_URL = 'http://127.0.0.1:4567' export type AuthMode = 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
interface AuthConfig { export class AuthRequiredError extends Error {
baseUrl: string constructor(msg = 'Authentication required') {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' super(msg)
user?: string this.name = 'AuthRequiredError'
pass?: string
}
export interface UiAuthDebugStatus {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
hasSession: boolean
hasRefreshToken: boolean
accessExpiresAt: number | null
refreshExpiresAt: number | null
accessExpiresInMs: number | null
refreshExpiresInMs: number | null
shouldRefreshSoon: boolean
refreshInFlight: boolean
skewMs: number
}
const SKEW_MS = 60_000 * 2
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
let accessToken: string | null = null
let refreshToken: string | null = null
let accessExpiresAt: number | null = null
let refreshExpiresAt: number | null = null
let refreshInFlight = false
function parseExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
} }
} }
export const authSession = { const TOKEN_KEY = 'moku_access_token'
clearTokens() { const UI_SESSION_KEY = 'moku_ui_auth_session'
accessToken = null const REFRESH_SKEW_MS = 30_000
refreshToken = null
accessExpiresAt = null interface StoredToken {
refreshExpiresAt = null base: string
token: string
}
interface UiSession {
base: string
accessToken: string
refreshToken?: string
clientMutationId?: string
accessExpiresAt?: number | null
refreshExpiresAt?: number | null
}
interface JwtSettings {
jwtAudience?: string | null
jwtRefreshExpiry?: string | null
jwtTokenExpiry?: string | null
}
let _session: UiSession | null = null
let _accessToken: string | null = null
let _accessTokenBase: string | null = null
let _refreshPromise: Promise<string | null> | null = null
let _jwtSettings: JwtSettings | null = null
let _jwtSettingsBase: string | null = null
let _jwtSettingsFetchedAt = 0
let _serverBase = 'http://127.0.0.1:4567'
let _authMode: AuthMode = 'NONE'
let _basicUser = ''
let _basicPass = ''
export function configureAuth(base: string, mode: AuthMode, user = '', pass = '') {
_serverBase = base.replace(/\/$/, '')
_authMode = mode
_basicUser = user
_basicPass = pass
}
export function getServerBase(): string {
return _serverBase
}
export function getAuthMode(): AuthMode {
return _authMode
}
function timeoutSignal(ms: number): AbortSignal {
return AbortSignal.timeout(ms)
}
function gqlBody(query: string, variables?: Record<string, unknown>): string {
return JSON.stringify({ query, variables })
}
function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: 'Basic ' + btoa(`${user}:${pass}`) }
}
function bearerHeader(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` }
}
function parseIsoDuration(d: string): number | null {
const m = d.match(/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/)
if (!m) return null
let ms = 0
if (m[1]) ms += +m[1] * 365.25 * 86400000
if (m[2]) ms += +m[2] * 30.44 * 86400000
if (m[3]) ms += +m[3] * 86400000
if (m[4]) ms += +m[4] * 3600000
if (m[5]) ms += +m[5] * 60000
if (m[6]) ms += parseFloat(m[6]) * 1000
return ms
}
function decodeJwtExpiry(token: string): number | null {
try {
const part = token.split('.')[1]
if (!part) return null
const pad = part.replace(/-/g, '+').replace(/_/g, '/')
const json = JSON.parse(atob(pad.padEnd(pad.length + ((4 - pad.length % 4) % 4), '='))) as { exp?: number }
return typeof json.exp === 'number' ? json.exp * 1000 : null
} catch { return null }
}
function isExpired(at?: number | null, skew = REFRESH_SKEW_MS): boolean {
if (!at || !Number.isFinite(at)) return false
return Date.now() >= at - skew
}
function readStoredSession(): UiSession | null {
try { return JSON.parse(sessionStorage.getItem(UI_SESSION_KEY) ?? 'null') } catch { return null }
}
function readStoredToken(): StoredToken | null {
try { return JSON.parse(sessionStorage.getItem(TOKEN_KEY) ?? 'null') } catch { return null }
}
export const uiAuth = {
getSession(): UiSession | null {
if (_session?.base === _serverBase) return _session
const stored = readStoredSession()
if (!stored || stored.base !== _serverBase) {
sessionStorage.removeItem(UI_SESSION_KEY)
sessionStorage.removeItem(TOKEN_KEY)
_session = _accessToken = _accessTokenBase = null
return null
}
_session = stored
_accessToken = stored.accessToken
_accessTokenBase = stored.base
return _session
},
setSession(session: Omit<UiSession, 'base'>) {
_session = { ...session, base: _serverBase }
_accessToken = session.accessToken
_accessTokenBase = _serverBase
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_session))
sessionStorage.removeItem(TOKEN_KEY)
},
getToken(): string | null {
const s = uiAuth.getSession()
if (!s || isExpired(s.accessExpiresAt, 0)) return null
if (_accessToken && _accessTokenBase === _serverBase) return _accessToken
const stored = readStoredToken()
if (!stored || stored.base !== _serverBase) {
sessionStorage.removeItem(TOKEN_KEY)
_accessToken = _accessTokenBase = null
return null
}
_accessToken = stored.token
_accessTokenBase = stored.base
return _accessToken
},
setToken(t: string) {
const existing = uiAuth.getSession()
if (existing?.refreshToken) {
uiAuth.setSession({ ...existing, accessToken: t, ...expiryFromJwt(t, _jwtSettings) })
return
}
_accessToken = t
_accessTokenBase = _serverBase
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base: _serverBase, token: t }))
},
setLoginSession(
payload: { accessToken: string; refreshToken: string; clientMutationId?: string },
jwt: JwtSettings | null,
) {
uiAuth.setSession({
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
clientMutationId: payload.clientMutationId,
...expiryFromJwt(payload.accessToken, jwt),
})
},
updateAccessToken(
payload: { accessToken: string; clientMutationId?: string },
jwt: JwtSettings | null,
) {
const s = uiAuth.getSession()
if (!s) return
uiAuth.setSession({
...s,
accessToken: payload.accessToken,
clientMutationId: payload.clientMutationId ?? s.clientMutationId,
...expiryFromJwt(payload.accessToken, jwt),
})
},
clearToken() {
_session = _accessToken = _accessTokenBase = null
sessionStorage.removeItem(UI_SESSION_KEY)
sessionStorage.removeItem(TOKEN_KEY)
}, },
} }
export function getUIAccessToken(): string | null { function expiryFromJwt(token: string, jwt: JwtSettings | null) {
return accessToken
}
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
const now = Date.now() const now = Date.now()
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
return { return {
mode: config.mode, accessExpiresAt: decodeJwtExpiry(token) ?? (jwt?.jwtTokenExpiry ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null),
hasSession: accessToken !== null, refreshExpiresAt: jwt?.jwtRefreshExpiry ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null,
hasRefreshToken: refreshToken !== null,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs,
refreshExpiresInMs,
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
refreshInFlight,
skewMs: SKEW_MS,
} }
} }
export function configureAuth( async function fetchJwtSettings(): Promise<JwtSettings | null> {
baseUrl: string, try {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', const res = await fetchAuthenticated(`${_serverBase}/api/graphql`, {
user?: string, method: 'POST',
pass?: string, headers: { 'Content-Type': 'application/json' },
): void { body: gqlBody(`query { settings { jwtAudience jwtRefreshExpiry jwtTokenExpiry } }`),
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass } }, timeoutSignal(5000))
authSession.clearTokens() if (!res.ok) return null
const json = await res.json()
const s = json?.data?.settings
if (!s) return null
return {
jwtAudience: s.jwtAudience ?? null,
jwtRefreshExpiry: s.jwtRefreshExpiry ?? null,
jwtTokenExpiry: s.jwtTokenExpiry ?? null,
}
} catch { return null }
} }
export function authHeaders(): Record<string, string> { async function getJwtSettings(force = false): Promise<JwtSettings | null> {
if (config.mode === 'BASIC_AUTH' && config.user && config.pass) { const fresh = Date.now() - _jwtSettingsFetchedAt < 60_000
return { Authorization: 'Basic ' + btoa(`${config.user}:${config.pass}`) } if (!force && _jwtSettingsBase === _serverBase && _jwtSettings && fresh) return _jwtSettings
} _jwtSettings = await fetchJwtSettings()
if (config.mode === 'UI_LOGIN' && accessToken) { _jwtSettingsBase = _serverBase
return { Authorization: `Bearer ${accessToken}` } _jwtSettingsFetchedAt = Date.now()
} return _jwtSettings
return {}
} }
async function gqlRaw(query: string, variables?: Record<string, unknown>): Promise<unknown> { export async function fetchAuthenticated(
const res = await fetch(`${config.baseUrl}/api/graphql`, { url: string,
method: 'POST', init: RequestInit = {},
headers: { 'Content-Type': 'application/json', ...authHeaders() }, signal?: AbortSignal,
body: JSON.stringify({ query, variables }), ): Promise<Response> {
const baseHeaders = { ...(init.headers as Record<string, string> ?? {}) }
if (_authMode === 'BASIC_AUTH') {
return fetch(url, {
...init, signal, credentials: 'omit',
headers: { ...baseHeaders, ...(_basicUser && _basicPass ? basicHeader(_basicUser, _basicPass) : {}) },
})
}
if (_authMode === 'UI_LOGIN') {
const token = await getUIAccessToken()
if (!token) throw new AuthRequiredError()
let res = await fetch(url, {
...init, signal, credentials: 'omit',
headers: { ...baseHeaders, ...bearerHeader(token) },
})
if (res.status !== 401) return res
const refreshed = await refreshUiAccessToken(true)
if (!refreshed) return res
return fetch(url, {
...init, signal, credentials: 'omit',
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
})
}
return fetch(url, { ...init, signal, credentials: 'omit' })
}
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
const s = uiAuth.getSession()
if (!s) return null
if (forceRefresh || isExpired(s.accessExpiresAt)) return refreshUiAccessToken(true)
return s.accessToken
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
const s = uiAuth.getSession()
if (!s) return null
if (!s.refreshToken) {
if (force && isExpired(s.accessExpiresAt, 0)) return null
return s.accessToken
}
if (!force && !isExpired(s.accessExpiresAt)) return s.accessToken
if (isExpired(s.refreshExpiresAt)) { uiAuth.clearToken(); return null }
if (_refreshPromise) return _refreshPromise
_refreshPromise = (async () => {
const jwt = await getJwtSettings().catch(() => null)
const res = await fetch(`${_serverBase}/api/graphql`, {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: gqlBody(
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken clientMutationId
}
}`,
{ refreshToken: s.refreshToken, clientMutationId: s.clientMutationId },
),
signal: timeoutSignal(5000),
})
if (!res.ok) {
if (res.status === 401 || res.status === 403) { uiAuth.clearToken(); return null }
throw new Error(`Token refresh failed (${res.status})`)
}
const json = await res.json()
const refreshed = json?.data?.refreshToken
const next: string | undefined = refreshed?.accessToken
if (!next) { uiAuth.clearToken(); return null }
uiAuth.updateAccessToken({ accessToken: next, clientMutationId: refreshed?.clientMutationId }, jwt)
return next
})().finally(() => { _refreshPromise = null })
return _refreshPromise
}
export async function loginUI(user: string, pass: string): Promise<void> {
const res = await fetch(`${_serverBase}/api/graphql`, {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: gqlBody(
`mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken refreshToken clientMutationId
}
}`,
{ username: user, password: pass },
),
signal: timeoutSignal(8000),
}) })
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`Login request failed (${res.status})`)
const json = await res.json() const json = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message) const payload = json?.data?.login
return json.data if (!payload?.accessToken || !payload?.refreshToken) {
throw new Error(json?.errors?.[0]?.message ?? 'Login failed')
}
const jwt = await getJwtSettings(true).catch(() => null)
uiAuth.setLoginSession({
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
clientMutationId: typeof payload.clientMutationId === 'string' ? payload.clientMutationId : undefined,
}, jwt)
_authMode = 'UI_LOGIN'
_basicUser = user
_basicPass = ''
}
export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${_serverBase}/api/graphql`, {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/json', ...basicHeader(user, pass) },
body: gqlBody('{ __typename }'),
signal: timeoutSignal(5000),
})
if (!res.ok) throw new Error(`Authentication failed (${res.status})`)
_authMode = 'BASIC_AUTH'
_basicUser = user
_basicPass = pass
}
export async function logout(): Promise<void> {
uiAuth.clearToken()
_authMode = 'NONE'
_basicUser = ''
_basicPass = ''
} }
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> { export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
try { try {
const res = await fetch(`${config.baseUrl}/api/graphql`, { const headers: Record<string, string> = { 'Content-Type': 'application/json' }
method: 'POST', if (_authMode === 'BASIC_AUTH' && _basicUser && _basicPass) {
headers: { 'Content-Type': 'application/json', ...authHeaders() }, Object.assign(headers, basicHeader(_basicUser, _basicPass))
body: JSON.stringify({ query: '{ aboutServer { name } }' }), } else if (_authMode === 'UI_LOGIN') {
const token = await getUIAccessToken()
if (!token) return 'auth_required'
Object.assign(headers, bearerHeader(token))
}
const res = await fetch(`${_serverBase}/api/graphql`, {
method: 'POST', credentials: 'omit', headers,
body: gqlBody('{ __typename }'),
signal: timeoutSignal(5000),
}) })
if (res.status === 401 || res.status === 403) return 'auth_required' if (res.ok) return 'ok'
if (!res.ok) return 'unreachable' if (res.status === 401) return 'auth_required'
const json = await res.json()
const isAuthError = json.errors?.some((e: { message: string }) =>
/unauthorized|unauthenticated/i.test(e.message)
)
return isAuthError ? 'auth_required' : 'ok'
} catch {
return 'unreachable' return 'unreachable'
} } catch { return 'unreachable' }
} }
export async function loginBasic(user: string, pass: string): Promise<void> {
config.user = user
config.pass = pass
config.mode = 'BASIC_AUTH'
const probe = await probeServer()
if (probe !== 'ok') throw new Error('Invalid credentials')
}
const LOGIN_MUTATION = `
mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken refreshToken
}
}
`
const REFRESH_MUTATION = `
mutation RefreshToken($refreshToken: String!) {
refreshToken(input: { refreshToken: $refreshToken }) {
accessToken
}
}
`
export async function loginUI(user: string, pass: string): Promise<void> {
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as {
login: { accessToken: string; refreshToken: string }
}
accessToken = data.login.accessToken
refreshToken = data.login.refreshToken
accessExpiresAt = parseExpiry(accessToken)
refreshExpiresAt = parseExpiry(refreshToken)
config.mode = 'UI_LOGIN'
config.user = user
}
export async function refreshAccessToken(): Promise<boolean> {
if (!refreshToken) return false
try {
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
refreshToken: { accessToken: string }
}
accessToken = data.refreshToken.accessToken
accessExpiresAt = parseExpiry(accessToken)
return true
} catch {
return false
}
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
if (config.mode !== 'UI_LOGIN') return null
if (!refreshToken) return null
const now = Date.now()
if (!force && accessExpiresAt !== null && accessExpiresAt - now > SKEW_MS) return accessToken
if (refreshInFlight) return accessToken
refreshInFlight = true
try {
const ok = await refreshAccessToken()
return ok ? accessToken : null
} finally {
refreshInFlight = false
}
}
+74 -247
View File
@@ -1,256 +1,83 @@
import { invoke } from "@tauri-apps/api/core"; import type {Settings} from '$lib/types/settings';
import {
persistSettings,
persistLibrary,
persistUpdates,
} from "$lib/core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const; export interface HistoryBackupPayload {
history: unknown[];
export async function exportAppData(): Promise<void> { bookmarks: unknown[];
const entries: [string, string][] = await invoke("read_store_files", { markers: unknown[];
names: [...STORE_FILES], readLog: unknown[];
}); readingStats: Record<string, unknown>;
dailyReadCounts: Record<string, number>;
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("export_app_data", { bytes: Array.from(zip) });
} }
export async function importAppData(): Promise<void> { export interface AppDataBackup {
const raw: number[] = await invoke("import_app_data"); version: 1;
const files = parseZip(new Uint8Array(raw)); exportedAt: string;
settings: Settings;
const decode = (name: string) => { history: HistoryBackupPayload;
const bytes = files.get(name);
if (!bytes) throw new Error(`Backup is missing ${name}`);
return JSON.parse(new TextDecoder().decode(bytes));
};
const s = decode("settings.json");
const l = decode("library.json");
const u = decode("updates.json");
await Promise.all([
persistSettings({
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
}),
]);
await showExitModal();
invoke("exit_app");
} }
function showExitModal(): Promise<void> { function isObject(value: unknown): value is Record<string, unknown> {
return new Promise(resolve => { return typeof value === 'object' && value !== null;
const backdrop = document.createElement("div");
backdrop.className = "s-backdrop";
backdrop.style.cssText = "z-index:99999";
const modal = document.createElement("div");
modal.style.cssText = [
"background:var(--bg-surface)",
"border:1px solid var(--border-base)",
"border-radius:var(--radius-2xl)",
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
"width:min(400px,calc(100vw - 40px))",
"display:flex",
"flex-direction:column",
"overflow:hidden",
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
].join(";");
const header = document.createElement("div");
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
const title = document.createElement("p");
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
title.textContent = "Import complete";
header.appendChild(title);
const body = document.createElement("div");
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
const sub = document.createElement("p");
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
const counter = document.createElement("p");
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
counter.textContent = "Closing in 3…";
body.append(sub, counter);
const footer = document.createElement("div");
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
const btn = document.createElement("button");
btn.className = "s-btn s-btn-danger";
btn.textContent = "Close now";
footer.appendChild(btn);
modal.append(header, body, footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
let secs = 3;
const tick = setInterval(() => {
secs--;
counter.textContent = secs > 0 ? `Closing in ${secs}` : "Closing…";
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
}, 1000);
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
});
} }
export async function autoBackupAppData(): Promise<void> { export function buildAppDataBackup(settings: Settings, history: HistoryBackupPayload): AppDataBackup {
try { return {
const entries: [string, string][] = await invoke("read_store_files", { version: 1,
names: [...STORE_FILES], exportedAt: new Date().toISOString(),
settings,
history,
};
}
export function parseAppDataBackup(raw: string): AppDataBackup {
const parsed = JSON.parse(raw) as unknown;
if (!isObject(parsed)) throw new Error('Backup file is not a valid object');
if (parsed.version !== 1) throw new Error('Unsupported backup format version');
if (!isObject(parsed.settings)) throw new Error('Backup is missing settings data');
if (!isObject(parsed.history)) throw new Error('Backup is missing history data');
const history = parsed.history;
return {
version: 1,
exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : new Date().toISOString(),
settings: parsed.settings as unknown as Settings,
history: {
history: Array.isArray(history.history) ? history.history : [],
bookmarks: Array.isArray(history.bookmarks) ? history.bookmarks : [],
markers: Array.isArray(history.markers) ? history.markers : [],
readLog: Array.isArray(history.readLog) ? history.readLog : [],
readingStats: isObject(history.readingStats) ? history.readingStats : {},
dailyReadCounts: isObject(history.dailyReadCounts) ? (history.dailyReadCounts as Record<string, number>) : {},
},
};
}
export function downloadAppDataBackup(backup: AppDataBackup, filename = 'moku-app-backup.json'): void {
const blob = new Blob([JSON.stringify(backup, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function pickAppDataBackupFile(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', () => {
const file = input.files?.[0] ?? null;
resolve(file);
}, {once: true});
input.click();
}); });
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
} }
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
const buf = new ArrayBuffer(30 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint32(14, crc32(data), true);
v.setUint32(18, data.byteLength, true);
v.setUint32(22, data.byteLength, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, 0, true);
new Uint8Array(buf).set(name, 30);
return new Uint8Array(buf);
}
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
const buf = new ArrayBuffer(46 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint16(14, 0, true);
v.setUint32(16, crc32(data), true);
v.setUint32(20, data.byteLength, true);
v.setUint32(24, data.byteLength, true);
v.setUint16(28, name.byteLength, true);
v.setUint16(30, 0, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, 0, true);
v.setUint32(42, offset, true);
new Uint8Array(buf).set(name, 46);
return new Uint8Array(buf);
}
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
const buf = new ArrayBuffer(22);
const v = new DataView(buf);
v.setUint32(0, 0x06054b50, true);
v.setUint16(4, 0, true);
v.setUint16(6, 0, true);
v.setUint16(8, count, true);
v.setUint16(10, count, true);
v.setUint32(12, cdSize, true);
v.setUint32(16, cdOffset, true);
v.setUint16(20, 0, true);
return new Uint8Array(buf);
}
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const offsets: number[] = [];
let pos = 0;
for (const { name, bytes } of files) {
const nameBytes = enc.encode(name);
const lh = localHeader(nameBytes, bytes);
offsets.push(pos);
parts.push(lh, bytes);
pos += lh.byteLength + bytes.byteLength;
}
const cdParts = files.map(({ name, bytes }, i) =>
centralHeader(enc.encode(name), bytes, offsets[i])
);
const cd = concat(cdParts);
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
}
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const files = new Map<string, Uint8Array>();
let pos = 0;
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
const fnLen = view.getUint16(pos + 26, true);
const exLen = view.getUint16(pos + 28, true);
const cSize = view.getUint32(pos + 18, true);
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
const start = pos + 30 + fnLen + exLen;
files.set(name, data.subarray(start, start + cSize));
pos = start + cSize;
}
return files;
}
function concat(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
return out;
}
+112 -93
View File
@@ -1,134 +1,153 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import {fetchAuthenticated, getAuthMode} from '$lib/core/auth';
import { settingsState } from "$lib/state/settings.svelte"; import {resolveImageUrl} from '$lib/core/image';
import { getUIAccessToken } from "$lib/core/auth";
const cache = new Map<string, string>(); interface CacheEntry {
const inflight = new Map<string, Promise<string>>(); value: string;
const MAX_CONCURRENT = 6; revokable: boolean;
let active = 0; }
let drainScheduled = false;
let clearing = false;
interface QueueEntry { interface QueueEntry {
url: string; url: string;
priority: number; priority: number;
resolve: (v: string) => void; resolve: (value: string) => void;
reject: (e: unknown) => void; reject: (error: unknown) => void;
} }
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<string>>();
const queue: QueueEntry[] = []; const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> { const MAX_CONCURRENT = 6;
const mode = settingsState.serverAuthMode ?? "NONE"; let active = 0;
if (mode === "UI_LOGIN") { let drainScheduled = false;
const token = await getUIAccessToken(); let clearing = false;
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = settingsState.serverAuthUser?.trim() ?? "";
const pass = settingsState.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
}
async function doFetch(url: string): Promise<string> { async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders(); const resolved = resolveImageUrl(url) ?? url;
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`); if (getAuthMode() === 'NONE') {
const blob = await res.blob(); cache.set(url, {value: resolved, revokable: false});
if (clearing) throw new DOMException("Cancelled", "AbortError"); return resolved;
const blobUrl = URL.createObjectURL(blob); }
cache.set(url, blobUrl);
return blobUrl; const response = await fetchAuthenticated(resolved);
if (!response.ok) throw new Error(String(response.status));
const blob = await response.blob();
if (clearing) throw new DOMException('Cancelled', 'AbortError');
const objectUrl = URL.createObjectURL(blob);
cache.set(url, {value: objectUrl, revokable: true});
return objectUrl;
} }
function insertSorted(entry: QueueEntry) { function insertSorted(entry: QueueEntry) {
let lo = 0, hi = queue.length; let lo = 0;
while (lo < hi) { let hi = queue.length;
const mid = (lo + hi) >>> 1;
if (queue[mid].priority > entry.priority) lo = mid + 1; while (lo < hi) {
else hi = mid; const mid = (lo + hi) >>> 1;
} if (queue[mid].priority > entry.priority) lo = mid + 1;
queue.splice(lo, 0, entry); else hi = mid;
}
queue.splice(lo, 0, entry);
} }
function drain() { function drain() {
drainScheduled = false; drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!; while (active < MAX_CONCURRENT && queue.length > 0) {
active++; const entry = queue.shift();
doFetch(entry.url) if (!entry) break;
.then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); }); active += 1;
} void doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => {
active -= 1;
drain();
});
}
} }
function scheduleDrain() { function scheduleDrain() {
if (drainScheduled) return; if (drainScheduled) return;
drainScheduled = true; drainScheduled = true;
requestAnimationFrame(drain); requestAnimationFrame(drain);
} }
function enqueue(url: string, priority: number): Promise<string> { function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => { const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject }); insertSorted({url, priority, resolve, reject});
}).catch(err => { }).catch((error) => {
inflight.delete(url); inflight.delete(url);
return Promise.reject(err); return Promise.reject(error);
}); });
inflight.set(url, promise);
scheduleDrain(); inflight.set(url, promise);
return promise; scheduleDrain();
return promise;
} }
export function getBlobUrl(url: string, priority = 0): Promise<string> { export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve(""); if (!url) return Promise.resolve('');
const cached = cache.get(url);
if (cached) return Promise.resolve(cached); const cached = cache.get(url);
const existing = inflight.get(url); if (cached) return Promise.resolve(cached.value);
if (existing) {
const idx = queue.findIndex(e => e.url === url); const existing = inflight.get(url);
if (idx !== -1 && priority > queue[idx].priority) { if (existing) {
const [entry] = queue.splice(idx, 1); const queueIndex = queue.findIndex((entry) => entry.url === url);
entry.priority = priority; if (queueIndex !== -1 && priority > queue[queueIndex].priority) {
insertSorted(entry); const [entry] = queue.splice(queueIndex, 1);
if (entry) {
entry.priority = priority;
insertSorted(entry);
}
}
return existing;
} }
return existing;
} return enqueue(url, priority);
return enqueue(url, priority);
} }
export function preloadBlobUrls(urls: string[], basePriority = 0): void { export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, i) => { urls.forEach((url, index) => {
if (!url || cache.has(url) || inflight.has(url)) return; if (!url || cache.has(url) || inflight.has(url)) return;
enqueue(url, basePriority - i); void enqueue(url, basePriority - index);
}); });
} }
export function revokeBlobUrl(url: string): void { export function revokeBlobUrl(url: string): void {
const blob = cache.get(url); const entry = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); } if (!entry) return;
if (entry.revokable) URL.revokeObjectURL(entry.value);
cache.delete(url);
} }
export function deprioritizeQueue(): void { export function deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0; for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority); queue.sort((a, b) => b.priority - a.priority);
} }
export function cancelQueuedFetches(): void { export function cancelQueuedFetches(): void {
const dropped = queue.splice(0); const dropped = queue.splice(0);
for (const entry of dropped) { for (const entry of dropped) {
inflight.delete(entry.url); inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError")); entry.reject(new DOMException('Cancelled', 'AbortError'));
} }
} }
export function clearBlobCache(): void { export function clearBlobCache(): void {
clearing = true; clearing = true;
cancelQueuedFetches(); cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear(); for (const [url, entry] of cache.entries()) {
inflight.clear(); if (entry.revokable) URL.revokeObjectURL(entry.value);
clearing = false; cache.delete(url);
}
inflight.clear();
clearing = false;
} }
+4
View File
@@ -0,0 +1,4 @@
export * from '$lib/core/cache/memoryCache';
export * from '$lib/core/cache/pageCache';
export * from '$lib/core/cache/imageCache';
export * from '$lib/core/cache/queryCache';
+119
View File
@@ -0,0 +1,119 @@
import type {Page} from '$lib/server-adapters/types';
import {getAdapter} from '$lib/request-manager';
import {resolveImageUrl} from '$lib/core/image';
import {getBlobUrl, preloadBlobUrls} from '$lib/core/cache/imageCache';
const pageCache = new Map<number, Page[]>();
const inflight = new Map<number, Promise<Page[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
const absoluteUrl = resolveImageUrl(url) ?? url;
if (!useBlob) return Promise.resolve(absoluteUrl);
const cached = resolvedUrlCache.get(absoluteUrl);
if (cached) return cached;
const promise = getBlobUrl(absoluteUrl, priority).catch((error) => {
resolvedUrlCache.delete(absoluteUrl);
return Promise.reject(error);
});
resolvedUrlCache.set(absoluteUrl, promise);
return promise;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<Page[]> {
const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
if (!inflight.has(chapterId)) {
const request = getAdapter()
.getChapterPages(String(chapterId))
.then((pages) => {
const normalized = pages.map((page) => ({
...page,
url: resolveImageUrl(page.url) ?? page.url,
}));
if (useBlob && normalized[priorityPage]?.url) {
void getBlobUrl(normalized[priorityPage].url, 999);
}
pageCache.set(chapterId, normalized);
return normalized;
})
.finally(() => inflight.delete(chapterId));
inflight.set(chapterId, request);
}
const base = inflight.get(chapterId);
if (!base) return Promise.resolve([]);
if (!signal) return base;
return new Promise<Page[]>((resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {once: true});
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
const absoluteUrl = resolveImageUrl(url) ?? url;
if (aspectCache.has(absoluteUrl)) return Promise.resolve(aspectCache.get(absoluteUrl) ?? 0.67);
return resolveUrl(absoluteUrl, useBlob).then(
(src) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const ratio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
aspectCache.set(absoluteUrl, ratio);
resolve(ratio);
};
img.onerror = () => resolve(0.67);
img.src = src;
}),
);
}
export function preloadImage(url: string, useBlob: boolean): void {
const absoluteUrl = resolveImageUrl(url) ?? url;
if (useBlob) {
preloadBlobUrls([absoluteUrl], 0);
return;
}
void resolveUrl(absoluteUrl, false)
.then((src) => {
const img = new Image();
img.src = src;
})
.catch(() => {});
}
export function clearResolvedUrlCache(): void {
resolvedUrlCache.clear();
aspectCache.clear();
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
inflight.delete(chapterId);
return;
}
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
aspectCache.clear();
}
+32 -32
View File
@@ -1,18 +1,18 @@
interface Entry<T> { interface Entry<T> {
promise: Promise<T>; promise: Promise<T>;
fetchedAt: number; fetchedAt: number;
fetcher?: () => Promise<T>; fetcher?: () => Promise<T>;
ttl?: number; ttl?: number;
} }
const store = new Map<string, Entry<unknown>>(); const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>(); const subs = new Map<string, Set<() => void>>();
const keyToGroups = new Map<string, Set<string>>(); const keyToGroups = new Map<string, Set<string>>();
const groups = new Map<string, Set<string>>(); const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000; export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); } function notify(key: string) {subs.get(key)?.forEach(cb => cb());}
function registerGroups(key: string, group?: string | string[]) { function registerGroups(key: string, group?: string | string[]) {
if (!group) return; if (!group) return;
@@ -40,7 +40,7 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}) as Promise<T>; }) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl }); store.set(key, {promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl});
registerGroups(key, group); registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {}); promise.then(() => notify(key)).catch(() => {});
return promise; return promise;
@@ -62,7 +62,7 @@ export const cache = {
const existing = store.get(key) as Entry<T> | undefined; const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return; if (!existing) return;
const next = existing.promise.then(fn); const next = existing.promise.then(fn);
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() }); store.set(key, {...existing, promise: next, fetchedAt: Date.now()});
next.then(() => notify(key)).catch(() => {}); next.then(() => notify(key)).catch(() => {});
}, },
@@ -73,7 +73,7 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}); });
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() }); store.set(key, {...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now()});
promise.then(() => notify(key)).catch(() => {}); promise.then(() => notify(key)).catch(() => {});
return promise; return promise;
}, },
@@ -88,13 +88,13 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}); });
store.set(key, { ...existing, promise, fetchedAt: Date.now() }); store.set(key, {...existing, promise, fetchedAt: Date.now()});
promise.then(() => notify(key)).catch(() => {}); promise.then(() => notify(key)).catch(() => {});
} }
} }
}, },
has(key: string): boolean { return store.has(key); }, has(key: string): boolean {return store.has(key);},
ageOf(key: string): number | undefined { ageOf(key: string): number | undefined {
const e = store.get(key); const e = store.get(key);
@@ -146,16 +146,16 @@ export const CACHE_GROUPS = {
} as const; } as const;
export const CACHE_KEYS = { export const CACHE_KEYS = {
LIBRARY: "library", LIBRARY: "library",
RECENT_UPDATES: "recent_updates", RECENT_UPDATES: "recent_updates",
ALL_MANGA: "all_manga_unfiltered", ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories", CATEGORIES: "categories",
SEARCH: "search_all_manga", SEARCH: "search_all_manga",
SOURCES: "sources", SOURCES: "sources",
POPULAR: "popular", POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`, GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`, MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`, CHAPTERS: (id: number) => `chapters:${id}`,
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string { sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
@@ -189,24 +189,24 @@ export interface PageSet {
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet { export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query); const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return { return {
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); }, add(page) {if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page);},
pages() { return new Set(_pageSets.get(key) ?? []); }, pages() {return new Set(_pageSets.get(key) ?? []);},
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; }, next() {const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1;},
clear() { _pageSets.delete(key); }, clear() {_pageSets.delete(key);},
}; };
} }
const FRECENCY_KEY = "moku-source-frecency"; const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4; const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>; type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap { function loadFrecency(): FrecencyMap {
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; } try {const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {};}
catch { return {}; } catch {return {};}
} }
function saveFrecency(map: FrecencyMap) { function saveFrecency(map: FrecencyMap) {
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {} try {localStorage.setItem(FRECENCY_KEY, JSON.stringify(map));} catch {}
} }
export function recordSourceAccess(sourceId: string) { export function recordSourceAccess(sourceId: string) {
@@ -216,9 +216,9 @@ export function recordSourceAccess(sourceId: string) {
saveFrecency(map); saveFrecency(map);
} }
export function getTopSources<T extends { id: string }>(sources: T[]): T[] { export function getTopSources<T extends {id: string;}>(sources: T[]): T[] {
const map = loadFrecency(); const map = loadFrecency();
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 })); const withScore = sources.map(s => ({s, score: map[s.id] ?? 0}));
if (withScore.some(x => x.score > 0)) { if (withScore.some(x => x.score > 0)) {
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s); return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
} }
@@ -234,7 +234,7 @@ export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string):
cache.clear(CACHE_KEYS.ALL_MANGA); cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) { if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("$lib/core/cache/imageCache"); const {revokeBlobUrl, getBlobUrl} = await import('$lib/core/cache/imageCache');
revokeBlobUrl(thumbnailUrl); revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {}); getBlobUrl(thumbnailUrl, 999).catch(() => {});
} }
-24
View File
@@ -1,24 +0,0 @@
import { appState } from '$lib/state/app.svelte'
import type { Manga } from '$lib/types'
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise(resolve => {
const worker = new Worker(new URL('./autoLinkWorker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data
for (const id of matches) appState.linkManga(focal.id, id)
worker.terminate()
resolve(matches.length)
}
worker.onerror = () => { worker.terminate(); resolve(0) }
worker.postMessage({
focalTitle: focal.title,
focalId: focal.id,
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
linkedIds: appState.settings.mangaLinks?.[focal.id] ?? [],
})
})
}
+20 -17
View File
@@ -1,26 +1,29 @@
interface WorkerMsg { interface WorkerMsg {
focalTitle: string focalTitle: string;
focalId: number focalId: number;
allManga: { id: number; title: string }[] allManga: { id: number; title: string }[];
linkedIds: number[] linkedIds: number[];
} }
function titleSimilarity(a: string, b: string): number { function titleSimilarity(a: string, b: string): number {
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean) const norm = (s: string) =>
const wa = new Set(norm(a)) s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wb = new Set(norm(b)) const wa = new Set(norm(a));
if (!wa.size || !wb.size) return 0 const wb = new Set(norm(b));
const intersection = [...wa].filter(w => wb.has(w)).length if (!wa.size || !wb.size) return 0;
return intersection / new Set([...wa, ...wb]).size const intersection = [...wa].filter(w => wb.has(w)).length;
return intersection / new Set([...wa, ...wb]).size;
} }
self.onmessage = (e: MessageEvent<WorkerMsg>) => { self.onmessage = (e: MessageEvent<WorkerMsg>) => {
const { focalTitle, focalId, allManga, linkedIds } = e.data const { focalTitle, focalId, allManga, linkedIds } = e.data;
const matches: number[] = [] const matches: number[] = [];
for (const m of allManga) { for (const m of allManga) {
if (m.id === focalId) continue if (m.id === focalId) continue;
if (linkedIds.includes(m.id)) continue if (linkedIds.includes(m.id)) continue;
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id) if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
} }
self.postMessage(matches)
} self.postMessage(matches);
};
+29 -28
View File
@@ -1,53 +1,54 @@
const THUMB_SIZE = 16 const THUMB_SIZE = 16;
const DUPE_THRESH = 0.12 const DUPE_THRESH = 0.12;
const hashCache = new Map<string, Uint8ClampedArray>()
const hashCache = new Map<string, Uint8ClampedArray>();
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray { function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
const gray = new Uint8ClampedArray(pixels) const gray = new Uint8ClampedArray(pixels);
for (let i = 0; i < pixels; i++) { for (let i = 0; i < pixels; i++) {
const o = i * 4 const o = i * 4;
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000 gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
} }
return gray return gray;
} }
function loadThumb(url: string): Promise<Uint8ClampedArray> { function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = new Image();
img.crossOrigin = 'anonymous' img.crossOrigin = "anonymous";
img.onload = () => { img.onload = () => {
const canvas = document.createElement('canvas') const canvas = document.createElement("canvas");
canvas.width = canvas.height = THUMB_SIZE canvas.width = canvas.height = THUMB_SIZE;
const ctx = canvas.getContext('2d')! const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE) ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE)) resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
} };
img.onerror = reject img.onerror = reject;
img.src = url img.src = url;
}) });
} }
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number { function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
let diff = 0 let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]) for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / (a.length * 255) return diff / (a.length * 255);
} }
export async function getHash(url: string): Promise<Uint8ClampedArray | null> { export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
if (hashCache.has(url)) return hashCache.get(url)! if (hashCache.has(url)) return hashCache.get(url)!;
try { try {
const thumb = await loadThumb(url) const thumb = await loadThumb(url);
hashCache.set(url, thumb) hashCache.set(url, thumb);
return thumb return thumb;
} catch { } catch {
return null return null;
} }
} }
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean { export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH return similarity(a, b) <= DUPE_THRESH;
} }
export function clearHashCache(): void { export function clearHashCache(): void {
hashCache.clear() hashCache.clear();
} }
-92
View File
@@ -1,92 +0,0 @@
import { appState } from '$lib/state/app.svelte'
import { searchWithScore } from '$lib/core/algorithms/search'
import { getHash, areDuplicates } from '$lib/core/cover/coverHash'
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null }
export type CoverCandidate = {
mangaId: number
url: string
label: string
isActive: boolean
}
const FUZZY_SCORE_THRESHOLD = 0.65
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
u.search = ''
return u.href.toLowerCase()
} catch {
return url.toLowerCase()
}
}
export function resolvedCover(mangaId: number, ownUrl: string): string {
return appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
return searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
)
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id)
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = appState.getLinkedMangaIds(mangaId)
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById)
const current = appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]))
const raw: { mangaId: number; url: string; label: string }[] = [
{ mangaId, url: ownUrl, label: 'This source' },
...allIds.flatMap(id => {
const m = mangaById.get(id)
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : []
}),
]
const seen = new Set<string>()
return raw
.filter(c => {
const key = normalizeUrl(c.url)
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }))
}
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)))
const groups: number[][] = []
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i]
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false })
: undefined
if (existing) existing.push(i)
else groups.push([i])
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0]
const labels = [...new Set(group.map(i => candidates[i].label))]
return { ...candidates[active], label: labels.join(' · ') }
})
}
+30
View File
@@ -0,0 +1,30 @@
import {fetchAuthenticated, getAuthMode, getServerBase} from '$lib/core/auth';
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//.test(value);
}
export function resolveImageUrl(path: string | null | undefined): string | undefined {
if (!path) return undefined;
if (isAbsoluteUrl(path)) return path;
const normalizedBase = getServerBase().replace(/\/$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${normalizedBase}${normalizedPath}`;
}
export async function loadImageObjectUrl(path: string, signal?: AbortSignal): Promise<string> {
const resolved = resolveImageUrl(path);
if (!resolved) throw new Error('Image URL is missing');
if (getAuthMode() === 'NONE') {
return resolved;
}
const response = await fetchAuthenticated(resolved, {}, signal);
if (!response.ok) {
throw new Error(`Failed to load image: ${response.status}`);
}
return URL.createObjectURL(await response.blob());
}
-3
View File
@@ -1,3 +0,0 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from './keybindEngine'
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
export type { Keybinds } from './defaultBinds'
+21 -3
View File
@@ -1,10 +1,10 @@
export function eventToKeybind(e: KeyboardEvent): string { export function eventToKeybind(e: KeyboardEvent): string {
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return ""; if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
const parts: string[] = []; const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl"); if (e.ctrlKey) parts.push("ctrl");
if (e.altKey) parts.push("alt"); if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift"); if (e.shiftKey) parts.push("shift");
if (e.metaKey) parts.push("meta"); if (e.metaKey) parts.push("meta");
parts.push(e.key); parts.push(e.key);
return parts.join("+"); return parts.join("+");
} }
@@ -12,3 +12,21 @@ export function eventToKeybind(e: KeyboardEvent): string {
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean { export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
return eventToKeybind(e) === bind; return eventToKeybind(e) === bind;
} }
export function initKeybindEngine(): () => void {
// Global matching is event-driven via handleGlobalKeydown in the app shell.
// This hook makes boot ordering explicit and reserves a dedicated setup point.
return () => {};
}
export async function toggleFullscreen(): Promise<void> {
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
try {
const {getCurrentWindow} = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
await currentWindow.setFullscreen(!await currentWindow.isFullscreen());
} catch (error) {
console.warn('toggleFullscreen unavailable:', error);
}
}
+12 -8
View File
@@ -15,15 +15,17 @@ interface StoredVault {
data: string; data: string;
} }
function toB64(buf: ArrayBuffer): string { function toB64(data: ArrayBuffer | Uint8Array): string {
return btoa(String.fromCharCode(...new Uint8Array(buf))); const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
return btoa(String.fromCharCode(...bytes));
} }
function fromB64(s: string): Uint8Array { function fromB64(s: string): ArrayBuffer {
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); const bytes = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
} }
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> { async function deriveKey(pin: string, salt: ArrayBuffer): Promise<CryptoKey> {
const enc = new TextEncoder(); const enc = new TextEncoder();
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey( return crypto.subtle.deriveKey(
@@ -42,7 +44,7 @@ export function vaultExists(): boolean {
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> { export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16)); const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(pin, salt); const key = await deriveKey(pin, salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength));
const enc = new TextEncoder(); const enc = new TextEncoder();
const cipher = await crypto.subtle.encrypt( const cipher = await crypto.subtle.encrypt(
@@ -65,10 +67,12 @@ export async function unlockVault(pin: string): Promise<VaultPayload | null> {
try { try {
const stored = JSON.parse(raw) as StoredVault; const stored = JSON.parse(raw) as StoredVault;
const key = await deriveKey(pin, fromB64(stored.salt)); const key = await deriveKey(pin, fromB64(stored.salt));
const iv = new Uint8Array(fromB64(stored.iv));
const cipher = fromB64(stored.data);
const plain = await crypto.subtle.decrypt( const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromB64(stored.iv) }, { name: "AES-GCM", iv },
key, key,
fromB64(stored.data), cipher,
); );
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload; return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
} catch { } catch {
-5
View File
@@ -1,5 +0,0 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
+60 -149
View File
@@ -1,166 +1,77 @@
import { LazyStore } from "@tauri-apps/plugin-store"; import {isSupported, readFile, writeFile} from '$lib/platform-service';
const settingsStore = new LazyStore("settings.json", { autoSave: false }); const encoder = new TextEncoder();
const libraryStore = new LazyStore("library.json", { autoSave: false }); const decoder = new TextDecoder();
const updatesStore = new LazyStore("updates.json", { autoSave: false }); const STORAGE_PREFIX = 'moku:';
const backupsStore = new LazyStore("backups.json", { autoSave: false });
export interface PersistedData { function localStorageKey(key: string): string {
settings: any; return `${STORAGE_PREFIX}${key}`;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
} }
export async function loadAllStores(): Promise<PersistedData> { function fileName(key: string): string {
const migrated = await migrateFromLocalStorage(); return `moku.${key}.json`;
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
} }
async function migrateFromLocalStorage(): Promise<PersistedData | null> { function canUseLocalStorage(): boolean {
try { return typeof window !== 'undefined' && typeof localStorage !== 'undefined';
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
localStorage.removeItem("moku-store");
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
}
} }
export async function persistSettings(data: { settings: any; storeVersion: number }) { function canUseFilesystem(): boolean {
await Promise.all([ try {
settingsStore.set("settings", data.settings), return isSupported('filesystem');
settingsStore.set("storeVersion", data.storeVersion), } catch {
]); return false;
await settingsStore.save(); }
} }
export async function persistLibrary(data: { export async function loadPersistentState<T>(key: string): Promise<T | null> {
history: any[]; if (canUseFilesystem()) {
bookmarks: any[]; try {
markers: any[]; const data = await readFile(fileName(key));
readLog: any[]; if (data.length > 0) {
readingStats: any; return JSON.parse(decoder.decode(data)) as T;
dailyReadCounts: Record<string, number>; }
}) { } catch {
await Promise.all([ // Fall back to localStorage when the file does not exist or the adapter cannot read it yet.
libraryStore.set("history", data.history), }
libraryStore.set("bookmarks", data.bookmarks), }
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog), if (!canUseLocalStorage()) return null;
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts), try {
]); const raw = localStorage.getItem(localStorageKey(key));
await libraryStore.save(); return raw ? JSON.parse(raw) as T : null;
} catch {
return null;
}
} }
export async function persistUpdates(data: { export async function savePersistentState<T>(key: string, value: T): Promise<void> {
libraryUpdates: any[]; const json = JSON.stringify(value);
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[]; if (canUseLocalStorage()) {
}) { localStorage.setItem(localStorageKey(key), json);
await Promise.all([ }
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh), if (!canUseFilesystem()) return;
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]); try {
await updatesStore.save(); await writeFile(fileName(key), encoder.encode(json));
} catch {
// LocalStorage remains the fallback when a platform adapter cannot persist to files.
}
} }
export interface BackupEntry { url: string; name: string; } export async function clearPersistentState(key: string): Promise<void> {
if (canUseLocalStorage()) {
localStorage.removeItem(localStorageKey(key));
}
export async function loadBackups(): Promise<BackupEntry[]> { if (!canUseFilesystem()) return;
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
}
export async function persistBackups(list: BackupEntry[]): Promise<void> { try {
await backupsStore.set("backupList", list); await writeFile(fileName(key), encoder.encode('null'));
await backupsStore.save(); } catch {
} // Ignore native persistence failures during cleanup.
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
} }
+54
View File
@@ -0,0 +1,54 @@
import {getAdapter} from '$lib/request-manager';
import {loadChapterPages} from '$lib/request-manager/chapters';
import {readerState} from '$lib/state/reader.svelte';
import {sortChapters} from './navigation';
/**
* Load (or resume) a reader session for the given manga and chapter.
* Caches manga/chapter list when the manga ID hasn't changed to avoid redundant fetches.
* Resumes at the reader's last saved page position.
*/
export async function ensureReaderSession(
mangaId: string,
chapterId: string,
): Promise<void> {
const adapter = getAdapter();
const mangaPromise =
readerState.manga && String(readerState.manga.id) === mangaId
? Promise.resolve(readerState.manga)
: adapter.getManga(mangaId);
const chaptersPromise =
readerState.chapters.length > 0 &&
String(readerState.chapters[0]?.mangaId) === mangaId
? Promise.resolve(readerState.chapters)
: adapter.getChapters(mangaId);
const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]);
const chapter =
chapters.find((ch) => String(ch.id) === chapterId) ??
(String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ??
(await adapter.getChapter(chapterId));
readerState.manga = manga;
readerState.chapters = chapters;
readerState.chapter = chapter;
readerState.pages = [];
readerState.currentPage = 0;
readerState.pagesError = null;
await loadChapterPages(chapterId);
if (readerState.pages.length > 0) {
const resumeIndex = Math.max(0, (chapter.lastPageRead ?? 1) - 1);
readerState.currentPage = Math.min(resumeIndex, readerState.pages.length - 1);
}
}
/**
* Return the sorted chapter list for the current manga ordered by source order.
* Convenience re-export for callers that only need adjacent chapter lookups.
*/
export {sortChapters};
+104
View File
@@ -0,0 +1,104 @@
import {getAdapter} from '$lib/request-manager';
import {loadChapterPages, updateProgress} from '$lib/request-manager/chapters';
import {readerState} from '$lib/state/reader.svelte';
import type {Chapter} from '$lib/types/index';
export function sortChapters(chapters: Chapter[]): Chapter[] {
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
function currentChapterIndex(): number {
if (!readerState.chapter) return -1;
return sortChapters(readerState.chapters).findIndex(
(ch) => String(ch.id) === String(readerState.chapter?.id),
);
}
function clampPageIndex(index: number): number {
if (readerState.pages.length === 0) return 0;
return Math.min(Math.max(index, 0), readerState.pages.length - 1);
}
export function getAdjacentChapters(): {
previous: Chapter | null;
next: Chapter | null;
} {
const chapters = sortChapters(readerState.chapters);
const index = currentChapterIndex();
return {
previous: index > 0 ? (chapters[index - 1] ?? null) : null,
next: index >= 0 && index < chapters.length - 1 ? (chapters[index + 1] ?? null) : null,
};
}
export async function setCurrentReaderPage(index: number): Promise<void> {
const nextIndex = clampPageIndex(index);
readerState.currentPage = nextIndex;
if (!readerState.chapter || readerState.pages.length === 0) return;
const lastPageRead = nextIndex + 1;
const completed = lastPageRead >= readerState.pages.length;
if (
readerState.chapter.lastPageRead === lastPageRead &&
readerState.chapter.read === completed
) {
return;
}
try {
await updateProgress(String(readerState.chapter.id), lastPageRead, completed);
} catch (error) {
readerState.pagesError = error instanceof Error ? error.message : String(error);
}
}
export async function goToNextReaderPage(): Promise<boolean> {
if (readerState.currentPage >= readerState.pages.length - 1) return false;
await setCurrentReaderPage(readerState.currentPage + 1);
return true;
}
export async function goToPreviousReaderPage(): Promise<boolean> {
if (readerState.currentPage <= 0) return false;
await setCurrentReaderPage(readerState.currentPage - 1);
return true;
}
export async function ensureReaderSession(
mangaId: string,
chapterId: string,
): Promise<void> {
const adapter = getAdapter();
const mangaPromise =
readerState.manga && String(readerState.manga.id) === mangaId
? Promise.resolve(readerState.manga)
: adapter.getManga(mangaId);
const chaptersPromise =
readerState.chapters.length > 0 &&
String(readerState.chapters[0]?.mangaId) === mangaId
? Promise.resolve(readerState.chapters)
: adapter.getChapters(mangaId);
const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]);
const chapter =
chapters.find((ch) => String(ch.id) === chapterId) ??
(String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ??
(await adapter.getChapter(chapterId));
readerState.manga = manga;
readerState.chapters = chapters;
readerState.chapter = chapter;
readerState.pages = [];
readerState.currentPage = 0;
readerState.pagesError = null;
await loadChapterPages(chapterId);
if (readerState.pages.length > 0) {
readerState.currentPage = clampPageIndex((chapter.lastPageRead ?? 1) - 1);
}
}
+50
View File
@@ -0,0 +1,50 @@
import type {Page} from '$lib/server-adapters/types';
/**
* Build double-page spread groups for a given page count.
* Groups are 1-based page numbers. Wide pages (aspect ratio > 1.2) get their own group.
* `offsetSpreads` causes the first pairing to start at page 2 (common for manga with a cover).
*/
export function buildPageGroups(
count: number,
aspects: number[],
offsetSpreads: boolean,
): number[][] {
if (count === 0) return [];
const groups: number[][] = [[1]];
if (offsetSpreads && count > 1) groups.push([2]);
let i = offsetSpreads ? 3 : 2;
while (i <= count) {
const aspect = aspects[i - 1] ?? 1;
if (aspect > 1.2 || i === count) {
groups.push([i++]);
} else {
groups.push([i, i + 1]);
i += 2;
}
}
return groups;
}
/**
* Imperatively kick off browser image preloading for a URL.
* Fire-and-forget; errors are silently swallowed.
*/
export function preloadImage(url: string): void {
if (!url || typeof document === 'undefined') return;
const img = new Image();
img.src = url;
}
/**
* Preload a window of pages ahead of the current position.
*/
export function preloadPages(pages: Page[], currentIndex: number, windowSize = 3): void {
const end = Math.min(currentIndex + windowSize, pages.length);
for (let i = currentIndex + 1; i < end; i++) {
const p = pages[i];
if (p) preloadImage(p.imageData ?? p.url);
}
}
+68
View File
@@ -0,0 +1,68 @@
import {createPinchGesture} from '$lib/core/ui/touchscreen';
import type {PinchGesture} from '$lib/core/ui/touchscreen';
import {clampZoom, ZOOM_MIN, ZOOM_MAX} from './zoomHelpers';
export type {PinchGesture as PinchTracker};
/** Max zoom level allowed in single-page inspect mode (pan+zoom overlay). */
const INSPECT_ZOOM_MAX = 8;
export interface PinchTrackerOptions {
/** Get the current reader-level zoom (longstrip scaling). */
getZoom: () => number;
/** Set a new reader-level zoom. */
setZoom: (value: number) => void;
/** Get the current inspect-mode zoom scale for single-page view. */
getInspectScale: () => number;
/** Set inspect-mode zoom scale. */
setInspectScale: (value: number) => void;
/** Reset inspect-mode pan offsets to origin. */
resetInspectPan: () => void;
/** Returns true when the reader is in longstrip mode. */
isLongstrip: () => boolean;
}
/**
* Create a pinch-gesture tracker that drives reader zoom.
*
* In longstrip mode pinch controls the global strip zoom level.
* In single/double mode pinch controls the in-page inspect zoom.
*
* Usage wire the returned handler methods to the container element:
* ```svelte
* <div
* onpointerdown={tracker.onPointerDown}
* onpointermove={tracker.onPointerMove}
* onpointerup={tracker.onPointerUp}
* onpointercancel={tracker.onPointerUp}
* >
* ```
*/
export function createPinchTracker(opts: PinchTrackerOptions): PinchGesture {
let startZoom = 0;
let startInspect = 0;
return createPinchGesture({
onPinch(scale) {
if (startZoom === 0) {
startZoom = opts.getZoom();
startInspect = opts.getInspectScale();
}
if (opts.isLongstrip()) {
opts.setZoom(clampZoom(startZoom * scale, ZOOM_MIN, ZOOM_MAX));
} else {
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale));
if (next !== opts.getInspectScale()) {
if (next <= 1) opts.resetInspectPan();
opts.setInspectScale(next);
}
}
},
onPinchEnd() {
startZoom = 0;
startInspect = 0;
},
});
}
+115
View File
@@ -0,0 +1,115 @@
import {matchesKeybind, toggleFullscreen} from '$lib/core/keybinds/keybindEngine';
import type {Keybinds} from '$lib/core/keybinds/defaultBinds';
export interface ReaderKeyActions {
/** Navigate one step forward (respects RTL). */
goNext: () => void;
/** Navigate one step backward (respects RTL). */
goPrev: () => void;
/** Jump to a specific 0-based page index. */
goToPage: (index: number) => void;
/** Return the 0-based index of the last page. */
lastPage: () => number;
/** Close the reader and return to the series page. */
exitReader: () => void;
/** Jump to the next chapter. */
chapterNext: () => void;
/** Jump to the previous chapter. */
chapterPrev: () => void;
/** Adjust reader zoom by delta (positive = zoom in, negative = zoom out). */
adjustZoom: (delta: number) => void;
/** Reset zoom to 1.0. */
resetZoom: () => void;
/** Cycle through available page display modes. */
cycleMode: () => void;
/** Toggle between LTR and RTL reading direction. */
toggleDirection: () => void;
/** Open the settings panel or navigate to /settings. */
openSettings: () => void;
/** Toggle the bookmark on the current chapter/page. */
toggleBookmark: () => void;
/** Toggle auto-scroll in longstrip mode. */
toggleAutoScroll: () => void;
/** Return the current keybind configuration. */
getKeybinds: () => Keybinds;
}
const CTRL_ZOOM_STEP = 0.1;
/**
* Create a keydown event handler for the reader with the given action callbacks.
* Suitable for use as `svelte:window onkeydown={handler}` in the reader page.
*/
export function createReaderKeyHandler(
actions: ReaderKeyActions,
): (event: KeyboardEvent) => void {
return function onKey(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
// Ctrl +/-/0 zoom shortcuts (standard browser-style overrides)
if (event.ctrlKey) {
if (event.key === '=' || event.key === '+') {
event.preventDefault();
actions.adjustZoom(CTRL_ZOOM_STEP);
return;
}
if (event.key === '-') {
event.preventDefault();
actions.adjustZoom(-CTRL_ZOOM_STEP);
return;
}
if (event.key === '0') {
event.preventDefault();
actions.resetZoom();
return;
}
}
const kb = actions.getKeybinds();
if (matchesKeybind(event, kb.exitReader)) {
event.preventDefault();
actions.exitReader();
} else if (event.key === 'Escape') {
event.preventDefault();
actions.exitReader();
} else if (matchesKeybind(event, kb.turnPageRight)) {
event.preventDefault();
actions.goNext();
} else if (matchesKeybind(event, kb.turnPageLeft)) {
event.preventDefault();
actions.goPrev();
} else if (matchesKeybind(event, kb.firstPage)) {
event.preventDefault();
actions.goToPage(0);
} else if (matchesKeybind(event, kb.lastPage)) {
event.preventDefault();
actions.goToPage(actions.lastPage());
} else if (matchesKeybind(event, kb.turnChapterRight)) {
event.preventDefault();
actions.chapterNext();
} else if (matchesKeybind(event, kb.turnChapterLeft)) {
event.preventDefault();
actions.chapterPrev();
} else if (matchesKeybind(event, kb.togglePageStyle)) {
event.preventDefault();
actions.cycleMode();
} else if (matchesKeybind(event, kb.toggleReadingDirection)) {
event.preventDefault();
actions.toggleDirection();
} else if (matchesKeybind(event, kb.toggleFullscreen)) {
event.preventDefault();
void toggleFullscreen();
} else if (matchesKeybind(event, kb.openSettings)) {
event.preventDefault();
actions.openSettings();
} else if (matchesKeybind(event, kb.toggleBookmark)) {
event.preventDefault();
actions.toggleBookmark();
} else if (matchesKeybind(event, kb.toggleAutoScroll)) {
event.preventDefault();
actions.toggleAutoScroll();
}
};
}
+142
View File
@@ -0,0 +1,142 @@
/** Fraction from the top of the viewport used as the "active page" read line. */
export const READ_LINE_PCT = 0.5;
export interface StripChapter {
chapterId: string;
chapterName: string;
pageCount: number;
}
export interface ScrollHandlerCallbacks {
/** Called when the visible page index changes (0-based). */
onPageChange: (pageIndex: number) => void;
/** Called when the visible chapter changes in multi-chapter strip mode. */
onChapterChange: (chapterId: string) => void;
/** Called when a chapter has been fully scrolled past (auto-mark-read). */
onMarkRead: (chapterId: string) => void;
/** Called when the reader is near the bottom and should load the next chapter. */
onAppend: () => void;
/** Return the current list of strip chapters for auto-mark calculations. */
getStripChapters: () => StripChapter[];
/** Whether to automatically mark chapters read on scroll. */
shouldAutoMark: () => boolean;
}
/**
* Attach scroll-position tracking to a longstrip container element.
* Returns a cleanup function to remove all listeners.
*
* Images in the container must have `data-page-index` (0-based) and optionally
* `data-chapter-id` attributes for multi-chapter strip tracking.
*/
export function setupScrollTracking(
containerEl: HTMLElement,
callbacks: ScrollHandlerCallbacks,
): () => void {
const {
onPageChange,
onChapterChange,
onMarkRead,
onAppend,
getStripChapters,
shouldAutoMark,
} = callbacks;
let rafId: number | null = null;
function tick() {
rafId = null;
const imgs = containerEl.querySelectorAll<HTMLElement>('img[data-page-index]');
if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
// Binary search for the last image whose top edge is above the read line
let lo = 0, hi = imgs.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if ((imgs[mid] as HTMLElement).getBoundingClientRect().top <= readLineY) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
const active = imgs[best] as HTMLElement;
const pageIndex = Number(active.dataset.pageIndex);
const chapterId = active.dataset.chapterId ?? null;
onPageChange(pageIndex);
if (chapterId) onChapterChange(chapterId);
if (shouldAutoMark() && chapterId) {
const chunks = getStripChapters();
const chunk = chunks.find((c) => c.chapterId === chapterId);
if (chunk && pageIndex >= chunk.pageCount - 1) {
onMarkRead(chapterId);
}
const atBottom =
containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 60;
if (atBottom) {
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
}
// Trigger appending next chapter when 80% scrolled
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.8) onAppend();
}
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(tick);
}
containerEl.addEventListener('scroll', onScroll, {passive: true});
return () => {
containerEl.removeEventListener('scroll', onScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}
/**
* Append the next chapter's pages to a strip view.
*
* Finds the chapter after the last currently-loaded strip chapter, fetches its
* pages, and calls `onAppended` with the new chunk. Calls `onDone` when finished
* (success or no-op).
*/
export async function appendNextChapter(
stripChapters: StripChapter[],
chapterList: {id: string; name: string;}[],
fetchPageCount: (chapterId: string) => Promise<number>,
onAppended: (next: StripChapter) => void,
onDone: () => void,
): Promise<void> {
if (!stripChapters.length) {onDone(); return; }
const lastChunk = stripChapters[stripChapters.length - 1];
if (!lastChunk) {onDone(); return; }
const lastIdx = chapterList.findIndex((c) => c.id === lastChunk.chapterId);
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) {onDone(); return; }
const next = chapterList[lastIdx + 1];
if (!next || stripChapters.some((c) => c.chapterId === next.id)) {onDone(); return; }
try {
const pageCount = await fetchPageCount(next.id);
if (stripChapters.some((c) => c.chapterId === next.id)) {onDone(); return; }
onAppended({chapterId: next.id, chapterName: next.name, pageCount});
} catch {
// swallow caller retries on next scroll trigger
} finally {
onDone();
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* @deprecated Import directly from the specific reader core modules:
* - chapterLoader.ts ensureReaderSession, sortChapters
* - navigation.ts getAdjacentChapters, setCurrentReaderPage, goToNextReaderPage, goToPreviousReaderPage
*
* This file is kept for backward-compatibility only.
*/
export {
ensureReaderSession,
} from './chapterLoader';
export {
sortChapters,
getAdjacentChapters,
setCurrentReaderPage,
goToNextReaderPage,
goToPreviousReaderPage,
} from './navigation';
+30
View File
@@ -0,0 +1,30 @@
export const ZOOM_MIN = 0.25;
export const ZOOM_MAX = 4.0;
export const ZOOM_STEP = 0.1;
/**
* Clamp a zoom value between the reader's min/max bounds.
*/
export function clampZoom(value: number, min = ZOOM_MIN, max = ZOOM_MAX): number {
return Math.max(min, Math.min(max, value));
}
/**
* Return the next zoom level after applying a delta, clamped to valid bounds.
* Rounded to avoid floating point drift.
*/
export function adjustZoom(current: number, delta: number): number {
return clampZoom(Math.round((current + delta) * 1000) / 1000);
}
/**
* Snap to a list of named presets (0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0).
* Returns the nearest preset value to the given zoom.
*/
export const ZOOM_PRESETS = [0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] as const;
export function snapToPreset(value: number): number {
return ZOOM_PRESETS.reduce((best, preset) =>
Math.abs(preset - value) < Math.abs(best - value) ? preset : best
);
}
+68 -45
View File
@@ -1,56 +1,79 @@
import type { CustomTheme } from '$lib/types/settings' import {settingsState, updateSettings} from '$lib/state/settings.svelte';
import type {CustomTheme, Theme} from '$lib/types/settings';
let themeStyleEl: HTMLStyleElement | null = null let themeStyleEl: HTMLStyleElement | null = null;
let mediaQuery: MediaQueryList | null = null
let mediaHandler: (() => void) | null = null
export function applyTheme(themeId: string, customThemes: CustomTheme[] = []) { function ensureThemeStyleEl(): HTMLStyleElement {
const custom = customThemes.find(t => t.id === themeId) if (themeStyleEl) return themeStyleEl;
if (custom) { themeStyleEl = document.createElement('style');
const vars = Object.entries(custom.tokens) themeStyleEl.id = 'moku-custom-theme';
.map(([k, v]) => ` --${k}: ${v};`) document.head.appendChild(themeStyleEl);
.join('\n') return themeStyleEl;
if (!themeStyleEl) {
themeStyleEl = document.createElement('style')
themeStyleEl.id = 'moku-custom-theme'
document.head.appendChild(themeStyleEl)
}
themeStyleEl.textContent = `:root {\n${vars}\n}`
document.documentElement.removeAttribute('data-theme')
return
}
if (themeStyleEl) {
themeStyleEl.remove()
themeStyleEl = null
}
document.documentElement.setAttribute('data-theme', themeId)
} }
export function mountSystemThemeSync( function removeCustomThemeCss() {
enabled: boolean, themeStyleEl?.remove();
darkTheme: string, themeStyleEl = null;
lightTheme: string, }
onSwitch: (themeId: string) => void
) {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener('change', mediaHandler)
mediaHandler = null
}
if (!enabled) { mediaQuery = null; return } function resolveBuiltinTheme(theme: Theme): string {
if (theme === 'light-contrast') return 'light';
return theme || 'dark';
}
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') export function applyTheme(theme: Theme, customThemes: CustomTheme[] = []) {
mediaHandler = () => onSwitch(mediaQuery!.matches ? darkTheme : lightTheme) const activeTheme = theme || 'dark';
mediaQuery.addEventListener('change', mediaHandler) const customThemeId = activeTheme.startsWith('custom:') ? activeTheme.slice(7) : activeTheme;
onSwitch(mediaQuery.matches ? darkTheme : lightTheme) const customTheme = customThemes.find(entry => entry.id === customThemeId);
if (!customTheme) {
removeCustomThemeCss();
document.documentElement.setAttribute('data-theme', resolveBuiltinTheme(activeTheme));
return;
}
const css = Object.entries(customTheme.tokens)
.map(([token, value]) => ` --${token}: ${value};`)
.join('\n');
ensureThemeStyleEl().textContent = `[data-theme="custom"] {\n${css}\n}`;
document.documentElement.setAttribute('data-theme', 'custom');
}
let systemThemeMedia: MediaQueryList | null = null;
let systemThemeHandler: ((event: MediaQueryListEvent) => void) | null = null;
function applySystemTheme(isDark: boolean) {
const themeId = isDark
? (settingsState.systemThemeDark ?? 'dark')
: (settingsState.systemThemeLight ?? 'light');
updateSettings({theme: themeId});
}
export function mountSystemThemeSync() {
if (typeof window === 'undefined') return;
if (systemThemeMedia && systemThemeHandler) {
systemThemeMedia.removeEventListener('change', systemThemeHandler);
systemThemeMedia = null;
systemThemeHandler = null;
}
if (!settingsState.systemThemeSync) return;
systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
systemThemeHandler = (event) => applySystemTheme(event.matches);
systemThemeMedia.addEventListener('change', systemThemeHandler);
applySystemTheme(systemThemeMedia.matches);
} }
export function unmountSystemThemeSync() { export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) { if (systemThemeMedia && systemThemeHandler) {
mediaQuery.removeEventListener('change', mediaHandler) systemThemeMedia.removeEventListener('change', systemThemeHandler);
mediaHandler = null }
mediaQuery = null
} systemThemeMedia = null;
systemThemeHandler = null;
} }
+49
View File
@@ -0,0 +1,49 @@
const IDLE_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const;
export function mountIdleDetection(
getTimeoutMinutes: () => number | undefined,
onIdle: () => void,
onActive: () => void,
): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let idle = false;
const markActive = () => {
if (!idle) return;
idle = false;
onActive();
};
const resetTimer = () => {
if (timer) clearTimeout(timer);
const timeoutMinutes = getTimeoutMinutes() ?? 5;
const timeoutMs = Math.max(0, timeoutMinutes) * 60 * 1000;
if (timeoutMs === 0) {
markActive();
return;
}
markActive();
timer = setTimeout(() => {
if (idle) return;
idle = true;
onIdle();
}, timeoutMs);
};
IDLE_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, resetTimer, {passive: true});
});
resetTimer();
return () => {
if (timer) clearTimeout(timer);
IDLE_EVENTS.forEach((eventName) => {
window.removeEventListener(eventName, resetTimer);
});
};
}
+20 -6
View File
@@ -16,12 +16,26 @@ export function applyZoom(uiZoom: number) {
export function zoomDelta(e: KeyboardEvent, current: number): number | null { export function zoomDelta(e: KeyboardEvent, current: number): number | null {
if (!e.ctrlKey) return null; if (!e.ctrlKey) return null;
if (e.key === "=" || e.key === "+") { e.preventDefault(); return Math.min(2.0, Math.round((current + 0.1) * 10) / 10); } if (e.key === "=" || e.key === "+") {e.preventDefault(); return Math.min(2.0, Math.round((current + 0.1) * 10) / 10);}
if (e.key === "-") { e.preventDefault(); return Math.max(0.5, Math.round((current - 0.1) * 10) / 10); } if (e.key === "-") {e.preventDefault(); return Math.max(0.5, Math.round((current - 0.1) * 10) / 10);}
if (e.key === "0") { e.preventDefault(); return 1.0; } if (e.key === "0") {e.preventDefault(); return 1.0;}
return null; return null;
} }
export function mountZoomKey(getCurrent: () => number, onChange: (next: number) => void): () => void {
const handleKey = (event: KeyboardEvent) => {
const nextZoom = zoomDelta(event, getCurrent());
if (nextZoom === null) return;
onChange(nextZoom);
};
window.addEventListener('keydown', handleKey);
return () => {
window.removeEventListener('keydown', handleKey);
};
}
export function clampZoom(z: number, min: number, max: number): number { export function clampZoom(z: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000; return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
} }
@@ -29,19 +43,19 @@ export function clampZoom(z: number, min: number, max: number): number {
export function captureZoomAnchor( export function captureZoomAnchor(
containerEl: HTMLElement | null, containerEl: HTMLElement | null,
style: string, style: string,
out: { el: HTMLElement | null; offset: number }, out: {el: HTMLElement | null; offset: number;},
) { ) {
if (!containerEl || style !== "longstrip") return; if (!containerEl || style !== "longstrip") return;
const containerTop = containerEl.getBoundingClientRect().top; const containerTop = containerEl.getBoundingClientRect().top;
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) { for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
const rect = img.getBoundingClientRect(); const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; } if (rect.bottom > containerTop) {out.el = img; out.offset = rect.top - containerTop; return;}
} }
} }
export function restoreZoomAnchor( export function restoreZoomAnchor(
containerEl: HTMLElement | null, containerEl: HTMLElement | null,
out: { el: HTMLElement | null; offset: number }, out: {el: HTMLElement | null; offset: number;},
) { ) {
if (!out.el || !containerEl) return; if (!out.el || !containerEl) return;
const el = out.el; const el = out.el;
+32 -32
View File
@@ -1,17 +1,17 @@
import type { Manga, Source } from "$lib/types"; import type {Manga, Source} from '$lib/types/index';
import type { Settings } from "$lib/types"; import type {Settings} from '$lib/types/settings';
export { clsx as cn } from "clsx"; export {clsx as cn} from "clsx";
export function timeAgo(ts: number): string { export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000); const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now"; if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`; if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`; if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24); const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`; if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); return new Date(ts).toLocaleDateString("en-US", {month: "short", day: "numeric"});
} }
export function dayLabel(ts: number): string { export function dayLabel(ts: number): string {
@@ -19,11 +19,11 @@ export function dayLabel(ts: number): string {
if (d.toDateString() === now.toDateString()) return "Today"; if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1); const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday"; if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); return d.toLocaleDateString("en-US", {weekday: "long", month: "long", day: "numeric"});
} }
export function formatReadTime(m: number): string { export function formatReadTime(m: number): string {
if (m < 1) return "< 1 min"; if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`; if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60; const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`; return r === 0 ? `${h}h` : `${h}h ${r}m`;
@@ -46,7 +46,7 @@ type ContentFilterSettings = Pick<
>; >;
function blockedTagsForSettings(settings: ContentFilterSettings): string[] { function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
if (settings.contentLevel === "strict") return STRICT_TAGS; if (settings.contentLevel === "strict") return STRICT_TAGS;
if (settings.contentLevel === "moderate") return MODERATE_TAGS; if (settings.contentLevel === "moderate") return MODERATE_TAGS;
return []; return [];
} }
@@ -59,7 +59,7 @@ function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean
const idx = norm.indexOf(tag); const idx = norm.indexOf(tag);
if (idx === -1) return false; if (idx === -1) return false;
const before = idx === 0 || /\W/.test(norm[idx - 1]); const before = idx === 0 || /\W/.test(norm[idx - 1]);
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]); const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
return before && after; return before && after;
}); });
}); });
@@ -71,7 +71,7 @@ export function shouldHideNsfw(
): boolean { ): boolean {
if (settings.contentLevel === "unrestricted") return false; if (settings.contentLevel === "unrestricted") return false;
const srcId = manga.source?.id; const srcId = manga.source?.id;
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : []; const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : []; const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
@@ -99,19 +99,19 @@ export function shouldHideSource(
} }
export function dedupeSourcesByLang( export function dedupeSourcesByLang(
sources: Source[], sources: Source[],
preferredLang: string, preferredLang: string,
settings: ContentFilterSettings, settings: ContentFilterSettings,
applyHide = false, applyHide = false,
): Source[] { ): Source[] {
const map = new Map<string, Source>(); const map = new Map<string, Source>();
for (const s of sources) { for (const s of sources) {
if (s.id === "0") continue; if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue; if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name); const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; } if (!existing) {map.set(s.name, s); continue;}
const existingPref = existing.lang === preferredLang; const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang; const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s); if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s); else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
} }
@@ -159,36 +159,36 @@ function authorFingerprint(author?: string | null, artist?: string | null): stri
} }
export function dedupeMangaByTitle<T extends { export function dedupeMangaByTitle<T extends {
id: number; id: number;
title: string; title: string;
description?: string | null; description?: string | null;
author?: string | null; author?: string | null;
artist?: string | null; artist?: string | null;
inLibrary?: boolean; inLibrary?: boolean;
downloadCount?: number; downloadCount?: number;
}>(items: T[], links: Record<number, number[]> = {}): T[] { }>(items: T[], links: Record<number, number[]> = {}): T[] {
const byTitle = new Map<string, number>(); const byTitle = new Map<string, number>();
const byDesc = new Map<string, number>(); const byDesc = new Map<string, number>();
const byAuthorDesc = new Map<string, number>(); const byAuthorDesc = new Map<string, number>();
const byId = new Map<number, number>(); const byId = new Map<number, number>();
const out: T[] = []; const out: T[] = [];
for (const m of items) { for (const m of items) {
const tk = normalizeTitle(m.title); const tk = normalizeTitle(m.title);
const dk = descFingerprint(m.description); const dk = descFingerprint(m.description);
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null; const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
const linkedIds = links[m.id] ?? []; const linkedIds = links[m.id] ?? [];
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
const existingIdx = const existingIdx =
linkedIdx ?? linkedIdx ??
byTitle.get(tk) ?? byTitle.get(tk) ??
(dk ? byDesc.get(dk) : undefined) ?? (dk ? byDesc.get(dk) : undefined) ??
(ak ? byAuthorDesc.get(ak) : undefined); (ak ? byAuthorDesc.get(ak) : undefined);
if (existingIdx !== undefined) { if (existingIdx !== undefined) {
const existing = out[existingIdx]; const existing = out[existingIdx];
const mBetter = const mBetter =
(m.inLibrary && !existing.inLibrary) || (m.inLibrary && !existing.inLibrary) ||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0)); (!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
@@ -213,11 +213,11 @@ export function dedupeMangaByTitle<T extends {
return out; return out;
} }
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] { export function dedupeMangaById<T extends {id: number;}>(items: T[]): T[] {
const seen = new Set<number>(); const seen = new Set<number>();
const out: T[] = []; const out: T[] = [];
for (const m of items) { for (const m of items) {
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); } if (!seen.has(m.id)) {seen.add(m.id); out.push(m);}
} }
return out; return out;
} }
+48
View File
@@ -0,0 +1,48 @@
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-down { animation: fadeDown 0.18s ease both; }
.anim-scale-in { animation: scaleIn 0.14s ease both; }
.anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; }
.skeleton {
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm);
}
+4
View File
@@ -0,0 +1,4 @@
@import './reset.css';
@import './animations.css';
@import './scrollbars.css';
@import './typography.css';
+48
View File
@@ -0,0 +1,48 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#svelte {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol {
list-style: none;
}
img, svg {
display: block;
max-width: 100%;
}
p {
margin: 0;
}
+22
View File
@@ -0,0 +1,22 @@
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar {
width: 4px;
height: 4px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 99px;
}
*::-webkit-scrollbar-thumb:hover {
background: transparent;
}
+9
View File
@@ -0,0 +1,9 @@
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
+3
View File
@@ -0,0 +1,3 @@
@import './base/index.css';
@import './tokens/index.css';
@import './themes/index.css';
+25
View File
@@ -0,0 +1,25 @@
[data-theme='dark'] {
--bg-void: #000000;
--bg-base: #080808;
--bg-surface: #0d0d0d;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
}
+6
View File
@@ -0,0 +1,6 @@
@import './original.css';
@import './dark.css';
@import './light.css';
@import './light-contrast.css';
@import './midnight.css';
@import './warm.css';
+29
View File
@@ -0,0 +1,29 @@
[data-theme='light-contrast'] {
--bg-void: #f3efe7;
--bg-base: #fbf7f1;
--bg-surface: #ffffff;
--bg-raised: #fffdfa;
--bg-overlay: #ffffff;
--bg-subtle: #efe8dc;
--border-dim: #c0b8ab;
--border-base: #8f8677;
--border-strong: #60594f;
--border-focus: #234c23;
--text-primary: #050402;
--text-secondary: #14110b;
--text-muted: #3e3529;
--text-faint: #655c50;
--text-disabled: #a39b8f;
--accent: #244f24;
--accent-dim: #b7d2b7;
--accent-muted: #d5e6d5;
--accent-fg: #173717;
--accent-bright: #173f17;
--color-error: #7f1010;
--color-error-bg: #fdeaea;
--color-read: #ece5da;
}
+29
View File
@@ -0,0 +1,29 @@
[data-theme='light'] {
--bg-void: #d8d4ce;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme='midnight'] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
+31
View File
@@ -0,0 +1,31 @@
[data-theme='original'] {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme='warm'] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
+35
View File
@@ -0,0 +1,35 @@
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
}
+7
View File
@@ -0,0 +1,7 @@
@import './colors.css';
@import './typography.css';
@import './spacing.css';
@import './radius.css';
@import './motion.css';
@import './shadows.css';
@import './zindex.css';
+5
View File
@@ -0,0 +1,5 @@
:root {
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
}
+8
View File
@@ -0,0 +1,8 @@
:root {
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
}
+2
View File
@@ -0,0 +1,2 @@
:root {
}
+13
View File
@@ -0,0 +1,13 @@
:root {
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
--titlebar-height: 36px;
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--font-ui: 'DM Mono', 'Fira Mono', ui-monospace, monospace;
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
}
+5
View File
@@ -0,0 +1,5 @@
:root {
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}

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