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
122 changed files with 10071 additions and 1289 deletions
+1
View File
@@ -6,6 +6,7 @@ dist-tauri/
target/ target/
bin/ bin/
out/ out/
notes/
.direnv/ .direnv/
result result
+9
View File
@@ -22,14 +22,23 @@
"@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.0.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/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-fs": "^2.5.1",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"capacitor-native-biometric": "^4.2.2",
"phosphor-svelte": "^3.1.0" "phosphor-svelte": "^3.1.0"
} }
} }
+137 -23
View File
@@ -8,31 +8,58 @@ importers:
.: .:
dependencies: dependencies:
'@capacitor/app':
specifier: ^8.1.0
version: 8.1.0(@capacitor/core@3.9.0)
'@capacitor/browser':
specifier: ^8.0.3
version: 8.0.3(@capacitor/core@3.9.0)
'@capacitor/filesystem':
specifier: ^8.1.2
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
'@tauri-apps/plugin-dialog':
specifier: ^2.7.1
version: 2.7.1
'@tauri-apps/plugin-fs':
specifier: ^2.5.1
version: 2.5.1
'@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':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
capacitor-native-biometric:
specifier: ^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.0.0 specifier: ^2.0.0
version: 2.11.2 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)
@@ -44,10 +71,31 @@ 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':
resolution: {integrity: sha512-WJWPHEPbweiFoHYmVlCbZf5yrqJ2Rchx2Xvbmd+3Lf+Zkpq3nXBThThY2CF69lYEg1NINGF9BcHThIOEU1gZlQ==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/core@3.9.0':
resolution: {integrity: sha512-j1lL0+/7stY8YhIq1Lm6xixvUqIn89vtyH5ZpJNNmcZ0kwz6K9eLkcG6fvq1UWMDgSVZg9JrRGSFhb4LLoYOsw==}
'@capacitor/filesystem@8.1.2':
resolution: {integrity: sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/synapse@1.0.4':
resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -477,9 +525,21 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-dialog@2.7.1':
resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==}
'@tauri-apps/plugin-fs@2.5.1':
resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==}
'@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':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -489,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==}
@@ -512,6 +575,9 @@ packages:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
capacitor-native-biometric@4.2.2:
resolution: {integrity: sha512-stg0h48UxgkNuNcCAgCXLp2DUspRQs79bCBPntpCBhsDxk2bhDRUu+J/QpFtDQHG4M4DioSUcYaAsVw2N6N7wA==}
chokidar@4.0.3: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
@@ -785,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}
@@ -841,6 +910,25 @@ packages:
snapshots: snapshots:
'@capacitor/app@8.1.0(@capacitor/core@3.9.0)':
dependencies:
'@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':
dependencies:
tslib: 2.8.1
'@capacitor/filesystem@8.1.2(@capacitor/core@3.9.0)':
dependencies:
'@capacitor/core': 3.9.0
'@capacitor/synapse': 1.0.4
'@capacitor/synapse@1.0.4': {}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@@ -1055,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
@@ -1083,18 +1171,18 @@ 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': {}
@@ -1145,10 +1233,26 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2 '@tauri-apps/cli-win32-ia32-msvc': 2.11.2
'@tauri-apps/cli-win32-x64-msvc': 2.11.2 '@tauri-apps/cli-win32-x64-msvc': 2.11.2
'@tauri-apps/plugin-dialog@2.7.1':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-fs@2.5.1':
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
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.11.0
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -1158,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': {}
@@ -1171,6 +1279,10 @@ snapshots:
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
capacitor-native-biometric@4.2.2:
dependencies:
'@capacitor/core': 3.9.0
chokidar@4.0.3: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
@@ -1299,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: {}
@@ -1434,12 +1546,13 @@ snapshots:
totalist@3.0.1: {} totalist@3.0.1: {}
tslib@2.8.1: tslib@2.8.1: {}
optional: true
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
@@ -1447,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 -1
View File
@@ -4,7 +4,7 @@
"version": "0.9.4", "version": "0.9.4",
"identifier": "io.github.MokuProject.Moku", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../build",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm build"
}, },
"app": { "app": {
+12 -251
View File
@@ -1,262 +1,23 @@
@import './lib/design/index.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;
} }
[data-theme="dark"] { html,
--bg-void: #000000; body,
--bg-base: #080808; #svelte {
--bg-surface: #0d0d0d; width: 100%;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
} }
[data-theme="light"] { body {
--bg-void: #d8d4ce; overscroll-behavior: none;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
[data-theme="midnight"] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
[data-theme="original"] {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
}
[data-theme="warm"] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
} }
#svelte { #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 {
+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 {}
+71 -49
View File
@@ -1,53 +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 SAVED_URL_KEY = 'moku_server_url' const SAVED_URL_KEY = 'moku_server_url';
const SAVED_AUTH_KEY = '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 normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
return mode === 'BASIC_AUTH' ? 'BASIC_AUTH' : mode === 'UI_LOGIN' || mode === 'SIMPLE_LOGIN' ? 'UI_LOGIN' : 'NONE';
} }
function isTauri(): boolean { function isTauri(): boolean {
return '__TAURI_INTERNALS__' in window return '__TAURI_INTERNALS__' in window;
} }
function isCapacitor(): boolean { function isCapacitor(): boolean {
return 'Capacitor' in window return 'Capacitor' in window;
} }
function loadSavedServerUrl(): string { function loadSavedServerUrl(): string {
return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567' return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567';
} }
function loadSavedAuth(): SavedAuth { function loadSavedAuth(): SavedAuth {
try { try {
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' } return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? {mode: 'NONE'};
} catch { } catch {
return { mode: 'NONE' } 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() {
@@ -55,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 = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web' initRequestManager(serverAdapter);
appState.version = await platformAdapter.getVersion() initPlatformService(platformAdapter);
const savedUrl = loadSavedServerUrl() await Promise.all([
const savedAuth = loadSavedAuth() initSettingsState(),
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();
if (isTauri() && platformAdapter.isSupported('server-management')) { const legacyAuth = loadSavedAuth();
await platformAdapter.launchServer({ url: savedUrl }).catch(() => {}) const savedUrl = settingsState.serverUrl || loadSavedServerUrl();
} const savedAuth: SavedAuth = {
mode: normalizeAuthMode(settingsState.serverAuthMode || legacyAuth.mode),
user: settingsState.serverAuthUser || legacyAuth.user,
pass: settingsState.serverAuthPass || legacyAuth.pass,
};
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass) updateSettings({
await serverAdapter.connect({ baseUrl: savedUrl }) serverUrl: savedUrl,
serverAuthMode: savedAuth.mode,
serverAuthUser: savedAuth.user ?? '',
serverAuthPass: savedAuth.pass ?? '',
});
const probe = await probeServer() appState.serverUrl = savedUrl;
appState.authMode = savedAuth.mode;
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass);
await serverAdapter.connect({baseUrl: savedUrl});
const probe = await probeServer();
if (probe === 'auth_required') { if (probe === 'auth_required') {
appState.status = 'auth' appState.status = 'auth';
return return;
} }
if (probe === 'unreachable') { if (probe === 'unreachable') {
appState.error = `Could not reach server at ${savedUrl}` appState.error = `Could not reach server at ${savedUrl}`;
appState.status = 'error' appState.status = 'error';
return return;
} }
appState.authenticated = true appState.authenticated = true;
appState.status = 'ready' appState.status = 'ready';
} catch (e) { } catch (e) {
appState.error = String(e) appState.error = String(e);
appState.status = 'error' appState.status = 'error';
} }
} }
boot() boot();
+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();
};
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@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));
+39
View File
@@ -0,0 +1,39 @@
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let index = 0;
async function worker() {
while (index < items.length) {
if (signal.aborted) return;
const item = items[index++];
await fn(item).catch(() => {});
}
}
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, worker));
}
const inflight = new Map<string, Promise<unknown>>();
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
if (typeof keyOrFn === 'function') {
const fn = keyOrFn;
return (key: string) => dedupeRequest(key, () => fn(key));
}
const key = keyOrFn;
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const request = factory!().finally(() => inflight.delete(key));
inflight.set(key, request);
return request;
}
@@ -0,0 +1,27 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>;
reset(): void;
hasMore(): boolean;
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{items: T[]; hasNextPage: boolean;}>;
}
export function createPaginatedQuery<T>(config: PaginatedQueryConfig<T>): PaginatedQuery<T> {
let hasMore = true;
return {
async fetchPage(page) {
const {items, hasNextPage} = await config.fetcher(page);
hasMore = hasNextPage;
return items;
},
reset() {
hasMore = true;
},
hasMore() {
return hasMore;
},
};
}
+36
View File
@@ -0,0 +1,36 @@
export interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
}
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
shouldRetry = () => true,
} = options;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !shouldRetry(error, attempt)) {
throw error;
}
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
+83
View File
@@ -0,0 +1,83 @@
import type {Settings} from '$lib/types/settings';
export interface HistoryBackupPayload {
history: unknown[];
bookmarks: unknown[];
markers: unknown[];
readLog: unknown[];
readingStats: Record<string, unknown>;
dailyReadCounts: Record<string, number>;
}
export interface AppDataBackup {
version: 1;
exportedAt: string;
settings: Settings;
history: HistoryBackupPayload;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function buildAppDataBackup(settings: Settings, history: HistoryBackupPayload): AppDataBackup {
return {
version: 1,
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();
});
}
+153
View File
@@ -0,0 +1,153 @@
import {fetchAuthenticated, getAuthMode} from '$lib/core/auth';
import {resolveImageUrl} from '$lib/core/image';
interface CacheEntry {
value: string;
revokable: boolean;
}
interface QueueEntry {
url: string;
priority: number;
resolve: (value: string) => void;
reject: (error: unknown) => void;
}
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<string>>();
const queue: QueueEntry[] = [];
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
async function doFetch(url: string): Promise<string> {
const resolved = resolveImageUrl(url) ?? url;
if (getAuthMode() === 'NONE') {
cache.set(url, {value: resolved, revokable: false});
return resolved;
}
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) {
let lo = 0;
let hi = queue.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (queue[mid].priority > entry.priority) lo = mid + 1;
else hi = mid;
}
queue.splice(lo, 0, entry);
}
function drain() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift();
if (!entry) break;
active += 1;
void doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => {
active -= 1;
drain();
});
}
}
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
requestAnimationFrame(drain);
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
insertSorted({url, priority, resolve, reject});
}).catch((error) => {
inflight.delete(url);
return Promise.reject(error);
});
inflight.set(url, promise);
scheduleDrain();
return promise;
}
export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve('');
const cached = cache.get(url);
if (cached) return Promise.resolve(cached.value);
const existing = inflight.get(url);
if (existing) {
const queueIndex = queue.findIndex((entry) => entry.url === url);
if (queueIndex !== -1 && priority > queue[queueIndex].priority) {
const [entry] = queue.splice(queueIndex, 1);
if (entry) {
entry.priority = priority;
insertSorted(entry);
}
}
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, index) => {
if (!url || cache.has(url) || inflight.has(url)) return;
void enqueue(url, basePriority - index);
});
}
export function revokeBlobUrl(url: string): void {
const entry = cache.get(url);
if (!entry) return;
if (entry.revokable) URL.revokeObjectURL(entry.value);
cache.delete(url);
}
export function deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority);
}
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException('Cancelled', 'AbortError'));
}
}
export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
for (const [url, entry] of cache.entries()) {
if (entry.revokable) URL.revokeObjectURL(entry.value);
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();
}
+16 -16
View File
@@ -12,7 +12,7 @@ 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);
@@ -189,10 +189,10 @@ 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);},
}; };
} }
@@ -201,12 +201,12 @@ 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("@core/cache/imageCache"); const {revokeBlobUrl, getBlobUrl} = await import('$lib/core/cache/imageCache');
revokeBlobUrl(thumbnailUrl); revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {}); getBlobUrl(thumbnailUrl, 999).catch(() => {});
} }
+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());
}
+18
View File
@@ -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 {
+77
View File
@@ -0,0 +1,77 @@
import {isSupported, readFile, writeFile} from '$lib/platform-service';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const STORAGE_PREFIX = 'moku:';
function localStorageKey(key: string): string {
return `${STORAGE_PREFIX}${key}`;
}
function fileName(key: string): string {
return `moku.${key}.json`;
}
function canUseLocalStorage(): boolean {
return typeof window !== 'undefined' && typeof localStorage !== 'undefined';
}
function canUseFilesystem(): boolean {
try {
return isSupported('filesystem');
} catch {
return false;
}
}
export async function loadPersistentState<T>(key: string): Promise<T | null> {
if (canUseFilesystem()) {
try {
const data = await readFile(fileName(key));
if (data.length > 0) {
return JSON.parse(decoder.decode(data)) as T;
}
} catch {
// Fall back to localStorage when the file does not exist or the adapter cannot read it yet.
}
}
if (!canUseLocalStorage()) return null;
try {
const raw = localStorage.getItem(localStorageKey(key));
return raw ? JSON.parse(raw) as T : null;
} catch {
return null;
}
}
export async function savePersistentState<T>(key: string, value: T): Promise<void> {
const json = JSON.stringify(value);
if (canUseLocalStorage()) {
localStorage.setItem(localStorageKey(key), json);
}
if (!canUseFilesystem()) return;
try {
await writeFile(fileName(key), encoder.encode(json));
} catch {
// LocalStorage remains the fallback when a platform adapter cannot persist to files.
}
}
export async function clearPersistentState(key: string): Promise<void> {
if (canUseLocalStorage()) {
localStorage.removeItem(localStorageKey(key));
}
if (!canUseFilesystem()) return;
try {
await writeFile(fileName(key), encoder.encode('null'));
} catch {
// Ignore native persistence failures during cleanup.
}
}
+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
);
}
+79
View File
@@ -0,0 +1,79 @@
import {settingsState, updateSettings} from '$lib/state/settings.svelte';
import type {CustomTheme, Theme} from '$lib/types/settings';
let themeStyleEl: HTMLStyleElement | null = null;
function ensureThemeStyleEl(): HTMLStyleElement {
if (themeStyleEl) return themeStyleEl;
themeStyleEl = document.createElement('style');
themeStyleEl.id = 'moku-custom-theme';
document.head.appendChild(themeStyleEl);
return themeStyleEl;
}
function removeCustomThemeCss() {
themeStyleEl?.remove();
themeStyleEl = null;
}
function resolveBuiltinTheme(theme: Theme): string {
if (theme === 'light-contrast') return 'light';
return theme || 'dark';
}
export function applyTheme(theme: Theme, customThemes: CustomTheme[] = []) {
const activeTheme = theme || 'dark';
const customThemeId = activeTheme.startsWith('custom:') ? activeTheme.slice(7) : activeTheme;
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() {
if (systemThemeMedia && systemThemeHandler) {
systemThemeMedia.removeEventListener('change', systemThemeHandler);
}
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;
+8 -8
View File
@@ -1,7 +1,7 @@
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);
@@ -11,7 +11,7 @@ export function timeAgo(ts: number): string {
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,7 +19,7 @@ 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 {
@@ -109,7 +109,7 @@ export function dedupeSourcesByLang(
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);
@@ -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;
}
+44 -18
View File
@@ -1,40 +1,66 @@
import { getAdapter } from '$lib/request-manager' import {getAdapter} from '$lib/request-manager';
import { seriesState } from '$lib/state/series.svelte' import {seriesState} from '$lib/state/series.svelte';
import { readerState } from '$lib/state/reader.svelte' import {readerState} from '$lib/state/reader.svelte';
export async function loadChapters(mangaId: string) { export async function loadChapters(mangaId: string) {
seriesState.chaptersLoading = true seriesState.chaptersLoading = true;
seriesState.chaptersError = null seriesState.chaptersError = null;
try { try {
seriesState.chapters = await getAdapter().getChapters(mangaId) seriesState.chapters = await getAdapter().getChapters(mangaId);
} catch (e) { } catch (e) {
seriesState.chaptersError = String(e) seriesState.chaptersError = String(e);
} finally { } finally {
seriesState.chaptersLoading = false seriesState.chaptersLoading = false;
} }
} }
export async function loadChapterPages(chapterId: string) { export async function loadChapterPages(chapterId: string) {
readerState.pagesLoading = true readerState.pagesLoading = true;
readerState.pagesError = null readerState.pagesError = null;
try { try {
readerState.pages = await getAdapter().getChapterPages(chapterId) readerState.pages = await getAdapter().getChapterPages(chapterId);
} catch (e) { } catch (e) {
readerState.pagesError = String(e) readerState.pagesError = String(e);
} finally { } finally {
readerState.pagesLoading = false readerState.pagesLoading = false;
}
}
export async function updateProgress(chapterId: string, lastPageRead: number, read = false) {
await getAdapter().updateChapterProgress(chapterId, lastPageRead, read);
const chapterIds = new Set<string>([chapterId]);
const nextRead = read || false;
for (const chapter of seriesState.chapters) {
if (chapterIds.has(String(chapter.id))) {
chapter.lastPageRead = lastPageRead;
chapter.read = nextRead;
}
}
for (const chapter of readerState.chapters) {
if (chapterIds.has(String(chapter.id))) {
chapter.lastPageRead = lastPageRead;
chapter.read = nextRead;
}
}
if (readerState.chapter && String(readerState.chapter.id) === chapterId) {
readerState.chapter.lastPageRead = lastPageRead;
readerState.chapter.read = nextRead;
} }
} }
export async function markRead(id: string, read: boolean) { export async function markRead(id: string, read: boolean) {
await getAdapter().markChapterRead(id, read) await getAdapter().markChapterRead(id, read);
const chapter = seriesState.chapters.find(c => c.id === id) const chapter = seriesState.chapters.find(c => String(c.id) === id);
if (chapter) chapter.read = read if (chapter) chapter.read = read;
} }
export async function markManyRead(ids: string[], read: boolean) { export async function markManyRead(ids: string[], read: boolean) {
await getAdapter().markChaptersRead(ids, read) await getAdapter().markChaptersRead(ids, read);
for (const c of seriesState.chapters) { for (const c of seriesState.chapters) {
if (ids.includes(c.id)) c.read = read if (ids.includes(String(c.id))) c.read = read;
} }
} }
+32 -12
View File
@@ -1,28 +1,48 @@
import { getAdapter } from '$lib/request-manager' import {getAdapter} from '$lib/request-manager';
import { trackingState } from '$lib/state/tracking.svelte' import {trackingState} from '$lib/state/tracking.svelte';
import type {TrackRecord} from '$lib/types/index';
export async function loadTrackers() { export async function loadTrackers() {
trackingState.loading = true trackingState.loading = true;
trackingState.error = null trackingState.error = null;
try { try {
trackingState.trackers = await getAdapter().getTrackers() trackingState.trackers = await getAdapter().getTrackers();
} catch (e) { } catch (e) {
trackingState.error = String(e) trackingState.error = String(e);
} finally { } finally {
trackingState.loading = false trackingState.loading = false;
} }
} }
export async function loadTrackerRecords(): Promise<TrackRecord[]> {
return getAdapter().getTrackerRecords();
}
export async function loginTrackerOAuth(trackerId: number, callbackUrl: string) {
await getAdapter().loginTrackerOAuth(trackerId, callbackUrl);
await loadTrackers();
}
export async function loginTrackerCredentials(trackerId: number, username: string, password: string) {
await getAdapter().loginTrackerCredentials(trackerId, username, password);
await loadTrackers();
}
export async function logoutTracker(trackerId: number) {
await getAdapter().logoutTracker(trackerId);
await loadTrackers();
}
export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) { export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) {
await getAdapter().linkTracker(mangaId, trackerId, remoteId) await getAdapter().linkTracker(mangaId, trackerId, remoteId);
await loadTrackers() await loadTrackers();
} }
export async function syncTracking(mangaId: string) { export async function syncTracking(mangaId: string) {
trackingState.syncing = true trackingState.syncing = true;
try { try {
await getAdapter().syncTracking(mangaId) await getAdapter().syncTracking(mangaId);
} finally { } finally {
trackingState.syncing = false trackingState.syncing = false;
} }
} }
+36 -30
View File
@@ -8,46 +8,52 @@ import type {
Page, Page,
DownloadItem, DownloadItem,
UpdateResult, UpdateResult,
} from '$lib/server-adapters/types' } from '$lib/server-adapters/types';
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types/index';
import type {TrackRecord} from '$lib/types/tracking';
function notImplemented(): never { function notImplemented(): never {
throw new Error('MokuAdapter: not implemented') throw new Error('MokuAdapter: not implemented');
} }
export class MokuAdapter implements ServerAdapter { export class MokuAdapter implements ServerAdapter {
async connect(_config: ServerConfig): Promise<void> { notImplemented() } async connect(_config: ServerConfig): Promise<void> {notImplemented();}
async getStatus(): Promise<ServerStatus> { return notImplemented() } async getStatus(): Promise<ServerStatus> {return notImplemented();}
async getManga(_id: string): Promise<Manga> { return notImplemented() } async getManga(_id: string): Promise<Manga> {return notImplemented();}
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() } async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> {return notImplemented();}
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() } async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> {return notImplemented();}
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() } async addToLibrary(_mangaId: string): Promise<void> {notImplemented();}
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() } async removeFromLibrary(_mangaId: string): Promise<void> {notImplemented();}
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() } async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> {notImplemented();}
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() } async getChapters(_mangaId: string): Promise<Chapter[]> {return notImplemented();}
async getChapter(_id: string): Promise<Chapter> { return notImplemented() } async getChapter(_id: string): Promise<Chapter> {return notImplemented();}
async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() } async getChapterPages(_id: string): Promise<Page[]> {return notImplemented();}
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() } async markChapterRead(_id: string, _read: boolean): Promise<void> {notImplemented();}
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() } async updateChapterProgress(_id: string, _lastPageRead: number, _read?: boolean): Promise<void> {notImplemented();}
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> {notImplemented();}
async getDownloads(): Promise<DownloadItem[]> { return notImplemented() } async getDownloads(): Promise<DownloadItem[]> {return notImplemented();}
async enqueueDownload(_chapterId: string): Promise<void> { notImplemented() } async enqueueDownload(_chapterId: string): Promise<void> {notImplemented();}
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() } async dequeueDownload(_chapterId: string): Promise<void> {notImplemented();}
async clearDownloads(): Promise<void> { notImplemented() } async clearDownloads(): Promise<void> {notImplemented();}
async getExtensions(): Promise<Extension[]> { return notImplemented() } async getExtensions(): Promise<Extension[]> {return notImplemented();}
async installExtension(_id: string): Promise<void> { notImplemented() } async installExtension(_id: string): Promise<void> {notImplemented();}
async uninstallExtension(_id: string): Promise<void> { notImplemented() } async uninstallExtension(_id: string): Promise<void> {notImplemented();}
async updateExtension(_id: string): Promise<void> { notImplemented() } async updateExtension(_id: string): Promise<void> {notImplemented();}
async getSources(): Promise<Source[]> { return notImplemented() } async getSources(): Promise<Source[]> {return notImplemented();}
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() } async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> {return notImplemented();}
async getTrackers(): Promise<Tracker[]> { return notImplemented() } async getTrackers(): Promise<Tracker[]> {return notImplemented();}
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() } async getTrackerRecords(): Promise<TrackRecord[]> {return notImplemented();}
async syncTracking(_mangaId: string): Promise<void> { notImplemented() } async loginTrackerOAuth(_trackerId: number, _callbackUrl: string): Promise<void> {notImplemented();}
async loginTrackerCredentials(_trackerId: number, _username: string, _password: string): Promise<void> {notImplemented();}
async logoutTracker(_trackerId: number): Promise<void> {notImplemented();}
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> {notImplemented();}
async syncTracking(_mangaId: string): Promise<void> {notImplemented();}
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() } async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> {return notImplemented();}
} }
+127 -89
View File
@@ -8,8 +8,9 @@ import type {
Page, Page,
DownloadItem, DownloadItem,
UpdateResult, UpdateResult,
} from '$lib/server-adapters/types' } from '$lib/server-adapters/types';
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types/index';
import type {TrackRecord} from '$lib/types/tracking';
import { import {
GET_LIBRARY, GET_LIBRARY,
GET_MANGA, GET_MANGA,
@@ -18,39 +19,43 @@ import {
UPDATE_MANGA, UPDATE_MANGA,
SET_MANGA_META, SET_MANGA_META,
UPDATE_LIBRARY, UPDATE_LIBRARY,
FETCH_SOURCE_MANGA, } from './manga';
} from './manga'
import { import {
GET_CHAPTERS, GET_CHAPTERS,
FETCH_CHAPTERS, FETCH_CHAPTERS,
FETCH_CHAPTER_PAGES, FETCH_CHAPTER_PAGES,
MARK_CHAPTER_READ, MARK_CHAPTER_READ,
MARK_CHAPTERS_READ, MARK_CHAPTERS_READ,
} from './chapters' UPDATE_CHAPTERS_PROGRESS,
} from './chapters';
import { import {
GET_DOWNLOAD_STATUS, GET_DOWNLOAD_STATUS,
ENQUEUE_DOWNLOAD, ENQUEUE_DOWNLOAD,
DEQUEUE_DOWNLOAD, DEQUEUE_DOWNLOAD,
CLEAR_DOWNLOADER, CLEAR_DOWNLOADER,
} from './downloads' FETCH_SOURCE_MANGA,
} from './downloads';
import { import {
GET_EXTENSIONS, GET_EXTENSIONS,
GET_SOURCES, GET_SOURCES,
FETCH_EXTENSIONS, FETCH_EXTENSIONS,
UPDATE_EXTENSION, UPDATE_EXTENSION,
} from './extensions' } from './extensions';
import { import {
GET_TRACKERS, GET_TRACKERS,
BIND_TRACK, BIND_TRACK,
TRACK_PROGRESS, TRACK_PROGRESS,
} from './tracking' LOGIN_TRACKER_OAUTH,
LOGIN_TRACKER_CREDENTIALS,
LOGOUT_TRACKER,
} from './tracking';
import { import {
GQLResponse,
mapManga, mapManga,
mapChapter, mapChapter,
mapExtension, mapExtension,
mapDownloadItem, mapDownloadItem,
} from './types' } from './types';
import type {GQLResponse} from './types';
const GET_CHAPTER = ` const GET_CHAPTER = `
query GetChapter($id: Int!) { query GetChapter($id: Int!) {
@@ -59,17 +64,17 @@ const GET_CHAPTER = `
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
} }
} }
` `;
export class SuwayomiAdapter implements ServerAdapter { export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567' private baseUrl = 'http://127.0.0.1:4567';
private authHeader: string | null = null private authHeader: string | null = null;
async connect(config: ServerConfig) { async connect(config: ServerConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '') this.baseUrl = config.baseUrl.replace(/\/$/, '');
if (config.credentials) { if (config.credentials) {
const { username, password } = config.credentials const {username, password} = config.credentials;
this.authHeader = 'Basic ' + btoa(`${username}:${password}`) this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
} }
} }
@@ -78,154 +83,187 @@ export class SuwayomiAdapter implements ServerAdapter {
const res = await fetch(`${this.baseUrl}/api/graphql`, { const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST', method: 'POST',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify({ query: '{ aboutServer { name } }' }), body: JSON.stringify({query: '{ aboutServer { name } }'}),
}) });
return res.ok ? 'connected' : 'error' return res.ok ? 'connected' : 'error';
} catch { } catch {
return 'disconnected' return 'disconnected';
} }
} }
private headers(): Record<string, string> { private headers(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' } const h: Record<string, string> = {'Content-Type': 'application/json'};
if (this.authHeader) h['Authorization'] = this.authHeader if (this.authHeader) h['Authorization'] = this.authHeader;
return h return h;
} }
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> { private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/graphql`, { const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST', method: 'POST',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify({ query, variables }), body: JSON.stringify({query, variables}),
}) });
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`) if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json() const json: GQLResponse<T> = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message) if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data return json.data;
} }
async getManga(id: string): Promise<Manga> { async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) }) const data = await this.gql<{manga: Record<string, unknown>;}>(GET_MANGA, {id: Number(id)});
return mapManga(data.manga) return mapManga(data.manga);
} }
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> { async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY) const data = await this.gql<{mangas: {nodes: Record<string, unknown>[];};}>(GET_LIBRARY);
let items = data.mangas.nodes.map(mapManga) let items = data.mangas.nodes.map(mapManga);
if (filters.status) items = items.filter(m => m.status === filters.status) if (filters.status) items = items.filter(m => m.status === filters.status);
if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t))) if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t)));
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0) if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId) if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId);
return { items, hasNextPage: false } return {items, hasNextPage: false};
} }
async searchManga(query: string, sourceId?: string): Promise<Manga[]> { async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
if (!sourceId) return [] if (!sourceId) return [];
const data = await this.gql<{ const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] } fetchSourceManga: {mangas: Record<string, unknown>[];};
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query }) }>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'SEARCH', page: 1, query});
return data.fetchSourceManga.mangas.map(mapManga) return data.fetchSourceManga.mangas.map(mapManga);
} }
async addToLibrary(mangaId: string) { async addToLibrary(mangaId: string) {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true }) await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: true});
} }
async removeFromLibrary(mangaId: string) { async removeFromLibrary(mangaId: string) {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false }) await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: false});
} }
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) { async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
for (const [key, value] of Object.entries(meta)) { for (const [key, value] of Object.entries(meta)) {
if (value === undefined) continue if (value === undefined) continue;
await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) }) await this.gql(SET_MANGA_META, {mangaId: Number(id), key, value: String(value)});
} }
} }
async getChapters(mangaId: string): Promise<Chapter[]> { async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>( const data = await this.gql<{chapters: {nodes: Record<string, unknown>[];};}>(
GET_CHAPTERS, { mangaId: Number(mangaId) } GET_CHAPTERS, {mangaId: Number(mangaId)}
) );
return data.chapters.nodes.map(mapChapter) return data.chapters.nodes.map(mapChapter);
} }
async getChapter(id: string): Promise<Chapter> { async getChapter(id: string): Promise<Chapter> {
const data = await this.gql<{ chapter: Record<string, unknown> }>( const data = await this.gql<{chapter: Record<string, unknown>;}>(
GET_CHAPTER, { id: Number(id) } GET_CHAPTER, {id: Number(id)}
) );
return mapChapter(data.chapter) return mapChapter(data.chapter);
} }
async getChapterPages(id: string): Promise<Page[]> { async getChapterPages(id: string): Promise<Page[]> {
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>( const data = await this.gql<{fetchChapterPages: {pages: string[];};}>(
FETCH_CHAPTER_PAGES, { chapterId: Number(id) } FETCH_CHAPTER_PAGES, {chapterId: Number(id)}
) );
return data.fetchChapterPages.pages.map((url, index) => ({ index, url })) return data.fetchChapterPages.pages.map((url, index) => ({index, url}));
} }
async markChapterRead(id: string, read: boolean) { async markChapterRead(id: string, read: boolean) {
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read }) await this.gql(MARK_CHAPTER_READ, {id: Number(id), isRead: read});
}
async updateChapterProgress(id: string, lastPageRead: number, read?: boolean) {
await this.gql(UPDATE_CHAPTERS_PROGRESS, {
ids: [Number(id)],
lastPageRead,
isRead: read,
});
} }
async markChaptersRead(ids: string[], read: boolean) { async markChaptersRead(ids: string[], read: boolean) {
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read }) await this.gql(MARK_CHAPTERS_READ, {ids: ids.map(Number), isRead: read});
} }
async getDownloads(): Promise<DownloadItem[]> { async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>( const data = await this.gql<{downloadStatus: {queue: Record<string, unknown>[];};}>(
GET_DOWNLOAD_STATUS GET_DOWNLOAD_STATUS
) );
return data.downloadStatus.queue.map(mapDownloadItem) return data.downloadStatus.queue.map(mapDownloadItem);
} }
async enqueueDownload(chapterId: string) { async enqueueDownload(chapterId: string) {
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) await this.gql(ENQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
} }
async dequeueDownload(chapterId: string) { async dequeueDownload(chapterId: string) {
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) await this.gql(DEQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
} }
async clearDownloads() { async clearDownloads() {
await this.gql(CLEAR_DOWNLOADER) await this.gql(CLEAR_DOWNLOADER);
} }
async getExtensions(): Promise<Extension[]> { async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS) await this.gql(FETCH_EXTENSIONS);
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS) const data = await this.gql<{extensions: {nodes: Record<string, unknown>[];};}>(GET_EXTENSIONS);
return data.extensions.nodes.map(mapExtension) return data.extensions.nodes.map(mapExtension);
} }
async installExtension(id: string) { async installExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, install: true }) await this.gql(UPDATE_EXTENSION, {id, install: true});
} }
async uninstallExtension(id: string) { async uninstallExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, uninstall: true }) await this.gql(UPDATE_EXTENSION, {id, uninstall: true});
} }
async updateExtension(id: string) { async updateExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, update: true }) await this.gql(UPDATE_EXTENSION, {id, update: true});
} }
async getSources(): Promise<Source[]> { async getSources(): Promise<Source[]> {
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) const data = await this.gql<{sources: {nodes: Source[];};}>(GET_SOURCES);
return data.sources.nodes return data.sources.nodes;
} }
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> { async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean } fetchSourceManga: {mangas: Record<string, unknown>[]; hasNextPage: boolean;};
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page }) }>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'LATEST', page});
return { return {
items: data.fetchSourceManga.mangas.map(mapManga), items: data.fetchSourceManga.mangas.map(mapManga),
hasNextPage: data.fetchSourceManga.hasNextPage, hasNextPage: data.fetchSourceManga.hasNextPage,
} };
} }
async getTrackers(): Promise<Tracker[]> { async getTrackers(): Promise<Tracker[]> {
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) const data = await this.gql<{trackers: {nodes: Tracker[];};}>(GET_TRACKERS);
return data.trackers.nodes return data.trackers.nodes;
}
async getTrackerRecords(): Promise<TrackRecord[]> {
const trackers = await this.getTrackers();
const records: TrackRecord[] = [];
for (const tracker of trackers) {
for (const record of tracker.trackRecords?.nodes ?? []) {
records.push(record);
}
}
return records;
}
async loginTrackerOAuth(trackerId: number, callbackUrl: string) {
await this.gql(LOGIN_TRACKER_OAUTH, {trackerId, callbackUrl});
}
async loginTrackerCredentials(trackerId: number, username: string, password: string) {
await this.gql(LOGIN_TRACKER_CREDENTIALS, {trackerId, username, password});
}
async logoutTracker(trackerId: number) {
await this.gql(LOGOUT_TRACKER, {trackerId});
} }
async linkTracker(mangaId: string, trackerId: string, remoteId: string) { async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
@@ -233,25 +271,25 @@ export class SuwayomiAdapter implements ServerAdapter {
mangaId: Number(mangaId), mangaId: Number(mangaId),
trackerId: Number(trackerId), trackerId: Number(trackerId),
remoteId, remoteId,
}) });
} }
async syncTracking(mangaId: string) { async syncTracking(mangaId: string) {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) await this.gql(TRACK_PROGRESS, {mangaId: Number(mangaId)});
} }
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> { async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
if (mangaIds?.length) { if (mangaIds?.length) {
const results: UpdateResult[] = [] const results: UpdateResult[] = [];
for (const id of mangaIds) { for (const id of mangaIds) {
const before = await this.getChapters(id) const before = await this.getChapters(id);
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) }) await this.gql(FETCH_CHAPTERS, {mangaId: Number(id)});
const after = await this.getChapters(id) const after = await this.getChapters(id);
results.push({ mangaId: id, newChapters: after.length - before.length }) results.push({mangaId: id, newChapters: after.length - before.length});
} }
return results return results;
} }
await this.gql(UPDATE_LIBRARY) await this.gql(UPDATE_LIBRARY);
return [] return [];
} }
} }
+24 -9
View File
@@ -6,10 +6,17 @@ export const GET_TRACKERS = `
supportsPrivateTracking supportsReadingDates supportsTrackDeletion supportsPrivateTracking supportsReadingDates supportsTrackDeletion
scores scores
statuses { value name } statuses { value name }
trackRecords {
nodes {
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
manga { id title thumbnailUrl inLibrary }
} }
} }
} }
` }
}
`;
export const GET_MANGA_TRACK_RECORDS = ` export const GET_MANGA_TRACK_RECORDS = `
query GetMangaTrackRecords($mangaId: Int!) { query GetMangaTrackRecords($mangaId: Int!) {
@@ -22,7 +29,7 @@ export const GET_MANGA_TRACK_RECORDS = `
} }
} }
} }
` `;
export const SEARCH_TRACKER = ` export const SEARCH_TRACKER = `
query SearchTracker($trackerId: Int!, $query: String!) { query SearchTracker($trackerId: Int!, $query: String!) {
@@ -33,7 +40,7 @@ export const SEARCH_TRACKER = `
} }
} }
} }
` `;
export const BIND_TRACK = ` export const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) { mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
@@ -41,7 +48,7 @@ export const BIND_TRACK = `
trackRecord { id trackerId remoteId } trackRecord { id trackerId remoteId }
} }
} }
` `;
export const TRACK_PROGRESS = ` export const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) { mutation TrackProgress($mangaId: Int!) {
@@ -49,7 +56,7 @@ export const TRACK_PROGRESS = `
trackRecords { id trackerId lastChapterRead status } trackRecords { id trackerId lastChapterRead status }
} }
} }
` `;
export const UPDATE_TRACK = ` export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $score: Float, $lastChapterRead: Float, $startDate: LongString, $finishDate: LongString, $private: Boolean) { mutation UpdateTrack($recordId: Int!, $status: Int, $score: Float, $lastChapterRead: Float, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
@@ -65,7 +72,7 @@ export const UPDATE_TRACK = `
trackRecord { id status score lastChapterRead } trackRecord { id status score lastChapterRead }
} }
} }
` `;
export const UNLINK_TRACK = ` export const UNLINK_TRACK = `
mutation UnlinkTrack($trackRecordId: Int!) { mutation UnlinkTrack($trackRecordId: Int!) {
@@ -73,7 +80,7 @@ export const UNLINK_TRACK = `
trackRecord { id } trackRecord { id }
} }
} }
` `;
export const LOGIN_TRACKER_CREDENTIALS = ` export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) { mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
@@ -81,7 +88,15 @@ export const LOGIN_TRACKER_CREDENTIALS = `
isLoggedIn isLoggedIn
} }
} }
` `;
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
}
}
`;
export const LOGOUT_TRACKER = ` export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) { mutation LogoutTracker($trackerId: Int!) {
@@ -89,4 +104,4 @@ export const LOGOUT_TRACKER = `
isLoggedIn isLoggedIn
} }
} }
` `;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Manga, Chapter, Extension } from '$lib/types' import type { Manga, Chapter, Extension } from '$lib/types/index'
import type { DownloadItem } from '$lib/server-adapters/types' import type { DownloadItem } from '$lib/server-adapters/types'
export interface GQLResponse<T> { export interface GQLResponse<T> {
+61 -55
View File
@@ -4,91 +4,97 @@ import type {
Extension, Extension,
Source, Source,
Tracker, Tracker,
} from '$lib/types' } from '$lib/types/index';
import type {TrackRecord} from '$lib/types/tracking';
export interface ServerConfig { export interface ServerConfig {
baseUrl: string baseUrl: string;
credentials?: { username: string; password: string } credentials?: {username: string; password: string;};
} }
export type ServerStatus = 'connected' | 'disconnected' | 'error' export type ServerStatus = 'connected' | 'disconnected' | 'error';
export interface MangaFilters { export interface MangaFilters {
inLibrary?: boolean inLibrary?: boolean;
status?: MangaStatus status?: MangaStatus;
tags?: string[] tags?: string[];
unread?: boolean unread?: boolean;
sourceId?: string sourceId?: string;
} }
export type MangaStatus = 'ONGOING' | 'COMPLETED' | 'LICENSED' | 'PUBLISHING_FINISHED' | 'CANCELLED' | 'ON_HIATUS' export type MangaStatus = 'ONGOING' | 'COMPLETED' | 'LICENSED' | 'PUBLISHING_FINISHED' | 'CANCELLED' | 'ON_HIATUS';
export interface PaginatedResult<T> { export interface PaginatedResult<T> {
items: T[] items: T[];
hasNextPage: boolean hasNextPage: boolean;
total?: number total?: number;
} }
export interface MangaMeta { export interface MangaMeta {
customTitle?: string customTitle?: string;
customCover?: string customCover?: string;
notes?: string notes?: string;
[key: string]: unknown [key: string]: unknown;
} }
export interface Page { export interface Page {
index: number index: number;
url: string url: string;
imageData?: string imageData?: string;
} }
export interface DownloadItem { export interface DownloadItem {
chapterId: string chapterId: string;
mangaId: string mangaId: string;
chapterName: string chapterName: string;
mangaTitle: string mangaTitle: string;
progress: number progress: number;
state: 'queued' | 'downloading' | 'finished' | 'error' state: 'queued' | 'downloading' | 'finished' | 'error';
} }
export interface UpdateResult { export interface UpdateResult {
mangaId: string mangaId: string;
newChapters: number newChapters: number;
} }
export interface ServerAdapter { export interface ServerAdapter {
connect(config: ServerConfig): Promise<void> connect(config: ServerConfig): Promise<void>;
getStatus(): Promise<ServerStatus> getStatus(): Promise<ServerStatus>;
getManga(id: string): Promise<Manga> getManga(id: string): Promise<Manga>;
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>;
searchManga(query: string, sourceId?: string): Promise<Manga[]> searchManga(query: string, sourceId?: string): Promise<Manga[]>;
addToLibrary(mangaId: string): Promise<void> addToLibrary(mangaId: string): Promise<void>;
removeFromLibrary(mangaId: string): Promise<void> removeFromLibrary(mangaId: string): Promise<void>;
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void> updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>;
getChapters(mangaId: string): Promise<Chapter[]> getChapters(mangaId: string): Promise<Chapter[]>;
getChapter(id: string): Promise<Chapter> getChapter(id: string): Promise<Chapter>;
getChapterPages(id: string): Promise<Page[]> getChapterPages(id: string): Promise<Page[]>;
markChapterRead(id: string, read: boolean): Promise<void> markChapterRead(id: string, read: boolean): Promise<void>;
markChaptersRead(ids: string[], read: boolean): Promise<void> updateChapterProgress(id: string, lastPageRead: number, read?: boolean): Promise<void>;
markChaptersRead(ids: string[], read: boolean): Promise<void>;
getDownloads(): Promise<DownloadItem[]> getDownloads(): Promise<DownloadItem[]>;
enqueueDownload(chapterId: string): Promise<void> enqueueDownload(chapterId: string): Promise<void>;
dequeueDownload(chapterId: string): Promise<void> dequeueDownload(chapterId: string): Promise<void>;
clearDownloads(): Promise<void> clearDownloads(): Promise<void>;
getExtensions(): Promise<Extension[]> getExtensions(): Promise<Extension[]>;
installExtension(id: string): Promise<void> installExtension(id: string): Promise<void>;
uninstallExtension(id: string): Promise<void> uninstallExtension(id: string): Promise<void>;
updateExtension(id: string): Promise<void> updateExtension(id: string): Promise<void>;
getSources(): Promise<Source[]> getSources(): Promise<Source[]>;
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>;
getTrackers(): Promise<Tracker[]> getTrackers(): Promise<Tracker[]>;
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void> getTrackerRecords(): Promise<TrackRecord[]>;
syncTracking(mangaId: string): Promise<void> loginTrackerOAuth(trackerId: number, callbackUrl: string): Promise<void>;
loginTrackerCredentials(trackerId: number, username: string, password: string): Promise<void>;
logoutTracker(trackerId: number): Promise<void>;
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>;
syncTracking(mangaId: string): Promise<void>;
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>;
} }
+3 -2
View File
@@ -1,4 +1,4 @@
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error' export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error';
export const appState = $state({ export const appState = $state({
status: 'booting' as AppStatus, status: 'booting' as AppStatus,
@@ -8,4 +8,5 @@ export const appState = $state({
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
platform: 'web' as 'web' | 'tauri' | 'capacitor', platform: 'web' as 'web' | 'tauri' | 'capacitor',
version: '', version: '',
}) idle: false,
});
+15 -3
View File
@@ -5,12 +5,24 @@ export const downloadsState = $state({
error: null as string | null, error: null as string | null,
}) })
export const activeDownloads = $derived( const activeDownloadsValue = $derived(
downloadsState.items.filter(d => d.state === 'downloading') downloadsState.items.filter(d => d.state === 'downloading')
) )
export const queuedDownloads = $derived( const queuedDownloadsValue = $derived(
downloadsState.items.filter(d => d.state === 'queued') downloadsState.items.filter(d => d.state === 'queued')
) )
export const downloadCount = $derived(downloadsState.items.length) const downloadCountValue = $derived(downloadsState.items.length)
export function activeDownloads() {
return activeDownloadsValue
}
export function queuedDownloads() {
return queuedDownloadsValue
}
export function downloadCount() {
return downloadCountValue
}
+16 -10
View File
@@ -1,4 +1,6 @@
import type { Extension, Source, Manga } from '$lib/types' import type {Extension, Source, Manga} from '$lib/types/index';
import {shouldHideSource} from '$lib/core/util';
import {settingsState} from '$lib/state/settings.svelte';
export const extensionsState = $state({ export const extensionsState = $state({
items: [] as Extension[], items: [] as Extension[],
@@ -16,21 +18,25 @@ export const extensionsState = $state({
browseLoading: false, browseLoading: false,
browseError: null as string | null, browseError: null as string | null,
browseHasMore: false, browseHasMore: false,
}) });
export const filteredExtensions = $derived.by(() => { const filteredExtensionsValue = $derived.by(() => {
let result = extensionsState.items let result = extensionsState.items;
if (extensionsState.filter.installed) { if (extensionsState.filter.installed) {
result = result.filter(e => e.installed) result = result.filter(e => e.isInstalled);
} }
if (extensionsState.filter.language !== 'all') { if (extensionsState.filter.language !== 'all') {
result = result.filter(e => e.lang === extensionsState.filter.language) result = result.filter(e => e.lang === extensionsState.filter.language);
} }
if (extensionsState.filter.query) { if (extensionsState.filter.query) {
const q = extensionsState.filter.query.toLowerCase() const q = extensionsState.filter.query.toLowerCase();
result = result.filter(e => e.name.toLowerCase().includes(q)) result = result.filter(e => e.name.toLowerCase().includes(q));
} }
return result return result;
}) });
export function filteredExtensions() {
return filteredExtensionsValue;
}
+168
View File
@@ -0,0 +1,168 @@
import {untrack} from 'svelte';
import {
DEFAULT_READING_STATS,
type BookmarkEntry,
type HistoryEntry,
type MarkerEntry,
type ReadLogEntry,
type ReadingStats,
} from '$lib/types/history';
import {loadPersistentState, savePersistentState} from '$lib/core/persistence/persist';
const HISTORY_STORAGE_KEY = 'history';
const AVG_MIN_PER_CHAPTER = 5;
interface PersistedHistory {
history: HistoryEntry[];
bookmarks: BookmarkEntry[];
markers: MarkerEntry[];
readLog: ReadLogEntry[];
readingStats: ReadingStats;
dailyReadCounts: Record<string, number>;
}
function localDateString(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function emptyHistoryState(): PersistedHistory {
return {
history: [],
bookmarks: [],
markers: [],
readLog: [],
readingStats: {...DEFAULT_READING_STATS},
dailyReadCounts: {},
};
}
export const historyState = $state(emptyHistoryState());
export const historyStatus = $state({
ready: false,
loading: false,
error: null as string | null,
});
let initialized = false;
let persistQueued = false;
function queueHistoryPersist() {
if (!historyStatus.ready || historyStatus.loading || persistQueued) return;
persistQueued = true;
queueMicrotask(() => {
persistQueued = false;
if (!historyStatus.ready || historyStatus.loading) return;
const snapshot = JSON.stringify(historyState);
void savePersistentState(HISTORY_STORAGE_KEY, JSON.parse(snapshot) as PersistedHistory);
});
}
export async function initHistoryState() {
if (initialized || historyStatus.loading) return;
historyStatus.loading = true;
try {
const persisted = await loadPersistentState<PersistedHistory>(HISTORY_STORAGE_KEY);
untrack(() => {
Object.assign(historyState, {
...emptyHistoryState(),
...persisted,
readingStats: persisted?.readingStats ?? {...DEFAULT_READING_STATS},
dailyReadCounts: persisted?.dailyReadCounts ?? {},
});
});
initialized = true;
historyStatus.ready = true;
historyStatus.error = null;
} catch (error) {
historyStatus.ready = true;
historyStatus.error = String(error);
} finally {
historyStatus.loading = false;
}
}
export function addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
historyState.history = [entry, ...historyState.history.filter(item => item.chapterId !== entry.chapterId)].slice(0, 500);
if (!completed || historyState.readLog.some(item => item.chapterId === entry.chapterId)) {
queueHistoryPersist();
return;
}
historyState.readLog = [
...historyState.readLog,
{mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes},
];
const totalMinutes = historyState.readLog.reduce((sum, item) => sum + item.minutes, 0);
const uniqueChapters = new Set(historyState.readLog.map(item => item.chapterId));
const uniqueManga = new Set(historyState.readLog.map(item => item.mangaId));
const today = localDateString(new Date());
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayKey = localDateString(yesterday);
const previousStreakDate = historyState.readingStats.lastStreakDate;
const streak = previousStreakDate === today
? historyState.readingStats.currentStreakDays
: previousStreakDate === yesterdayKey
? historyState.readingStats.currentStreakDays + 1
: 1;
historyState.readingStats = {
totalChaptersRead: uniqueChapters.size,
totalMangaRead: uniqueManga.size,
totalMinutesRead: totalMinutes,
firstReadAt: historyState.readingStats.firstReadAt || entry.readAt,
lastReadAt: entry.readAt,
currentStreakDays: streak,
longestStreakDays: Math.max(historyState.readingStats.longestStreakDays, streak),
lastStreakDate: today,
};
historyState.dailyReadCounts = {
...historyState.dailyReadCounts,
[today]: (historyState.dailyReadCounts[today] ?? 0) + 1,
};
queueHistoryPersist();
}
export function addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) {
historyState.bookmarks = [
{...entry, savedAt: Date.now(), label},
...historyState.bookmarks.filter(item => item.chapterId !== entry.chapterId),
].slice(0, 200);
queueHistoryPersist();
}
export function removeBookmark(chapterId: number) {
historyState.bookmarks = historyState.bookmarks.filter(item => item.chapterId !== chapterId);
queueHistoryPersist();
}
export function getBookmark(chapterId: number): BookmarkEntry | undefined {
return historyState.bookmarks.find(item => item.chapterId === chapterId);
}
export function clearBookmarks() {
historyState.bookmarks = [];
queueHistoryPersist();
}
export function clearHistory() {
historyState.history = [];
historyState.readLog = [];
historyState.dailyReadCounts = {};
historyState.readingStats = {...DEFAULT_READING_STATS};
queueHistoryPersist();
}
+26 -18
View File
@@ -1,7 +1,9 @@
import type { Manga } from '$lib/types' import type {Manga} from '$lib/types/index';
import type { MangaStatus } from '$lib/server-adapters/types' import type {MangaStatus} from '$lib/server-adapters/types';
import {shouldHideNsfw} from '$lib/core/util';
import {settingsState} from '$lib/state/settings.svelte';
export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded' export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded';
export const libraryState = $state({ export const libraryState = $state({
items: [] as Manga[], items: [] as Manga[],
@@ -18,36 +20,42 @@ export const libraryState = $state({
sortDesc: false, sortDesc: false,
view: 'grid' as 'grid' | 'list', view: 'grid' as 'grid' | 'list',
selected: new Set<string>(), selected: new Set<string>(),
}) });
export const filteredItems = $derived.by(() => { const filteredItemsValue = $derived.by(() => {
let result = libraryState.items let result = libraryState.items;
result = result.filter(m => !shouldHideNsfw(m, settingsState));
if (libraryState.filter.unread) { if (libraryState.filter.unread) {
result = result.filter(m => m.unreadCount > 0) result = result.filter(m => (m.unreadCount ?? 0) > 0);
} }
if (libraryState.filter.status !== 'all') { if (libraryState.filter.status !== 'all') {
result = result.filter(m => m.status === libraryState.filter.status) result = result.filter(m => m.status === libraryState.filter.status);
} }
if (libraryState.filter.tags.length > 0) { if (libraryState.filter.tags.length > 0) {
result = result.filter(m => result = result.filter(m =>
libraryState.filter.tags.every(tag => m.tags?.includes(tag)) libraryState.filter.tags.every(tag => m.tags?.includes(tag))
) );
} }
if (libraryState.filter.query) { if (libraryState.filter.query) {
const q = libraryState.filter.query.toLowerCase() const q = libraryState.filter.query.toLowerCase();
result = result.filter(m => m.title.toLowerCase().includes(q)) result = result.filter(m => m.title.toLowerCase().includes(q));
} }
const sorted = [...result].sort((a, b) => { const sorted = [...result].sort((a, b) => {
switch (libraryState.sort) { switch (libraryState.sort) {
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0) case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0) case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0);
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0) case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0);
case 'alphabetical': case 'alphabetical':
default: return a.title.localeCompare(b.title) default: return a.title.localeCompare(b.title);
} }
}) });
return libraryState.sortDesc ? sorted.reverse() : sorted return libraryState.sortDesc ? sorted.reverse() : sorted;
}) });
export function filteredItems() {
return filteredItemsValue;
}
+40
View File
@@ -0,0 +1,40 @@
import {DEFAULT_MANGA_PREFS, type MangaPrefs} from '$lib/types/settings';
import {settingsState} from '$lib/state/settings.svelte';
export function getMangaPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
const prefs = settingsState.mangaPrefs[mangaId] ?? {};
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
export function getMangaPrefs(mangaId: number): MangaPrefs {
return {
...DEFAULT_MANGA_PREFS,
...(settingsState.mangaPrefs[mangaId] ?? {}),
};
}
export function setMangaPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
settingsState.mangaPrefs = {
...settingsState.mangaPrefs,
[mangaId]: {
...(settingsState.mangaPrefs[mangaId] ?? {}),
[key]: value,
},
};
}
export function replaceMangaPrefs(mangaId: number, prefs: Partial<MangaPrefs>) {
settingsState.mangaPrefs = {
...settingsState.mangaPrefs,
[mangaId]: {
...(settingsState.mangaPrefs[mangaId] ?? {}),
...prefs,
},
};
}
export function clearMangaPrefs(mangaId: number) {
const next = {...settingsState.mangaPrefs};
delete next[mangaId];
settingsState.mangaPrefs = next;
}
+38 -13
View File
@@ -1,9 +1,9 @@
import type { Manga, Chapter } from '$lib/types' import type {Manga, Chapter} from '$lib/types/index';
import type { Page } from '$lib/server-adapters/types' import type {Page} from '$lib/server-adapters/types';
export type ReadMode = 'single' | 'strip' export type ReadMode = 'single' | 'strip';
export type FitMode = 'width' | 'height' | 'original' export type FitMode = 'width' | 'height' | 'original';
export type ReadDirection = 'ltr' | 'rtl' export type ReadDirection = 'ltr' | 'rtl';
export const readerState = $state({ export const readerState = $state({
manga: null as Manga | null, manga: null as Manga | null,
@@ -20,22 +20,47 @@ export const readerState = $state({
direction: 'ltr' as ReadDirection, direction: 'ltr' as ReadDirection,
zoom: 1, zoom: 1,
/** Inspect-mode zoom for single-page view (1 = no magnification). */
inspectScale: 1,
/** Inspect-mode pan offset in CSS pixels. */
inspectPanX: 0,
inspectPanY: 0,
/** Whether auto-scroll is currently active in longstrip mode. */
autoScrollActive: false,
showControls: false, showControls: false,
showSettings: false, showSettings: false,
fullscreen: false, fullscreen: false,
}) });
export const currentPageData = $derived( const currentPageDataValue = $derived(
readerState.pages[readerState.currentPage] ?? null readerState.pages[readerState.currentPage] ?? null
) );
export const progress = $derived( const progressValue = $derived(
readerState.pages.length > 0 readerState.pages.length > 0
? (readerState.currentPage + 1) / readerState.pages.length ? (readerState.currentPage + 1) / readerState.pages.length
: 0 : 0
) );
export const hasPrev = $derived(readerState.currentPage > 0) const hasPrevValue = $derived(readerState.currentPage > 0);
export const hasNext = $derived( const hasNextValue = $derived(
readerState.currentPage < readerState.pages.length - 1 readerState.currentPage < readerState.pages.length - 1
) );
export function currentPageData() {
return currentPageDataValue;
}
export function progress() {
return progressValue;
}
export function hasPrev() {
return hasPrevValue;
}
export function hasNext() {
return hasNextValue;
}
+6 -2
View File
@@ -1,4 +1,4 @@
import type { Manga, Chapter } from '$lib/types' import type { Manga, Chapter } from '$lib/types/index'
export const seriesState = $state({ export const seriesState = $state({
current: null as Manga | null, current: null as Manga | null,
@@ -17,7 +17,7 @@ export const seriesState = $state({
chapterSortDesc: true, chapterSortDesc: true,
}) })
export const filteredChapters = $derived.by(() => { const filteredChaptersValue = $derived.by(() => {
let result = seriesState.chapters let result = seriesState.chapters
if (seriesState.chapterFilter.unread) { if (seriesState.chapterFilter.unread) {
@@ -34,3 +34,7 @@ export const filteredChapters = $derived.by(() => {
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber) const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
return seriesState.chapterSortDesc ? sorted.reverse() : sorted return seriesState.chapterSortDesc ? sorted.reverse() : sorted
}) })
export function filteredChapters() {
return filteredChaptersValue
}
+141
View File
@@ -0,0 +1,141 @@
import {untrack} from 'svelte';
import {DEFAULT_KEYBINDS} from '$lib/core/keybinds/defaultBinds';
import {savePersistentState, loadPersistentState} from '$lib/core/persistence/persist';
import {applyTheme} from '$lib/core/theme';
import {applyZoom} from '$lib/core/ui/zoom';
import {DEFAULT_AUTOMATION_DEFAULTS, DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS, type MangaPrefs, type Settings} from '$lib/types/settings';
const SETTINGS_STORAGE_KEY = 'settings';
const SETTINGS_STORE_VERSION = 1;
interface PersistedSettings {
settings: Partial<Settings> | null;
storeVersion: number | null;
}
function mergeSettings(saved: Partial<Settings> | null | undefined): Settings {
return {
...DEFAULT_SETTINGS,
...saved,
keybinds: {...DEFAULT_KEYBINDS, ...(saved?.keybinds ?? {})},
heroSlots: saved?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.mangaLinks ?? {},
mangaPrefs: saved?.mangaPrefs ?? {},
customThemes: saved?.customThemes ?? [],
hiddenCategoryIds: saved?.hiddenCategoryIds ?? [],
nsfwAllowedSourceIds: saved?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.nsfwBlockedSourceIds ?? [],
libraryTabSort: saved?.libraryTabSort ?? {},
libraryTabStatus: saved?.libraryTabStatus ?? {},
libraryTabFilters: saved?.libraryTabFilters ?? {},
extraScanDirs: saved?.extraScanDirs ?? [],
pinnedSourceIds: saved?.pinnedSourceIds ?? [],
readerPresets: saved?.readerPresets ?? [],
mangaReaderSettings: saved?.mangaReaderSettings ?? {},
hiddenLibraryTabs: saved?.hiddenLibraryTabs ?? [],
libraryPinnedTabOrder: saved?.libraryPinnedTabOrder ?? [],
automationDefaults: saved?.automationDefaults ?? DEFAULT_AUTOMATION_DEFAULTS,
};
}
export const settingsState = $state<Settings>(mergeSettings(null));
export const settingsStatus = $state({
ready: false,
loading: false,
error: null as string | null,
});
let initialized = false;
let persistQueued = false;
function persistSettings() {
const snapshot = JSON.stringify(settingsState);
void savePersistentState(SETTINGS_STORAGE_KEY, {
settings: JSON.parse(snapshot) as Settings,
storeVersion: SETTINGS_STORE_VERSION,
} satisfies PersistedSettings);
}
function applySettingsVisuals() {
if (!settingsStatus.ready || typeof document === 'undefined') return;
applyTheme(settingsState.theme, settingsState.customThemes);
applyZoom(settingsState.uiZoom);
}
function queueSettingsSync() {
applySettingsVisuals();
if (!settingsStatus.ready || settingsStatus.loading || persistQueued) return;
persistQueued = true;
queueMicrotask(() => {
persistQueued = false;
if (!settingsStatus.ready || settingsStatus.loading) return;
persistSettings();
});
}
export async function initSettingsState() {
if (initialized || settingsStatus.loading) return;
settingsStatus.loading = true;
try {
const persisted = await loadPersistentState<PersistedSettings>(SETTINGS_STORAGE_KEY);
untrack(() => {
Object.assign(settingsState, mergeSettings(persisted?.settings));
});
initialized = true;
settingsStatus.ready = true;
settingsStatus.error = null;
applySettingsVisuals();
} catch (error) {
settingsStatus.ready = true;
settingsStatus.error = String(error);
} finally {
settingsStatus.loading = false;
}
}
export function updateSettings(patch: Partial<Settings>) {
Object.assign(settingsState, patch);
queueSettingsSync();
}
export function resetSettings() {
Object.assign(settingsState, mergeSettings(null));
queueSettingsSync();
}
export function getMangaPrefs(mangaId: number): MangaPrefs {
return {
...DEFAULT_MANGA_PREFS,
...(settingsState.mangaPrefs[mangaId] ?? {}),
};
}
export function updateMangaPrefs(mangaId: number, patch: Partial<MangaPrefs>) {
settingsState.mangaPrefs = {
...settingsState.mangaPrefs,
[mangaId]: {
...(settingsState.mangaPrefs[mangaId] ?? {}),
...patch,
},
};
queueSettingsSync();
}
export function clearMangaPrefs(mangaId: number) {
const next = {...settingsState.mangaPrefs};
delete next[mangaId];
settingsState.mangaPrefs = next;
queueSettingsSync();
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Tracker } from '$lib/types' import type { Tracker } from '$lib/types/index'
export const trackingState = $state({ export const trackingState = $state({
trackers: [] as Tracker[], trackers: [] as Tracker[],
+28 -539
View File
@@ -1,539 +1,28 @@
import type { export type { Settings, MangaPrefs } from './settings';
ServerAdapter,
ServerConfig, export type { Manga, MangaDetail, Category, ChapterRef } from './manga';
ServerStatus, export type { Chapter } from './chapter';
MangaFilters, export type { Extension, Source } from './extension';
MangaMeta, export type { Tracker, TrackRecord, TrackerStatus } from './tracking';
PaginatedResult,
Page, export type {
DownloadItem, DownloadQueueItem,
UpdateResult, DownloadStatus,
} from '$lib/server-adapters/types' Connection,
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' PageInfo,
PaginatedConnection,
// ─── GQL client ──────────────────────────────────────────────────────────── MetaEntry,
UpdaterJobsInfo,
interface GQLResponse<T> { UpdateStatus,
data: T AboutServer,
errors?: { message: string }[] ServerUpdateEntry,
} } from './api';
export type {
// ─── Queries ──────────────────────────────────────────────────────────────── HistoryEntry,
BookmarkEntry,
const GET_LIBRARY = ` MarkerColor,
query GetLibrary { MarkerEntry,
mangas(condition: { inLibrary: true }) { ReadLogEntry,
nodes { ReadingStats,
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount LibraryUpdateEntry,
description status author artist genre inLibraryAt lastFetchedAt } from './history';
source { id name displayName }
chapters { totalCount }
lastReadChapter { id chapterNumber }
firstUnreadChapter { id chapterNumber }
}
}
}
`
const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
inLibraryAt lastFetchedAt updateStrategy
source { id name displayName }
lastReadChapter { id chapterNumber lastPageRead }
firstUnreadChapter { id chapterNumber }
highestNumberedChapter { id chapterNumber }
}
}
`
const GET_CHAPTERS = `
query GetChapters($mangaId: Int!) {
chapters(condition: { mangaId: $mangaId }) {
nodes {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`
const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`
const GET_EXTENSIONS = `
query GetExtensions {
extensions {
nodes {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`
const GET_SOURCES = `
query GetSources {
sources {
nodes {
id name lang displayName iconUrl isNsfw
isConfigurable supportsLatest
}
}
}
`
const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired authUrl
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
scores
statuses { value name }
}
}
}
`
// ─── Mutations ──────────────────────────────────────────────────────────────
const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
manga {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
}
`
const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
mangas { id title thumbnailUrl inLibrary }
hasNextPage
}
}
`
const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
manga { id inLibrary }
}
}
`
const SET_MANGA_META = `
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
meta { key value }
}
}
`
const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) {
fetchChapters(input: { mangaId: $mangaId }) {
chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`
const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
}
`
const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
chapter { id isRead }
}
}
`
const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
chapters { id isRead }
}
}
`
const ENQUEUE_DOWNLOAD = `
mutation EnqueueDownload($chapterId: Int!) {
enqueueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`
const DEQUEUE_DOWNLOAD = `
mutation DequeueDownload($chapterId: Int!) {
dequeueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`
const CLEAR_DOWNLOADER = `
mutation ClearDownloader {
clearDownloader(input: {}) {
downloadStatus { state }
}
}
`
const FETCH_EXTENSIONS = `
mutation FetchExtensions {
fetchExtensions(input: {}) {
extensions {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`
const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extension { apkName pkgName name isInstalled hasUpdate }
}
}
`
const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { id trackerId remoteId }
}
}
`
const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) {
trackProgress(input: { mangaId: $mangaId }) {
trackRecords { id trackerId lastChapterRead status }
}
}
`
const UPDATE_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
}
}
`
// ─── Mappers ────────────────────────────────────────────────────────────────
function mapChapter(raw: Record<string, unknown>): Chapter {
return {
id: raw.id as number,
name: raw.name as string,
chapterNumber: raw.chapterNumber as number,
sourceOrder: raw.sourceOrder as number,
read: (raw.isRead as boolean) ?? false,
downloaded: (raw.isDownloaded as boolean) ?? false,
bookmarked: (raw.isBookmarked as boolean) ?? false,
pageCount: (raw.pageCount as number) ?? 0,
mangaId: raw.mangaId as number,
fetchedAt: raw.fetchedAt as string | undefined,
uploadDate: raw.uploadDate as string | null | undefined,
realUrl: raw.realUrl as string | null | undefined,
lastPageRead: raw.lastPageRead as number | undefined,
lastReadAt: raw.lastReadAt as string | undefined,
scanlator: raw.scanlator as string | null | undefined,
manga: raw.manga as Chapter['manga'],
}
}
function mapManga(raw: Record<string, unknown>): Manga {
const inLibraryAt = raw.inLibraryAt as string | null | undefined
return {
...(raw as unknown as Manga),
tags: raw.genre as string[] | undefined,
addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined,
lastReadAt: raw.lastReadChapter
? Date.now()
: undefined,
}
}
function mapExtension(raw: Record<string, unknown>): Extension {
return {
...(raw as unknown as Extension),
id: raw.pkgName as string,
}
}
function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
const chapter = raw.chapter as Record<string, unknown>
const manga = chapter?.manga as Record<string, unknown>
return {
chapterId: String(chapter?.id),
mangaId: String(chapter?.mangaId ?? manga?.id),
chapterName: chapter?.name as string,
mangaTitle: manga?.title as string,
progress: (raw.progress as number) ?? 0,
state: mapDownloadState(raw.state as string),
}
}
function mapDownloadState(state: string): DownloadItem['state'] {
switch (state) {
case 'DOWNLOADING': return 'downloading'
case 'FINISHED': return 'finished'
case 'ERROR': return 'error'
default: return 'queued'
}
}
// ─── Adapter ────────────────────────────────────────────────────────────────
export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567'
private authHeader: string | null = null
async connect(config: ServerConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '')
if (config.credentials) {
const { username, password } = config.credentials
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
}
}
async getStatus(): Promise<ServerStatus> {
try {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
})
return res.ok ? 'connected' : 'error'
} catch {
return 'disconnected'
}
}
private headers(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' }
if (this.authHeader) h['Authorization'] = this.authHeader
return h
}
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query, variables }),
})
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
const json: GQLResponse<T> = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
return json.data
}
// ── Manga ──────────────────────────────────────────────────────────────
async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>(
GET_MANGA, { id: Number(id) }
)
return mapManga(data.manga)
}
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
if (filters.inLibrary) {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
}
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
}
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
if (!sourceId) return []
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] }
}>(FETCH_SOURCE_MANGA, {
source: sourceId,
type: 'SEARCH',
page: 1,
query,
})
return data.fetchSourceManga.mangas.map(mapManga)
}
async addToLibrary(mangaId: string) {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
}
async removeFromLibrary(mangaId: string) {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
}
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
for (const [key, value] of Object.entries(meta)) {
if (value === undefined) continue
await this.gql(SET_MANGA_META, {
mangaId: Number(id),
key,
value: String(value),
})
}
}
// ── Chapters ───────────────────────────────────────────────────────────
async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: Number(mangaId) }
)
return data.chapters.nodes.map(mapChapter)
}
async getChapter(id: string): Promise<Chapter> {
const chapters = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: 0 }
)
const found = chapters.chapters.nodes.find(c => String(c.id) === id)
if (!found) throw new Error(`Chapter ${id} not found`)
return mapChapter(found)
}
async getChapterPages(id: string): Promise<Page[]> {
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>(
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }
)
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
}
async markChapterRead(id: string, read: boolean) {
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read })
}
async markChaptersRead(ids: string[], read: boolean) {
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read })
}
// ── Downloads ──────────────────────────────────────────────────────────
async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{
downloadStatus: { queue: Record<string, unknown>[] }
}>(GET_DOWNLOAD_STATUS)
return data.downloadStatus.queue.map(mapDownloadItem)
}
async enqueueDownload(chapterId: string) {
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
}
async dequeueDownload(chapterId: string) {
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
}
async clearDownloads() {
await this.gql(CLEAR_DOWNLOADER)
}
// ── Extensions ─────────────────────────────────────────────────────────
async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS)
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(
GET_EXTENSIONS
)
return data.extensions.nodes.map(mapExtension)
}
async installExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, install: true })
}
async uninstallExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
}
async updateExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, update: true })
}
async getSources(): Promise<Source[]> {
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
return data.sources.nodes
}
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
}>(FETCH_SOURCE_MANGA, {
source: sourceId,
type: 'LATEST',
page,
})
return {
items: data.fetchSourceManga.mangas.map(mapManga),
hasNextPage: data.fetchSourceManga.hasNextPage,
}
}
// ── Tracking ───────────────────────────────────────────────────────────
async getTrackers(): Promise<Tracker[]> {
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
return data.trackers.nodes
}
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
await this.gql(BIND_TRACK, {
mangaId: Number(mangaId),
trackerId: Number(trackerId),
remoteId,
})
}
async syncTracking(mangaId: string) {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
}
// ── Updates ────────────────────────────────────────────────────────────
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
if (mangaIds?.length) {
const results: UpdateResult[] = []
for (const id of mangaIds) {
const before = await this.getChapters(id)
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) })
const after = await this.getChapters(id)
results.push({ mangaId: id, newChapters: after.length - before.length })
}
return results
}
await this.gql(UPDATE_LIBRARY)
return []
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ export interface Manga {
lastReadChapter?: ChapterRef | null lastReadChapter?: ChapterRef | null
firstUnreadChapter?: ChapterRef | null firstUnreadChapter?: ChapterRef | null
highestNumberedChapter?: ChapterRef | null highestNumberedChapter?: ChapterRef | null
source?: { id: string; name: string; displayName: string } | null source?: { id: string; name: string; displayName: string; isNsfw?: boolean } | null
} }
export interface MangaDetail extends Manga { export interface MangaDetail extends Manga {
+45 -2
View File
@@ -1,4 +1,4 @@
import type { Keybinds } from "$lib/core/keybinds/defaultBinds"; import type {Keybinds} from "$lib/core/keybinds/defaultBinds";
export type PageStyle = "single" | "double" | "longstrip"; export type PageStyle = "single" | "double" | "longstrip";
export type FitMode = "width" | "height" | "screen" | "original"; export type FitMode = "width" | "height" | "screen" | "original";
@@ -7,6 +7,7 @@ export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc"; export type ChapterSortDir = "desc" | "asc";
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
export type ContentLevel = "strict" | "moderate" | "unrestricted"; export type ContentLevel = "strict" | "moderate" | "unrestricted";
export type CloseAction = "ask" | "tray" | "quit";
export type LibrarySortMode = export type LibrarySortMode =
| "az" | "unreadCount" | "totalChapters" | "az" | "unreadCount" | "totalChapters"
@@ -98,6 +99,16 @@ export interface MangaPrefs {
coverUrl?: string; coverUrl?: string;
} }
export interface AutomationDefaults {
autoDownload: boolean;
downloadAhead: number;
deleteOnRead: boolean;
deleteDelayHours: number;
maxKeepChapters: number;
pauseUpdates: boolean;
refreshInterval: "daily" | "weekly" | "manual";
}
export const DEFAULT_MANGA_PREFS: MangaPrefs = { export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false, autoDownload: false,
downloadAhead: 0, downloadAhead: 0,
@@ -113,6 +124,16 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownloadScanlators: [], autoDownloadScanlators: [],
}; };
export const DEFAULT_AUTOMATION_DEFAULTS: AutomationDefaults = {
autoDownload: false,
downloadAhead: 0,
deleteOnRead: false,
deleteDelayHours: 0,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: "weekly",
};
export interface ReaderSettings { export interface ReaderSettings {
pageStyle: PageStyle; pageStyle: PageStyle;
fitMode: FitMode; fitMode: FitMode;
@@ -135,6 +156,8 @@ export interface Settings {
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
fitMode: FitMode; fitMode: FitMode;
readerZoom: number; readerZoom: number;
overlayBars: boolean;
tapToToggleBar: boolean;
pageGap: boolean; pageGap: boolean;
optimizeContrast: boolean; optimizeContrast: boolean;
offsetDoubleSpreads: boolean; offsetDoubleSpreads: boolean;
@@ -147,6 +170,8 @@ export interface Settings {
sourceOverridesEnabled: boolean; sourceOverridesEnabled: boolean;
nsfwAllowedSourceIds: string[]; nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[]; nsfwBlockedSourceIds: string[];
libraryShowAllInSaved: boolean;
libraryHideCompletedInSaved: boolean;
discordRpc: boolean; discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
@@ -154,6 +179,7 @@ export interface Settings {
uiZoom: number; uiZoom: number;
compactSidebar: boolean; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
closeAction: CloseAction;
serverUrl: string; serverUrl: string;
serverBinary: string; serverBinary: string;
serverBinaryArgs: string; serverBinaryArgs: string;
@@ -168,6 +194,9 @@ export interface Settings {
readerDebounceMs: number; readerDebounceMs: number;
autoBookmark: boolean; autoBookmark: boolean;
theme: Theme; theme: Theme;
systemThemeSync: boolean;
systemThemeDark: Theme;
systemThemeLight: Theme;
libraryBranches: boolean; libraryBranches: boolean;
renderLimit: number; renderLimit: number;
heroSlots: (number | null)[]; heroSlots: (number | null)[];
@@ -194,7 +223,7 @@ export interface Settings {
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
defaultLibraryCategoryId: number | null; defaultLibraryCategoryId: number | null;
savedIsDefaultCategory: boolean; savedIsDefaultCategory: boolean;
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>; libraryTabSort: Record<string, {mode: LibrarySortMode; dir: LibrarySortDir;}>;
libraryTabStatus: Record<string, LibraryStatusFilter>; libraryTabStatus: Record<string, LibraryStatusFilter>;
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>; libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
maxPageWidth?: number; maxPageWidth?: number;
@@ -220,6 +249,9 @@ export interface Settings {
autoScroll?: boolean; autoScroll?: boolean;
autoScrollSpeed?: number; autoScrollSpeed?: number;
disableAutoComplete: boolean; disableAutoComplete: boolean;
automationEnabled: boolean;
automationEnforceGlobal: boolean;
automationDefaults: AutomationDefaults;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
@@ -227,6 +259,8 @@ export const DEFAULT_SETTINGS: Settings = {
readingDirection: "ltr", readingDirection: "ltr",
fitMode: "width", fitMode: "width",
readerZoom: 1.0, readerZoom: 1.0,
overlayBars: false,
tapToToggleBar: false,
pageGap: true, pageGap: true,
optimizeContrast: false, optimizeContrast: false,
offsetDoubleSpreads: false, offsetDoubleSpreads: false,
@@ -239,6 +273,8 @@ export const DEFAULT_SETTINGS: Settings = {
sourceOverridesEnabled: false, sourceOverridesEnabled: false,
nsfwAllowedSourceIds: [], nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [], nsfwBlockedSourceIds: [],
libraryShowAllInSaved: true,
libraryHideCompletedInSaved: false,
discordRpc: false, discordRpc: false,
chapterSortDir: "desc", chapterSortDir: "desc",
chapterSortMode: "source", chapterSortMode: "source",
@@ -246,6 +282,7 @@ export const DEFAULT_SETTINGS: Settings = {
uiZoom: 1.0, uiZoom: 1.0,
compactSidebar: false, compactSidebar: false,
gpuAcceleration: true, gpuAcceleration: true,
closeAction: "ask",
serverUrl: "http://localhost:4567", serverUrl: "http://localhost:4567",
serverBinary: "", serverBinary: "",
serverBinaryArgs: "", serverBinaryArgs: "",
@@ -260,6 +297,9 @@ export const DEFAULT_SETTINGS: Settings = {
readerDebounceMs: 120, readerDebounceMs: 120,
autoBookmark: true, autoBookmark: true,
theme: "dark", theme: "dark",
systemThemeSync: false,
systemThemeDark: "dark",
systemThemeLight: "light",
libraryBranches: true, libraryBranches: true,
renderLimit: 48, renderLimit: 48,
heroSlots: [null, null, null, null], heroSlots: [null, null, null, null],
@@ -309,4 +349,7 @@ export const DEFAULT_SETTINGS: Settings = {
autoScroll: false, autoScroll: false,
autoScrollSpeed: 5, autoScrollSpeed: 5,
disableAutoComplete: false, disableAutoComplete: false,
automationEnabled: false,
automationEnforceGlobal: false,
automationDefaults: DEFAULT_AUTOMATION_DEFAULTS,
}; };
+333
View File
@@ -0,0 +1,333 @@
<script lang="ts">
interface MenuItem {
label: string
icon?: any
onClick: () => void
danger?: boolean
disabled?: boolean
separator?: never
children?: MenuEntry[]
}
interface MenuSeparator {
separator: true
}
type MenuEntry = MenuItem | MenuSeparator
interface Props {
x: number
y: number
items: MenuEntry[]
onClose: () => void
}
let { x, y, items, onClose }: Props = $props()
let focused = $state(-1)
let el = $state<HTMLDivElement | undefined>(undefined)
let measured = $state(false)
let pos = $state({ left: 0, top: 0 })
let subOpen = $state(-1)
let subEls = $state<(HTMLDivElement | null)[]>([])
const actionable = $derived(
items
.map((_, index) => index)
.filter((index) => !('separator' in items[index]) && !(items[index] as MenuItem).disabled)
)
$effect(() => {
if (actionable.length && focused === -1) focused = actionable[0]
})
function getZoom(): number {
const raw = parseFloat(document.documentElement.style.zoom || '1') || 1
return raw > 10 ? raw / 100 : raw
}
$effect(() => {
if (!el) return
const zoom = getZoom()
const style = getComputedStyle(document.documentElement)
const sidebarWidth = parseFloat(style.getPropertyValue('--sidebar-width')) || 52
const titlebarHeight = parseFloat(style.getPropertyValue('--titlebar-height')) || 36
const viewportWidth = window.innerWidth / zoom
const viewportHeight = window.innerHeight / zoom
const screenX = x / zoom - sidebarWidth / zoom
const screenY = y / zoom - titlebarHeight / zoom
const menuWidth = el.offsetWidth
const menuHeight = el.offsetHeight
pos = {
left: Math.max(4, screenX + menuWidth > viewportWidth ? screenX - menuWidth : screenX),
top: Math.max(4, screenY + menuHeight > viewportHeight ? screenY - menuHeight : screenY),
}
measured = true
})
$effect(() => {
if (subOpen < 0) return
const submenu = subEls[subOpen]
if (!submenu) return
requestAnimationFrame(() => {
const zoom = getZoom()
const viewportWidth = window.innerWidth / zoom
const rect = submenu.getBoundingClientRect()
if (rect.right / zoom > viewportWidth) submenu.classList.add('sub-flip')
else submenu.classList.remove('sub-flip')
})
})
function handlePointerOutside(target: EventTarget | null) {
const inMain = el?.contains(target as Node)
const inSubmenu = subOpen >= 0 && subEls[subOpen]?.contains(target as Node)
if (!inMain && !inSubmenu) onClose()
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation()
if (subOpen >= 0) subOpen = -1
else onClose()
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
const current = actionable.indexOf(focused)
focused = actionable[(current + 1) % actionable.length] ?? actionable[0]
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const current = actionable.indexOf(focused)
focused = actionable[(current - 1 + actionable.length) % actionable.length] ?? actionable[0]
return
}
if (event.key === 'ArrowRight' && focused >= 0) {
const item = items[focused] as MenuItem
if (item.children?.length) subOpen = focused
return
}
if (event.key === 'ArrowLeft') {
subOpen = -1
return
}
if (event.key === 'Enter' && focused >= 0) {
event.preventDefault()
const item = items[focused] as MenuItem
if (item.children?.length) {
subOpen = focused
return
}
if (!item.disabled) {
item.onClick()
onClose()
}
}
}
$effect(() => {
const onMouseDown = (event: MouseEvent) => handlePointerOutside(event.target)
const onTouchStart = (event: TouchEvent) => handlePointerOutside(event.target)
document.addEventListener('mousedown', onMouseDown, true)
document.addEventListener('touchstart', onTouchStart, true)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onMouseDown, true)
document.removeEventListener('touchstart', onTouchStart, true)
document.removeEventListener('keydown', onKey, true)
}
})
</script>
<div bind:this={el} class="menu" role="menu" tabindex="-1" style={`left:${pos.left}px;top:${pos.top}px;visibility:${measured ? 'visible' : 'hidden'}`} oncontextmenu={(event) => event.preventDefault()}>
{#each items as item, index (index)}
{#if 'separator' in item}
<div class="sep"></div>
{:else}
{@const menuItem = item as MenuItem}
{@const hasSubmenu = !!menuItem.children?.length}
<div class="item-wrap">
<button
class="item"
class:danger={menuItem.danger}
class:disabled={menuItem.disabled}
class:focused={focused === index}
class:has-sub={hasSubmenu}
disabled={menuItem.disabled}
onclick={() => {
if (menuItem.disabled) return
if (hasSubmenu) {
subOpen = subOpen === index ? -1 : index
return
}
menuItem.onClick()
onClose()
}}
onmouseenter={() => {
if (menuItem.disabled) return
focused = index
subOpen = hasSubmenu ? index : -1
}}
onmouseleave={() => {
focused = -1
}}
>
<span class="icon" class:icon-danger={menuItem.danger}>
{#if menuItem.icon}
<menuItem.icon size={13} weight="light" />
{/if}
</span>
<span class="label">{menuItem.label}</span>
{#if hasSubmenu}
<span class="sub-arrow"></span>
{/if}
</button>
{#if hasSubmenu && subOpen === index}
<div bind:this={subEls[index]} class="menu submenu" role="menu" tabindex="-1" onmouseenter={() => { subOpen = index }}>
{#each menuItem.children as child, childIndex (childIndex)}
{#if 'separator' in child}
<div class="sep"></div>
{:else}
{@const childItem = child as MenuItem}
<button class="item" class:danger={childItem.danger} class:disabled={childItem.disabled} disabled={childItem.disabled} onclick={() => {
if (childItem.disabled) return
childItem.onClick()
onClose()
}}>
<span class="icon" class:icon-danger={childItem.danger}>
{#if childItem.icon}
<childItem.icon size={13} weight="light" />
{/if}
</span>
<span class="label">{childItem.label}</span>
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
<style>
.menu {
position: fixed;
z-index: 200;
min-width: 190px;
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
background: var(--bg-raised);
padding: var(--sp-1);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.35), 0 16px 40px rgba(0, 0, 0, 0.25);
animation: scaleIn 0.1s ease both;
transform-origin: top left;
}
.item-wrap {
position: relative;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
z-index: 201;
animation: scaleIn 0.08s ease both;
transform-origin: top left;
}
:global(.submenu.sub-flip) {
left: auto;
right: 100%;
transform-origin: top right;
}
.item {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 5px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: left;
background: none;
border: none;
outline: none;
transition: background var(--t-fast), color var(--t-fast);
}
.item:hover:not(.disabled),
.item.focused:not(.disabled) {
background: var(--bg-overlay);
color: var(--text-primary);
}
.item.danger {
color: var(--color-error);
}
.item.danger:hover:not(.disabled),
.item.danger.focused:not(.disabled) {
background: var(--color-error-bg);
}
.item.disabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-faint);
border-radius: var(--radius-sm);
}
.icon-danger {
color: var(--color-error);
opacity: 0.7;
}
.label {
flex: 1;
line-height: 1.3;
}
.sub-arrow {
font-size: 14px;
color: var(--text-faint);
line-height: 1;
margin-left: auto;
padding-left: var(--sp-1);
}
.sep {
height: 1px;
background: var(--border-dim);
margin: 3px var(--sp-1);
}
</style>
+28 -12
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { import {
House, Books, MagnifyingGlass, ClockCounterClockwise, House, Books, MagnifyingGlass,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp, ClockCounterClockwise,
} from 'phosphor-svelte' } from 'phosphor-svelte'
import logoUrl from '$lib/assets/moku-icon-wordmark.svg' import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
@@ -14,6 +13,7 @@
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple }, { path: '/downloads', label: 'Downloads', icon: DownloadSimple },
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece }, { path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp }, { path: '/tracking', label: 'Tracking', icon: ChartLineUp },
{ path: '/history', label: 'History', icon: ClockCounterClockwise },
] as const ] as const
const TAB_SIZE = 36 const TAB_SIZE = 36
@@ -27,33 +27,45 @@
) )
const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP)) const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP))
function isActive(path: string) {
if (path === '/') return $page.url.pathname === '/'
return $page.url.pathname === path || $page.url.pathname.startsWith(`${path}/`)
}
</script> </script>
<aside class="root"> <aside class="root">
<button class="logo" onclick={() => goto('/')} title="Home" aria-label="Go to Home"> <a class="logo" href="/" title="Home" aria-label="Go to Home">
<div class="logo-icon" style="mask-image: url({logoUrl}); -webkit-mask-image: url({logoUrl})"></div> <div class="logo-icon" style="mask-image: url({logoUrl}); -webkit-mask-image: url({logoUrl})"></div>
</button> </a>
<nav class="nav"> <nav class="nav">
{#if activeIndex >= 0} {#if activeIndex >= 0}
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div> <div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
{/if} {/if}
{#each TABS as tab} {#each TABS as tab (tab.path)}
<button <a
class="tab" class="tab"
class:active={activeIndex === TABS.indexOf(tab)} class:active={isActive(tab.path)}
title={tab.label} title={tab.label}
onclick={() => goto(tab.path)} href={tab.path}
aria-current={isActive(tab.path) ? 'page' : undefined}
> >
<tab.icon size={18} weight="light" /> <tab.icon size={18} weight="light" />
</button> </a>
{/each} {/each}
</nav> </nav>
<div class="bottom"> <div class="bottom">
<button class="settings-btn" onclick={() => goto('/settings')} title="Settings"> <a
class="settings-btn"
class:active={isActive('/settings')}
href="/settings"
title="Settings"
aria-current={isActive('/settings') ? 'page' : undefined}
>
<GearSix size={18} weight="light" /> <GearSix size={18} weight="light" />
</button> </a>
</div> </div>
</aside> </aside>
@@ -80,6 +92,7 @@
margin-bottom: var(--sp-4); margin-bottom: var(--sp-4);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
transition: opacity var(--t-base), transform var(--t-base); transition: opacity var(--t-base), transform var(--t-base);
text-decoration: none;
} }
.logo:hover { opacity: 0.8; transform: scale(0.96); } .logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); } .logo:active { transform: scale(0.92); }
@@ -140,6 +153,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--text-faint); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base); transition: color var(--t-base), background var(--t-base);
text-decoration: none;
} }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } .tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:active { transform: scale(0.88); } .tab:active { transform: scale(0.88); }
@@ -167,7 +181,9 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--text-faint); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base), transform var(--t-slow); transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
text-decoration: none;
} }
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } .settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.settings-btn.active { color: var(--accent-fg); background: var(--accent-muted); transform: none; }
</style> </style>
+22 -4
View File
@@ -7,19 +7,37 @@
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
let os: OsKind = $state('unknown') let os = $state<OsKind>('unknown')
let isFullscreen = $state(false) let isFullscreen = $state(false)
onMount(async () => { onMount(() => {
if (!isTauri) return if (!isTauri) return
let disposed = false
let unlisten: (() => void) | null = null
void (async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window') const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow() const win = getCurrentWindow()
os = await detectOs() os = await detectOs()
isFullscreen = await win.isFullscreen() isFullscreen = await win.isFullscreen()
const unlisten = await win.onResized(async () => {
const stop = await win.onResized(async () => {
isFullscreen = await win.isFullscreen() isFullscreen = await win.isFullscreen()
}) })
return unlisten
if (disposed) {
stop()
return
}
unlisten = stop
})()
return () => {
disposed = true
unlisten?.()
}
}) })
const isMac = $derived(os === 'macos') const isMac = $derived(os === 'macos')
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { BookmarkSimple, BookOpen, DownloadSimple } from 'phosphor-svelte'
import type { Manga } from '$lib/types/manga'
import Thumbnail from '$lib/ui/manga/Thumbnail.svelte'
interface Props {
manga: Manga
href?: string
compact?: boolean
showMeta?: boolean
}
let {
manga,
href,
compact = false,
showMeta = true,
}: Props = $props()
const unreadCount = $derived(manga.unreadCount ?? 0)
const downloadCount = $derived(manga.downloadCount ?? 0)
const bookmarkCount = $derived(manga.bookmarkCount ?? 0)
</script>
{#if href}
<a class:compact class="card" {href} aria-label={manga.title}>
<Thumbnail class="manga-card-cover" src={manga.thumbnailUrl} alt={manga.title} />
<div class="body">
<p class="title">{manga.title}</p>
{#if showMeta}
<div class="meta">
<span><BookOpen size={12} weight="light" /> {unreadCount}</span>
<span><DownloadSimple size={12} weight="light" /> {downloadCount}</span>
<span><BookmarkSimple size={12} weight="light" /> {bookmarkCount}</span>
</div>
{/if}
{#if manga.source?.displayName}
<p class="source">{manga.source.displayName}</p>
{/if}
</div>
</a>
{:else}
<div class:compact class="card">
<Thumbnail class="manga-card-cover" src={manga.thumbnailUrl} alt={manga.title} />
<div class="body">
<p class="title">{manga.title}</p>
{#if showMeta}
<div class="meta">
<span><BookOpen size={12} weight="light" /> {unreadCount}</span>
<span><DownloadSimple size={12} weight="light" /> {downloadCount}</span>
<span><BookmarkSimple size={12} weight="light" /> {bookmarkCount}</span>
</div>
{/if}
{#if manga.source?.displayName}
<p class="source">{manga.source.displayName}</p>
{/if}
</div>
</div>
{/if}
<style>
.card {
display: flex;
flex-direction: column;
gap: var(--sp-3);
min-width: 0;
padding: var(--sp-3);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-overlay) 75%, transparent), transparent),
var(--bg-raised);
transition: border-color var(--t-base), transform var(--t-base), background var(--t-base);
}
.card:hover {
border-color: var(--border-strong);
transform: translateY(-2px);
}
.card.compact {
gap: var(--sp-2);
padding: var(--sp-2);
}
:global(.manga-card-cover) {
aspect-ratio: 3 / 4;
width: 100%;
border-radius: var(--radius-lg);
object-fit: cover;
background: var(--bg-overlay);
}
.body {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.title {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
line-height: var(--leading-snug);
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-2xs);
}
.meta span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.source {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
children?: Snippet
class?: string
}
let { children, class: className = '' }: Props = $props()
</script>
<div class={`hover-3d ${className}`.trim()}>
<div class="hover-3d-content">
{@render children?.()}
</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: 75rem;
--transform: 0, 0;
--shine: 100% 100%;
--shadow: 0rem 0rem 0rem;
--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);
filter:
drop-shadow(var(--shadow) 0.1rem #00000020)
drop-shadow(var(--shadow) 0.2rem #00000015)
drop-shadow(var(--shadow) 0.3rem #00000010);
transition: filter ease-out 400ms;
}
.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(--transform), 0, 10deg);
transition: transform var(--ease-out) 500ms, scale var(--ease-out) 500ms, outline-color ease-out 500ms;
outline: 0.5px solid transparent;
outline-offset: -1px;
}
.hover-3d-content::before {
content: '';
pointer-events: none;
position: absolute;
inset: 0;
z-index: 1;
opacity: 0;
filter: blur(0.75rem);
background-image: radial-gradient(circle at 50%, rgba(255, 255, 255, 0.18) 10%, transparent 50%);
translate: var(--shine);
transition: translate ease-out 400ms, opacity ease-out 400ms;
}
.hover-3d:hover {
--ease-out: var(--ease-hover);
}
.hover-3d:hover > .hover-3d-content {
scale: 1.05;
outline-color: rgba(255, 255, 255, 0.07);
}
.hover-3d:hover > .hover-3d-content::before {
opacity: 1;
}
.hover-3d:has(> :nth-child(2):hover) { --transform: -1, 1; --shine: 0% 0%; --shadow: -0.5rem -0.5rem; }
.hover-3d:has(> :nth-child(3):hover) { --transform: -1, 0; --shine: 100% 0%; --shadow: 0rem -0.5rem; }
.hover-3d:has(> :nth-child(4):hover) { --transform: -1, -1; --shine: 200% 0%; --shadow: 0.5rem -0.5rem; }
.hover-3d:has(> :nth-child(5):hover) { --transform: 0, 1; --shine: 0% 100%; --shadow: -0.5rem 0rem; }
.hover-3d:has(> :nth-child(6):hover) { --transform: 0, -1; --shine: 200% 100%; --shadow: 0.5rem 0rem; }
.hover-3d:has(> :nth-child(7):hover) { --transform: 1, 1; --shine: 0% 200%; --shadow: -0.5rem 0.5rem; }
.hover-3d:has(> :nth-child(8):hover) { --transform: 1, 0; --shine: 100% 200%; --shadow: 0rem 0.5rem; }
.hover-3d:has(> :nth-child(9):hover) { --transform: 1, -1; --shine: 200% 200%; --shadow: 0.5rem 0.5rem; }
</style>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import { getAuthMode } from '$lib/core/auth'
import { loadImageObjectUrl, resolveImageUrl } from '$lib/core/image'
interface Props {
src: string | null | undefined
alt?: string
class?: string
loading?: 'lazy' | 'eager'
decoding?: 'sync' | 'async' | 'auto'
draggable?: boolean
}
let {
src,
alt = '',
class: className = '',
loading = 'lazy',
decoding = 'async',
draggable = false,
}: Props = $props()
let objectUrl = $state<string | null>(null)
let failed = $state(false)
const resolvedSrc = $derived(objectUrl ?? resolveImageUrl(src) ?? '')
$effect(() => {
const source = src
failed = false
if (!source || getAuthMode() === 'NONE') {
if (objectUrl?.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl)
}
objectUrl = null
return
}
let active = true
const controller = new AbortController()
const previousUrl = objectUrl
void loadImageObjectUrl(source, controller.signal)
.then((nextUrl) => {
if (!active) {
if (nextUrl.startsWith('blob:')) URL.revokeObjectURL(nextUrl)
return
}
if (previousUrl?.startsWith('blob:') && previousUrl !== nextUrl) {
URL.revokeObjectURL(previousUrl)
}
objectUrl = nextUrl
})
.catch(() => {
if (!active) return
objectUrl = null
failed = true
})
return () => {
active = false
controller.abort()
if (objectUrl?.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl)
}
}
})
</script>
{#if resolvedSrc && !failed}
<img src={resolvedSrc} {alt} class={className} {loading} {decoding} {draggable} onerror={() => { failed = true }} />
{:else}
<div class={`placeholder ${className}`.trim()} aria-label={alt || 'Thumbnail unavailable'} role="img">
<span>no cover</span>
</div>
{/if}
<style>
.placeholder {
display: flex;
align-items: center;
justify-content: center;
background:
linear-gradient(160deg, color-mix(in srgb, var(--accent-muted) 60%, transparent), transparent 55%),
linear-gradient(180deg, var(--bg-raised), var(--bg-overlay));
color: var(--text-faint);
}
.placeholder span {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import type { HTMLButtonAttributes } from 'svelte/elements'
interface Props extends Omit<HTMLButtonAttributes, 'children'> {
children?: Snippet
variant?: 'solid' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
block?: boolean
}
let {
children,
class: className = '',
variant = 'solid',
size = 'md',
block = false,
type = 'button',
...rest
}: Props = $props()
</script>
<button class={`button ${variant} ${size} ${block ? 'block' : ''} ${className}`.trim()} {type} {...rest}>
{@render children?.()}
</button>
<style>
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
border-radius: var(--radius-md);
border: 1px solid transparent;
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
transition: background var(--t-base), border-color var(--t-base), color var(--t-base), opacity var(--t-base), transform var(--t-fast);
}
.button.sm { min-height: 30px; padding: 0 var(--sp-3); font-size: var(--text-2xs); }
.button.md { min-height: 36px; padding: 0 var(--sp-4); font-size: var(--text-xs); }
.button.lg { min-height: 42px; padding: 0 var(--sp-5); font-size: var(--text-sm); }
.button.solid {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.button.ghost {
background: var(--bg-raised);
border-color: var(--border-dim);
color: var(--text-secondary);
}
.button.danger {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-color: color-mix(in srgb, var(--color-error) 30%, transparent);
color: var(--color-error);
}
.button.block {
display: flex;
width: 100%;
}
.button:hover:not(:disabled) {
opacity: 0.92;
}
.button:active:not(:disabled) {
transform: scale(0.98);
}
.button:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.button:disabled {
opacity: 0.45;
cursor: default;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
trigger?: Snippet
children?: Snippet
align?: 'left' | 'right'
width?: string
}
let {
trigger,
children,
align = 'left',
width = '220px',
}: Props = $props()
let root = $state<HTMLElement | null>(null)
let open = $state(false)
function toggle() {
open = !open
}
function close() {
open = false
}
</script>
<svelte:document
onclick={(event) => {
if (!open || !(event.target instanceof Node) || root?.contains(event.target)) return
close()
}}
onkeydown={(event) => event.key === 'Escape' && close()}
/>
<div bind:this={root} class="dropdown">
<button class="trigger" type="button" onclick={toggle} aria-expanded={open}>
{@render trigger?.()}
</button>
{#if open}
<div class={`panel ${align}`.trim()} role="menu" style={`width: ${width}`}>
{@render children?.()}
</div>
{/if}
</div>
<style>
.dropdown {
position: relative;
display: inline-flex;
}
.trigger {
display: inline-flex;
}
.panel {
position: absolute;
top: calc(100% + var(--sp-2));
z-index: var(--z-modal);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
background: var(--bg-raised);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45);
padding: var(--sp-2);
}
.panel.left {
left: 0;
}
.panel.right {
right: 0;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements'
interface Props extends Omit<HTMLInputAttributes, 'value'> {
value?: string
label?: string
error?: string | null
inputClass?: string
}
let {
value = $bindable(''),
label = '',
error = null,
class: className = '',
inputClass = '',
...rest
}: Props = $props()
</script>
<label class={`field ${className}`.trim()}>
{#if label}
<span class="label">{label}</span>
{/if}
<input class={`control ${inputClass}`.trim()} bind:value {...rest} />
{#if error}
<span class="error">{error}</span>
{/if}
</label>
<style>
.field {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--text-faint);
}
.control {
width: 100%;
min-height: 38px;
border-radius: var(--radius-md);
border: 1px solid var(--border-base);
background: var(--bg-raised);
color: var(--text-primary);
padding: 0 var(--sp-3);
transition: border-color var(--t-base), box-shadow var(--t-base), background var(--t-base);
}
.control:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent);
}
.control:disabled {
opacity: 0.5;
cursor: default;
}
.error {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--color-error);
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
open: boolean
title?: string
description?: string
children?: Snippet
actions?: Snippet
onClose?: () => void
closeOnBackdrop?: boolean
width?: string
}
let {
open,
title = '',
description = '',
children,
actions,
onClose,
closeOnBackdrop = true,
width = 'min(520px, calc(100vw - 32px))',
}: Props = $props()
function close() {
onClose?.()
}
</script>
{#if open}
<div
class="backdrop"
role="presentation"
tabindex="-1"
onclick={() => closeOnBackdrop && close()}
onkeydown={(event) => event.key === 'Escape' && closeOnBackdrop && close()}
>
<div
class="panel anim-scale-in"
role="dialog"
aria-modal="true"
tabindex="-1"
style={`width: ${width}`}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
>
{#if title || description}
<header class="header">
{#if title}
<h2>{title}</h2>
{/if}
{#if description}
<p>{description}</p>
{/if}
</header>
{/if}
<section class="content">
{@render children?.()}
</section>
{#if actions}
<footer class="actions">
{@render actions()}
</footer>
{/if}
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-4);
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(10px);
}
.panel {
display: flex;
flex-direction: column;
gap: var(--sp-4);
max-height: calc(100vh - 32px);
overflow: hidden;
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
background: var(--bg-surface);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
}
.header,
.content,
.actions {
padding-inline: var(--sp-4);
}
.header {
padding-top: var(--sp-4);
}
.header h2 {
font-size: var(--text-lg);
font-weight: var(--weight-semi);
line-height: var(--leading-tight);
}
.header p {
margin-top: var(--sp-2);
color: var(--text-muted);
font-size: var(--text-sm);
}
.content {
overflow: auto;
padding-bottom: var(--sp-2);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
padding-bottom: var(--sp-4);
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
interface Option {
label: string
value: string
disabled?: boolean
}
interface Props {
value?: string
options: Option[]
label?: string
disabled?: boolean
class?: string
}
let {
value = $bindable(''),
options,
label = '',
disabled = false,
class: className = '',
}: Props = $props()
</script>
<label class={`field ${className}`.trim()}>
{#if label}
<span class="label">{label}</span>
{/if}
<div class="frame">
<select bind:value {disabled}>
{#each options as option (option.value)}
<option value={option.value} disabled={option.disabled}>{option.label}</option>
{/each}
</select>
</div>
</label>
<style>
.field {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--text-faint);
}
.frame {
position: relative;
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
background: var(--bg-raised);
}
.frame::after {
content: '▾';
position: absolute;
right: var(--sp-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-faint);
pointer-events: none;
}
select {
width: 100%;
min-height: 38px;
padding: 0 calc(var(--sp-5) + var(--sp-2)) 0 var(--sp-3);
border: 0;
outline: 0;
background: transparent;
appearance: none;
}
select:disabled {
opacity: 0.5;
}
</style>
+282 -12
View File
@@ -1,6 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import { page } from '$app/stores'
import { applyTheme, mountSystemThemeSync, unmountSystemThemeSync } from '$lib/core/theme'
import { initKeybindEngine, matchesKeybind, toggleFullscreen } from '$lib/core/keybinds/keybindEngine'
import { mountIdleDetection } from '$lib/core/ui/idle'
import { applyZoom, mountZoomKey } from '$lib/core/ui/zoom'
import { appState } from '$lib/state/app.svelte' import { appState } from '$lib/state/app.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { notificationsState } from '$lib/state/notifications.svelte' import { notificationsState } from '$lib/state/notifications.svelte'
import { readerState } from '$lib/state/reader.svelte'
import { clearDiscordPresence, isSupported, setDiscordPresence } from '$lib/platform-service'
import { loadDownloads } from '$lib/request-manager/downloads'
import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte' import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte'
import AuthGate from '$lib/ui/chrome/AuthGate.svelte' import AuthGate from '$lib/ui/chrome/AuthGate.svelte'
import Sidebar from '$lib/ui/chrome/Sidebar.svelte' import Sidebar from '$lib/ui/chrome/Sidebar.svelte'
@@ -10,17 +21,78 @@
let { children } = $props() let { children } = $props()
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const ringFull = $derived(appState.status !== 'booting')
let splashVisible = $state(true) let splashVisible = $state(true)
let bypassed = $state(false) let bypassed = $state(false)
let closeDialogOpen = $state(false)
const showApp = $derived( const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
appState.status === 'ready' || const pathname = $derived($page.url.pathname as string)
appState.status === 'auth' || const hideShellChrome = $derived(pathname === '/reader' || pathname.startsWith('/reader/'))
bypassed const ringFull = $derived(appState.status !== 'booting')
) const showSplash = $derived((appState.status === 'booting' || appState.status === 'error') && !bypassed)
const showAuthGate = $derived(appState.status === 'auth')
const showShell = $derived(appState.status === 'ready' || bypassed)
const splashCards = $derived(settingsState.splashCards ?? true)
async function handleClose() {
if (!isTauri) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
const action = settingsState.closeAction
if (action === 'tray') {
await win.hide()
} else if (action === 'ask') {
closeDialogOpen = true
} else {
await win.close()
}
}
async function confirmQuit() {
closeDialogOpen = false
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().close()
}
async function confirmTray() {
closeDialogOpen = false
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().hide()
}
function canUseDiscordRpc(): boolean {
try {
return isSupported('discord-rpc')
} catch {
return false
}
}
function hasEditableTarget(target: EventTarget | null): boolean {
const element = target as HTMLElement | null
if (!element) return false
const tag = element.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
return element.isContentEditable
}
function handleGlobalKeydown(event: KeyboardEvent) {
if (!showShell || hasEditableTarget(event.target)) return
if (matchesKeybind(event, settingsState.keybinds.openSettings)) {
event.preventDefault()
if (!pathname.startsWith('/settings')) {
void goto('/settings/general')
}
return
}
if (matchesKeybind(event, settingsState.keybinds.toggleFullscreen)) {
event.preventDefault()
void toggleFullscreen()
}
}
function onSplashReady() { function onSplashReady() {
splashVisible = false splashVisible = false
@@ -30,24 +102,157 @@
bypassed = true bypassed = true
splashVisible = false splashVisible = false
} }
onMount(() => {
// Keep shell startup deterministic: keybinds -> visuals -> idle -> platform listeners -> feature loops.
const stopKeybindEngine = initKeybindEngine()
applyTheme(settingsState.theme, settingsState.customThemes)
applyZoom(settingsState.uiZoom)
mountSystemThemeSync()
const stopZoomKey = mountZoomKey(
() => settingsState.uiZoom,
(nextZoom) => updateSettings({ uiZoom: nextZoom })
)
const stopIdleDetection = mountIdleDetection(
() => settingsState.idleTimeoutMin,
() => {
appState.idle = true
},
() => {
appState.idle = false
}
)
const handleResize = () => {
applyZoom(settingsState.uiZoom)
}
window.addEventListener('resize', handleResize, { passive: true })
let stopTauriScale: (() => void) | null = null
if (isTauri) {
void import('@tauri-apps/api/window').then(async ({ getCurrentWindow }) => {
stopTauriScale = await getCurrentWindow().onScaleChanged(() => {
applyZoom(settingsState.uiZoom)
})
})
}
let lastPresenceKey = ''
const stopDiscordWatch = $effect.root(() => {
$effect(() => {
const enabled = settingsState.discordRpc && appState.status === 'ready' && !appState.idle && canUseDiscordRpc()
if (!enabled) {
if (lastPresenceKey) {
lastPresenceKey = ''
void clearDiscordPresence().catch(() => {})
}
return
}
const isReaderRoute = pathname === '/reader' || pathname.startsWith('/reader/')
const title = isReaderRoute ? (readerState.manga?.title ?? 'Moku') : 'Moku'
const chapter = isReaderRoute && readerState.chapter
? `Chapter ${readerState.chapter.chapterNumber}`
: 'Browsing library'
const nextKey = `${title}|${chapter}`
if (nextKey === lastPresenceKey) return
lastPresenceKey = nextKey
void setDiscordPresence({
title,
chapter,
startTimestamp: Date.now(),
}).catch(() => {})
})
return () => {
if (lastPresenceKey) {
lastPresenceKey = ''
void clearDiscordPresence().catch(() => {})
}
}
})
const DOWNLOAD_POLL_MS = 8_000
let downloadPollId: ReturnType<typeof setInterval> | null = null
function startDownloadPolling() {
if (downloadPollId !== null) return
void loadDownloads()
downloadPollId = setInterval(() => {
void loadDownloads()
}, DOWNLOAD_POLL_MS)
}
function stopDownloadPolling() {
if (downloadPollId !== null) {
clearInterval(downloadPollId)
downloadPollId = null
}
}
if (appState.status === 'ready') {
startDownloadPolling()
}
const stopStatusWatch = $effect.root(() => {
$effect(() => {
if (appState.status === 'ready') {
startDownloadPolling()
} else {
stopDownloadPolling()
}
})
return () => {}
})
return () => {
appState.idle = false
stopZoomKey()
stopIdleDetection()
stopKeybindEngine()
stopDownloadPolling()
stopDiscordWatch()
stopStatusWatch()
window.removeEventListener('resize', handleResize)
unmountSystemThemeSync()
stopTauriScale?.()
}
})
</script> </script>
{#if splashVisible} <svelte:window onkeydown={handleGlobalKeydown} />
{#if showSplash && splashVisible}
<SplashScreen <SplashScreen
mode="loading" mode="loading"
{ringFull} {ringFull}
failed={appState.status === 'error'} failed={appState.status === 'error'}
showCards={splashCards}
onReady={onSplashReady} onReady={onSplashReady}
onBypass={onSplashBypass} onBypass={onSplashBypass}
onRetry={() => window.location.reload()} onRetry={() => window.location.reload()}
/> />
{/if} {/if}
{#if showApp} {#if showShell}
{#if hideShellChrome}
<main class="reader-main">
{@render children()}
</main>
{:else}
<div class="frame"> <div class="frame">
<div class="shell"> <div class="shell">
{#if isTauri} {#if isTauri}
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} /> <TitleBar onClose={handleClose} />
{/if} {/if}
<div class="body"> <div class="body">
<Sidebar /> <Sidebar />
@@ -57,11 +262,28 @@
</div> </div>
</div> </div>
</div> </div>
{/if}
{/if} {/if}
<AuthGate /> {#if showAuthGate}
<AuthGate />
{/if}
<Toaster toasts={notificationsState.toasts} /> <Toaster toasts={notificationsState.toasts} />
{#if closeDialogOpen}
<div class="close-dialog-backdrop" role="presentation" onclick={() => (closeDialogOpen = false)}>
<div class="close-dialog" role="dialog" aria-modal="true" aria-labelledby="close-dialog-title">
<p id="close-dialog-title" class="close-dialog-title">Close Moku?</p>
<p class="close-dialog-desc">Choose what to do when closing the window.</p>
<div class="close-dialog-actions">
<button type="button" class="settings-button" onclick={confirmTray}>Minimize to tray</button>
<button type="button" class="settings-button danger" onclick={confirmQuit}>Quit</button>
<button type="button" class="settings-button" onclick={() => (closeDialogOpen = false)}>Cancel</button>
</div>
</div>
</div>
{/if}
<style> <style>
.frame { .frame {
display: flex; display: flex;
@@ -99,4 +321,52 @@
contain: layout style; contain: layout style;
min-width: 0; min-width: 0;
} }
.reader-main {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-base);
}
.close-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 9000;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.close-dialog {
background: var(--bg-overlay);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
padding: var(--sp-5) var(--sp-6);
min-width: 280px;
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.close-dialog-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.close-dialog-desc {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
}
.close-dialog-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-1);
}
</style> </style>
+1 -1
View File
@@ -1,2 +1,2 @@
export const ssr = false export const ssr = false
export const prerender = true // export const prerender = true
+299
View File
@@ -1,3 +1,302 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'
import { BookOpen, Books, ClockCounterClockwise, DownloadSimple } from 'phosphor-svelte'
import { loadLibrary } from '$lib/request-manager/manga'
import { downloadCount as getDownloadCount } from '$lib/state/downloads.svelte'
import { historyState, initHistoryState } from '$lib/state/history.svelte'
import { libraryState } from '$lib/state/library.svelte'
const recentHistory = $derived(historyState.history.slice(0, 8))
const downloadCount = $derived(getDownloadCount())
const stats = $derived.by(() => [
{
label: 'Library Manga',
value: libraryState.items.length,
icon: Books,
},
{
label: 'Chapters Read',
value: historyState.readingStats.totalChaptersRead,
icon: BookOpen,
},
{
label: 'Active Downloads',
value: downloadCount,
icon: DownloadSimple,
},
{
label: 'Current Streak',
value: historyState.readingStats.currentStreakDays,
icon: ClockCounterClockwise,
suffix: 'days',
},
])
onMount(async () => {
await initHistoryState()
if (libraryState.items.length === 0) {
await loadLibrary({ inLibrary: true })
}
})
function formatTimestamp(value: number): string {
if (!value) return 'Unknown'
return new Date(value).toLocaleString()
}
</script> </script>
<section class="home-page">
<header class="hero">
<div>
<p class="eyebrow">Dashboard</p>
<h1>Welcome back</h1>
<p class="subtitle">Quick read stats and recent progress across your library.</p>
</div>
<div class="shortcuts">
<a href="/library">Open Library</a>
<a href="/browse">Browse Sources</a>
<a href="/history">View History</a>
</div>
</header>
<section class="stats-grid" aria-label="Reading stats">
{#each stats as stat (stat.label)}
<article class="stat-card">
<div class="stat-icon"><stat.icon size={16} weight="bold" /></div>
<p class="stat-label">{stat.label}</p>
<p class="stat-value">
{stat.value}
{#if stat.suffix}
<span>{stat.suffix}</span>
{/if}
</p>
</article>
{/each}
</section>
<section class="recent-panel">
<div class="section-head">
<h2>Recent Activity</h2>
<a href="/history">Open full history</a>
</div>
{#if recentHistory.length === 0}
<div class="empty-state">No recent reading activity yet.</div>
{:else}
<ul class="recent-list">
{#each recentHistory as entry (`${entry.chapterId}-${entry.readAt}`)}
<li>
<a class="recent-row" href={`/series/${entry.mangaId}`}>
<div class="row-main">
<p class="title">{entry.mangaTitle}</p>
<p class="meta">{entry.chapterName}</p>
</div>
<span class="time">{formatTimestamp(entry.readAt)}</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
</section>
<style>
.home-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.hero {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-4);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background:
linear-gradient(135deg, color-mix(in srgb, var(--accent) 18%, transparent), transparent 58%),
var(--bg-raised);
padding: var(--sp-5);
}
.eyebrow {
margin: 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero h1 {
margin: var(--sp-1) 0 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: clamp(var(--text-2xl), 2.2vw, var(--text-3xl));
}
.subtitle {
margin: var(--sp-2) 0 0;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
max-width: 60ch;
}
.shortcuts {
display: flex;
flex-wrap: wrap;
align-items: start;
gap: var(--sp-2);
}
.shortcuts a,
.section-head a {
display: inline-flex;
align-items: center;
height: 32px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
padding: 0 10px;
text-decoration: none;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.stats-grid {
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.stat-card {
display: flex;
flex-direction: column;
gap: var(--sp-2);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
background: var(--bg-raised);
padding: var(--sp-3);
}
.stat-icon {
color: var(--accent-fg);
}
.stat-label {
margin: 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.stat-value {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
line-height: 1;
}
.stat-value span {
margin-left: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.recent-panel {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-height: 0;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
padding: var(--sp-4);
}
.section-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-2);
align-items: center;
}
.section-head h2 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-xl);
}
.recent-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.recent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
text-decoration: none;
}
.row-main {
min-width: 0;
}
.title,
.meta,
.time {
margin: 0;
font-family: var(--font-ui);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
color: var(--text-primary);
font-size: var(--text-sm);
}
.meta,
.time {
color: var(--text-faint);
font-size: var(--text-xs);
}
.empty-state {
display: grid;
place-items: center;
min-height: 120px;
border: 1px dashed var(--border-dim);
border-radius: var(--radius-lg);
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
</style>
+270 -1
View File
@@ -1 +1,270 @@
<p>browse</p> <script lang="ts">
import { onMount } from 'svelte'
import { MagnifyingGlass, ArrowSquareOut } from 'phosphor-svelte'
import { loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { shouldHideSource } from '$lib/core/util'
let query = $state('')
let language = $state('all')
let includeNsfw = $state(false)
const languages = $derived.by(() => {
const values: string[] = []
for (const source of extensionsState.sources) {
if (!values.includes(source.lang)) values.push(source.lang)
}
return values.sort((a, b) => a.localeCompare(b))
})
const filteredSources = $derived.by(() => {
const q = query.trim().toLowerCase()
return extensionsState.sources.filter(source => {
if (language !== 'all' && source.lang !== language) return false
if (!includeNsfw && shouldHideSource(source, settingsState)) return false
if (!q) return true
return (
source.displayName.toLowerCase().includes(q) ||
source.name.toLowerCase().includes(q)
)
})
})
onMount(async () => {
if (extensionsState.sources.length === 0) {
await loadSources()
}
})
</script>
<section class="browse-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Browse Sources</h1>
<p>{filteredSources.length} available</p>
</div>
<div class="controls">
<label class="search">
<span><MagnifyingGlass size={14} weight="light" /> Search</span>
<input type="search" placeholder="Find source" bind:value={query} />
</label>
<label class="select-control">
<span>Language</span>
<select bind:value={language}>
<option value="all">All</option>
{#each languages as lang (lang)}
<option value={lang}>{lang.toUpperCase()}</option>
{/each}
</select>
</label>
<label class="checkbox-control">
<input type="checkbox" bind:checked={includeNsfw} />
<span>Include NSFW</span>
</label>
</div>
</header>
{#if extensionsState.error}
<div class="empty-state error-state">
<p>Unable to load sources.</p>
<small>{extensionsState.error}</small>
<button type="button" onclick={() => loadSources()}>Retry</button>
</div>
{:else if filteredSources.length === 0}
<div class="empty-state">No sources match the current filters.</div>
{:else}
<ul class="sources-grid">
{#each filteredSources as source (source.id)}
<li>
<a class="source-card" href={`/browse/${source.id}`}>
<div>
<p class="source-name">{source.displayName}</p>
<p class="source-meta">{source.lang.toUpperCase()} · {source.isNsfw ? 'NSFW' : 'Safe'}</p>
</div>
<ArrowSquareOut size={14} weight="bold" />
</a>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.browse-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-4);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search,
.select-control {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.search span,
.select-control span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
input,
select {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-primary);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.checkbox-control input {
width: 16px;
height: 16px;
margin: 0;
}
.sources-grid {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.source-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-2);
min-height: 72px;
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
text-decoration: none;
transition: border-color var(--t-base), transform var(--t-base), color var(--t-base);
}
.source-card:hover {
border-color: var(--border-strong);
color: var(--text-primary);
transform: translateY(-1px);
}
.source-name {
margin: 0;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.source-meta {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
.error-state button {
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
border-radius: var(--radius-md);
height: 30px;
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
+248
View File
@@ -0,0 +1,248 @@
<script lang="ts">
import { onMount } from 'svelte'
import { afterNavigate } from '$app/navigation'
import { ArrowLeft, ArrowsClockwise, CaretLeft, CaretRight, CircleNotch } from 'phosphor-svelte'
import { browseSource, loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte'
import MangaCard from '$lib/ui/manga/MangaCard.svelte'
import type { PageProps } from './$types'
let { params }: PageProps = $props()
let page = $state(1)
let sourceId = $state('')
const currentSource = $derived(
extensionsState.sources.find(source => source.id === sourceId)
)
async function loadSourcePage(nextPage = 1) {
page = nextPage
await browseSource(sourceId, nextPage)
}
async function refresh() {
await loadSourcePage(page)
}
onMount(async () => {
sourceId = params.sourceId
if (extensionsState.sources.length === 0) {
await loadSources()
}
await loadSourcePage(1)
const unsubscribe = afterNavigate(() => {
if (params.sourceId === sourceId) return
sourceId = params.sourceId
void loadSourcePage(1)
})
return unsubscribe
})
</script>
<section class="browse-source-page">
<header class="toolbar">
<div class="title-wrap">
<a class="back-link" href="/browse">
<ArrowLeft size={14} weight="bold" />
All sources
</a>
<h1>{currentSource?.displayName ?? 'Source'}</h1>
<p>
{currentSource?.lang?.toUpperCase() ?? 'N/A'}
{#if currentSource?.isNsfw}
· NSFW
{/if}
</p>
</div>
<div class="controls">
<button type="button" class="icon-btn" onclick={refresh} disabled={extensionsState.browseLoading}>
{#if extensionsState.browseLoading}
<CircleNotch size={14} weight="light" class="spin" />
{:else}
<ArrowsClockwise size={14} weight="bold" />
{/if}
</button>
<div class="pager">
<button
type="button"
aria-label="Previous page"
onclick={() => loadSourcePage(Math.max(1, page - 1))}
disabled={page <= 1 || extensionsState.browseLoading}
>
<CaretLeft size={14} weight="bold" />
</button>
<span>Page {page}</span>
<button
type="button"
aria-label="Next page"
onclick={() => loadSourcePage(page + 1)}
disabled={!extensionsState.browseHasMore || extensionsState.browseLoading}
>
<CaretRight size={14} weight="bold" />
</button>
</div>
</div>
</header>
{#if extensionsState.browseError}
<div class="empty-state error-state">
<p>Unable to browse this source.</p>
<small>{extensionsState.browseError}</small>
<button type="button" onclick={refresh}>Retry</button>
</div>
{:else if extensionsState.browseLoading && extensionsState.browseResults.length === 0}
<div class="empty-state">Loading manga...</div>
{:else if extensionsState.browseResults.length === 0}
<div class="empty-state">No manga found on this page.</div>
{:else}
<div class="results">
{#each extensionsState.browseResults as manga (manga.id)}
<MangaCard manga={manga} href={`/series/${manga.id}`} />
{/each}
</div>
{/if}
</section>
<style>
.browse-source-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-4);
}
.title-wrap h1 {
margin: var(--sp-1) 0 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
text-decoration: none;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.controls {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.icon-btn,
.pager button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
min-width: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
}
.icon-btn:disabled,
.pager button:disabled {
opacity: 0.4;
cursor: default;
}
.pager {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
}
.pager span {
min-width: 78px;
text-align: center;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--sp-3);
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
.error-state button {
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
border-radius: var(--radius-md);
height: 30px;
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
+309 -1
View File
@@ -1 +1,309 @@
<p>downloads</p> <script lang="ts">
import { onMount } from 'svelte'
import { ArrowsClockwise, DownloadSimple, TrashSimple, XCircle } from 'phosphor-svelte'
import { clearDownloads, dequeueDownload, loadDownloads } from '$lib/request-manager/downloads'
import {
activeDownloads as getActiveDownloads,
downloadCount as getDownloadCount,
downloadsState,
queuedDownloads as getQueuedDownloads,
} from '$lib/state/downloads.svelte'
let busy = $state(false)
const activeDownloads = $derived(getActiveDownloads())
const queuedDownloads = $derived(getQueuedDownloads())
const downloadCount = $derived(getDownloadCount())
onMount(async () => {
await loadDownloads()
})
async function refresh() {
busy = true
try {
await loadDownloads()
} finally {
busy = false
}
}
async function removeItem(chapterId: string) {
busy = true
try {
await dequeueDownload(chapterId)
} finally {
busy = false
}
}
async function clearAll() {
busy = true
try {
await clearDownloads()
} finally {
busy = false
}
}
function progressLabel(progress: number): string {
const pct = Math.round(Math.max(0, Math.min(1, progress)) * 100)
return `${pct}%`
}
</script>
<section class="downloads-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Downloads</h1>
<p>{downloadCount} total · {activeDownloads.length} active · {queuedDownloads.length} queued</p>
</div>
<div class="actions">
<button type="button" onclick={refresh} disabled={busy}>
<ArrowsClockwise size={14} weight="bold" /> Refresh
</button>
<button type="button" class="danger" onclick={clearAll} disabled={busy || downloadCount === 0}>
<TrashSimple size={14} weight="bold" /> Clear all
</button>
</div>
</header>
{#if downloadsState.error}
<div class="empty-state error-state">
<p>Unable to load downloads.</p>
<small>{downloadsState.error}</small>
<button type="button" onclick={refresh} disabled={busy}>Retry</button>
</div>
{:else if downloadsState.items.length === 0}
<div class="empty-state">
<DownloadSimple size={16} weight="light" />
Nothing in the queue.
</div>
{:else}
<ul class="downloads-list">
{#each downloadsState.items as item (item.chapterId)}
<li class="download-row" class:done={item.state === 'finished'} class:failed={item.state === 'error'}>
<div class="row-main">
<p class="title">{item.mangaTitle}</p>
<p class="meta">{item.chapterName}</p>
</div>
<div class="row-right">
<span class="state-pill">{item.state}</span>
<span class="progress-text">{progressLabel(item.progress)}</span>
<button
type="button"
class="icon-btn"
aria-label="Remove from queue"
onclick={() => removeItem(item.chapterId)}
disabled={busy}
>
<XCircle size={16} weight="bold" />
</button>
</div>
<div class="progress-track">
<div class="progress-fill" style={`width: ${progressLabel(item.progress)}`}></div>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.downloads-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-3);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.actions {
display: inline-flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.actions button,
.error-state button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
padding: 0 12px;
cursor: pointer;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.actions .danger {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 32%, var(--border-dim));
}
.actions button:disabled,
.icon-btn:disabled,
.error-state button:disabled {
opacity: 0.5;
cursor: default;
}
.downloads-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.download-row {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--sp-2) var(--sp-3);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
}
.download-row.done {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-dim));
}
.download-row.failed {
border-color: color-mix(in srgb, var(--color-error) 40%, var(--border-dim));
}
.row-main {
min-width: 0;
}
.title,
.meta {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.meta {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.row-right {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.state-pill {
border-radius: 999px;
border: 1px solid var(--border-dim);
padding: 3px 8px;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.progress-text {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
min-width: 34px;
text-align: right;
}
.icon-btn {
display: inline-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-overlay);
color: var(--text-faint);
cursor: pointer;
}
.progress-track {
grid-column: 1 / -1;
height: 6px;
border-radius: 999px;
background: var(--bg-overlay);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: var(--accent);
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
flex-direction: column;
text-align: center;
padding: var(--sp-4);
}
.error-state p,
.error-state small {
margin: 0;
}
</style>
+352 -1
View File
@@ -1 +1,352 @@
<p>extensions</p> <script lang="ts">
import { onMount } from 'svelte'
import { ArrowsClockwise, DownloadSimple, TrashSimple, ArrowFatUp, MagnifyingGlass } from 'phosphor-svelte'
import {
installExtension,
loadExtensions,
uninstallExtension,
updateExtension,
} from '$lib/request-manager/extensions'
import { extensionsState, filteredExtensions as getFilteredExtensions } from '$lib/state/extensions.svelte'
let busyIds = $state<string[]>([])
const filteredExtensions = $derived(getFilteredExtensions())
const languageOptions = $derived.by(() => {
const values: string[] = []
for (const extension of extensionsState.items) {
if (!values.includes(extension.lang)) values.push(extension.lang)
}
return values.sort((a, b) => a.localeCompare(b))
})
onMount(async () => {
await loadExtensions()
})
function isBusy(id: string): boolean {
return busyIds.includes(id)
}
function addBusy(id: string) {
if (busyIds.includes(id)) return
busyIds = [...busyIds, id]
}
function removeBusy(id: string) {
busyIds = busyIds.filter(value => value !== id)
}
async function refresh() {
await loadExtensions()
}
async function install(id: string) {
addBusy(id)
try {
await installExtension(id)
} finally {
removeBusy(id)
}
}
async function uninstall(id: string) {
addBusy(id)
try {
await uninstallExtension(id)
} finally {
removeBusy(id)
}
}
async function update(id: string) {
addBusy(id)
try {
await updateExtension(id)
} finally {
removeBusy(id)
}
}
function clearFilters() {
extensionsState.filter.query = ''
extensionsState.filter.installed = false
extensionsState.filter.language = 'all'
}
</script>
<section class="extensions-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Extensions</h1>
<p>{filteredExtensions.length} shown · {extensionsState.items.length} total</p>
</div>
<div class="controls">
<label class="search">
<span><MagnifyingGlass size={14} weight="light" /> Search</span>
<input type="search" placeholder="Find extension" bind:value={extensionsState.filter.query} />
</label>
<label class="select-control">
<span>Language</span>
<select bind:value={extensionsState.filter.language}>
<option value="all">All</option>
{#each languageOptions as lang (lang)}
<option value={lang}>{lang.toUpperCase()}</option>
{/each}
</select>
</label>
<label class="checkbox-control">
<input type="checkbox" bind:checked={extensionsState.filter.installed} />
<span>Installed only</span>
</label>
<button type="button" onclick={clearFilters}>Clear</button>
<button type="button" onclick={refresh} disabled={extensionsState.loading}>
<ArrowsClockwise size={14} weight="bold" />
</button>
</div>
</header>
{#if extensionsState.error}
<div class="empty-state error-state">
<p>Unable to load extensions.</p>
<small>{extensionsState.error}</small>
<button type="button" onclick={refresh}>Retry</button>
</div>
{:else if extensionsState.loading && extensionsState.items.length === 0}
<div class="empty-state">Loading extensions...</div>
{:else if filteredExtensions.length === 0}
<div class="empty-state">No extensions match the current filters.</div>
{:else}
<ul class="extensions-list">
{#each filteredExtensions as extension (extension.id)}
<li class="extension-row">
<div class="row-main">
<p class="title">{extension.name}</p>
<p class="meta">
{extension.lang.toUpperCase()} · v{extension.versionName}
{#if extension.isObsolete}
· Obsolete
{/if}
</p>
</div>
<div class="row-actions">
{#if extension.hasUpdate}
<button type="button" onclick={() => update(extension.id)} disabled={isBusy(extension.id)}>
<ArrowFatUp size={14} weight="bold" /> Update
</button>
{/if}
{#if extension.isInstalled}
<button type="button" class="danger" onclick={() => uninstall(extension.id)} disabled={isBusy(extension.id)}>
<TrashSimple size={14} weight="bold" /> Remove
</button>
{:else}
<button type="button" onclick={() => install(extension.id)} disabled={isBusy(extension.id)}>
<DownloadSimple size={14} weight="bold" /> Install
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.extensions-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-3);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search,
.select-control {
display: flex;
flex-direction: column;
gap: 6px;
}
.search span,
.select-control span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.search input,
.select-control select,
.controls button {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.search input {
min-width: 180px;
color: var(--text-primary);
}
.controls button {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.extensions-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
}
.row-main {
min-width: 0;
}
.title,
.meta {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.meta {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.row-actions {
display: inline-flex;
gap: var(--sp-2);
flex-shrink: 0;
}
.row-actions button,
.error-state button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
.row-actions .danger {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 32%, var(--border-dim));
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
</style>
+296
View File
@@ -0,0 +1,296 @@
<script lang="ts">
import { onMount } from 'svelte'
import { BookOpen, BookmarkSimple, TrashSimple, MagnifyingGlass } from 'phosphor-svelte'
import {
clearHistory,
historyState,
historyStatus,
initHistoryState,
removeBookmark,
} from '$lib/state/history.svelte'
let tab = $state<'history' | 'bookmarks'>('history')
let query = $state('')
const filteredHistory = $derived.by(() => {
const q = query.trim().toLowerCase()
if (!q) return historyState.history
return historyState.history.filter(entry =>
entry.mangaTitle.toLowerCase().includes(q) || entry.chapterName.toLowerCase().includes(q)
)
})
const filteredBookmarks = $derived.by(() => {
const q = query.trim().toLowerCase()
if (!q) return historyState.bookmarks
return historyState.bookmarks.filter(entry =>
entry.mangaTitle.toLowerCase().includes(q) || entry.chapterName.toLowerCase().includes(q)
)
})
onMount(async () => {
await initHistoryState()
})
function formatTimestamp(value: number): string {
if (!value) return 'Unknown'
return new Date(value).toLocaleString()
}
</script>
<section class="history-page">
<header class="toolbar">
<div class="title-wrap">
<h1>History</h1>
<p>
{historyState.history.length} reads ·
{historyState.bookmarks.length} bookmarks ·
{historyState.readingStats.totalChaptersRead} chapters completed
</p>
</div>
<div class="controls">
<label class="search">
<span><MagnifyingGlass size={14} weight="light" /> Search</span>
<input type="search" placeholder="Filter history" bind:value={query} />
</label>
<div class="tabs">
<button type="button" class:active={tab === 'history'} onclick={() => (tab = 'history')}>
<BookOpen size={14} weight="bold" /> Reads
</button>
<button type="button" class:active={tab === 'bookmarks'} onclick={() => (tab = 'bookmarks')}>
<BookmarkSimple size={14} weight="bold" /> Bookmarks
</button>
</div>
<button
type="button"
class="danger"
onclick={() => clearHistory()}
disabled={historyState.history.length === 0}
>
<TrashSimple size={14} weight="bold" /> Clear reads
</button>
</div>
</header>
{#if historyStatus.loading}
<div class="empty-state">Loading history...</div>
{:else if historyStatus.error}
<div class="empty-state error-state">
<p>Unable to load local history data.</p>
<small>{historyStatus.error}</small>
</div>
{:else if tab === 'history' && filteredHistory.length === 0}
<div class="empty-state">No reading history matches your filter.</div>
{:else if tab === 'bookmarks' && filteredBookmarks.length === 0}
<div class="empty-state">No bookmarks match your filter.</div>
{:else if tab === 'history'}
<ul class="entry-list">
{#each filteredHistory as entry (`h-${entry.chapterId}-${entry.readAt}`)}
<li class="entry-row">
<div class="row-main">
<p class="title">{entry.mangaTitle}</p>
<p class="meta">{entry.chapterName}</p>
</div>
<span class="time">{formatTimestamp(entry.readAt)}</span>
</li>
{/each}
</ul>
{:else}
<ul class="entry-list">
{#each filteredBookmarks as entry (`b-${entry.chapterId}-${entry.savedAt}`)}
<li class="entry-row">
<div class="row-main">
<p class="title">{entry.mangaTitle}</p>
<p class="meta">{entry.chapterName} · page {entry.pageNumber}</p>
</div>
<div class="row-actions">
<span class="time">{formatTimestamp(entry.savedAt)}</span>
<button type="button" onclick={() => removeBookmark(entry.chapterId)}>Remove</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.history-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-3);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search {
display: flex;
flex-direction: column;
gap: 6px;
}
.search span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.search input,
.danger,
.tabs button,
.row-actions button {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.search input {
min-width: 180px;
color: var(--text-primary);
}
.tabs {
display: inline-flex;
gap: var(--sp-1);
}
.tabs button,
.danger,
.row-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.tabs button.active {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.danger {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 32%, var(--border-dim));
}
.entry-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.entry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
}
.row-main {
min-width: 0;
}
.title,
.meta {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.meta,
.time {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.row-actions {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
</style>
+473 -1
View File
@@ -1 +1,473 @@
<p>library</p> <script lang="ts">
import { onMount } from 'svelte'
import { Funnel, ArrowsDownUp, SquaresFour, ListBullets, X } from 'phosphor-svelte'
import { loadLibrary } from '$lib/request-manager/manga'
import { libraryState, filteredItems as getFilteredItems } from '$lib/state/library.svelte'
import type { LibrarySortOption } from '$lib/state/library.svelte'
import type { MangaStatus } from '$lib/server-adapters/types'
import MangaCard from '$lib/ui/manga/MangaCard.svelte'
const sortOptions: Array<{ value: LibrarySortOption; label: string }> = [
{ value: 'alphabetical', label: 'Alphabetical' },
{ value: 'unread', label: 'Unread chapters' },
{ value: 'lastRead', label: 'Last read' },
{ value: 'dateAdded', label: 'Date added' },
]
const statusOptions: Array<{ value: MangaStatus | 'all'; label: string }> = [
{ value: 'all', label: 'All statuses' },
{ value: 'ONGOING', label: 'Ongoing' },
{ value: 'COMPLETED', label: 'Completed' },
{ value: 'ON_HIATUS', label: 'On hiatus' },
{ value: 'CANCELLED', label: 'Cancelled' },
{ value: 'LICENSED', label: 'Licensed' },
{ value: 'PUBLISHING_FINISHED', label: 'Publishing finished' },
]
let filtersOpen = $state(false)
const availableTags = $derived.by(() => {
const tags: string[] = []
for (const manga of libraryState.items) {
for (const tag of manga.tags ?? []) {
const normalized = tag.trim()
if (normalized && !tags.includes(normalized)) tags.push(normalized)
}
}
return tags.sort((a, b) => a.localeCompare(b))
})
const activeFilterCount = $derived.by(() => {
let count = 0
if (libraryState.filter.unread) count += 1
if (libraryState.filter.status !== 'all') count += 1
if (libraryState.filter.tags.length > 0) count += 1
if (libraryState.filter.query.trim()) count += 1
return count
})
const filteredItems = $derived(getFilteredItems())
const hasResults = $derived(filteredItems.length > 0)
onMount(async () => {
if (libraryState.items.length === 0) {
await loadLibrary({ inLibrary: true })
}
})
function toggleTag(tag: string) {
if (libraryState.filter.tags.includes(tag)) {
libraryState.filter.tags = libraryState.filter.tags.filter(t => t !== tag)
return
}
libraryState.filter.tags = [...libraryState.filter.tags, tag]
}
function clearFilters() {
libraryState.filter.status = 'all'
libraryState.filter.tags = []
libraryState.filter.unread = false
libraryState.filter.query = ''
}
</script>
<section class="library-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Library</h1>
<p>{filteredItems.length} manga</p>
</div>
<div class="controls">
<label class="search">
<span>Search</span>
<input
type="search"
placeholder="Search titles"
bind:value={libraryState.filter.query}
/>
</label>
<label class="select-control">
<span><ArrowsDownUp size={14} weight="bold" /> Sort</span>
<select bind:value={libraryState.sort}>
{#each sortOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
<button
class="icon-toggle"
type="button"
aria-label="Toggle sort direction"
title={libraryState.sortDesc ? 'Descending' : 'Ascending'}
onclick={() => (libraryState.sortDesc = !libraryState.sortDesc)}
>
{libraryState.sortDesc ? 'DESC' : 'ASC'}
</button>
<button
class="icon-toggle"
type="button"
aria-label="Toggle filters"
title="Filters"
onclick={() => (filtersOpen = !filtersOpen)}
>
<Funnel size={14} weight="bold" />
{#if activeFilterCount > 0}
<span class="badge">{activeFilterCount}</span>
{/if}
</button>
<div class="view-toggle" role="group" aria-label="View mode">
<button
class:active={libraryState.view === 'grid'}
type="button"
onclick={() => (libraryState.view = 'grid')}
aria-label="Grid view"
>
<SquaresFour size={14} weight="bold" />
</button>
<button
class:active={libraryState.view === 'list'}
type="button"
onclick={() => (libraryState.view = 'list')}
aria-label="List view"
>
<ListBullets size={14} weight="bold" />
</button>
</div>
</div>
</header>
{#if filtersOpen}
<aside class="filters-panel">
<div class="panel-heading-row">
<h2>Filters</h2>
<div class="panel-actions">
<button type="button" onclick={clearFilters}>Clear</button>
<button type="button" aria-label="Close filters" onclick={() => (filtersOpen = false)}>
<X size={14} weight="bold" />
</button>
</div>
</div>
<div class="filters-grid">
<label class="select-control">
<span>Status</span>
<select bind:value={libraryState.filter.status}>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
<label class="checkbox-control">
<input type="checkbox" bind:checked={libraryState.filter.unread} />
<span>Unread only</span>
</label>
</div>
{#if availableTags.length > 0}
<div class="tags-row">
<p>Tags</p>
<div class="tags-list">
{#each availableTags as tag (tag)}
<button
type="button"
class:active={libraryState.filter.tags.includes(tag)}
onclick={() => toggleTag(tag)}
>
{tag}
</button>
{/each}
</div>
</div>
{/if}
</aside>
{/if}
{#if libraryState.loading}
<div class="empty-state">Loading library...</div>
{:else if libraryState.error}
<div class="empty-state error-state">
<p>Failed to load library.</p>
<small>{libraryState.error}</small>
<button type="button" onclick={() => loadLibrary({ inLibrary: true })}>Retry</button>
</div>
{:else if !hasResults}
<div class="empty-state">No manga match the current filters.</div>
{:else}
<div class="results" class:list-view={libraryState.view === 'list'}>
{#each filteredItems as manga (manga.id)}
<MangaCard manga={manga} href={`/series/${manga.id}`} compact={libraryState.view === 'list'} />
{/each}
</div>
{/if}
</section>
<style>
.library-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-4);
}
.title-wrap h1 {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-2xl);
color: var(--text-primary);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search,
.select-control {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.search span,
.select-control span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
input,
select {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-primary);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.icon-toggle {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 44px;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
cursor: pointer;
}
.badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-fg);
font-size: 10px;
line-height: 16px;
}
.view-toggle {
display: inline-flex;
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-raised);
}
.view-toggle button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 34px;
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.view-toggle button.active {
background: var(--accent-muted);
color: var(--accent-fg);
}
.filters-panel {
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.panel-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-heading-row h2 {
margin: 0;
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: var(--text-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.panel-actions {
display: inline-flex;
gap: var(--sp-2);
}
.panel-actions button,
.tags-list button,
.empty-state button {
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
border-radius: var(--radius-md);
height: 30px;
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
.filters-grid {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
align-items: end;
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.checkbox-control input {
width: 16px;
height: 16px;
margin: 0;
}
.tags-row p {
margin: 0 0 var(--sp-2);
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.tags-list button.active {
border-color: var(--accent-dim);
background: var(--accent-muted);
color: var(--accent-fg);
}
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: var(--sp-3);
}
.results.list-view {
grid-template-columns: 1fr;
}
.empty-state {
min-height: 240px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-2);
border: 1px dashed var(--border-dim);
border-radius: var(--radius-xl);
color: var(--text-muted);
text-align: center;
padding: var(--sp-5);
}
.error-state small {
max-width: 80ch;
white-space: normal;
overflow-wrap: anywhere;
}
@media (max-width: 860px) {
.library-page {
padding: var(--sp-4);
}
.controls {
width: 100%;
}
.search,
.select-control {
min-width: min(220px, 100%);
flex: 1;
}
}
</style>
@@ -0,0 +1,18 @@
<script lang="ts">
let { children } = $props()
</script>
<div class="reader-shell">
{@render children()}
</div>
<style>
.reader-shell {
width: 100%;
height: 100%;
background:
radial-gradient(circle at top, color-mix(in srgb, var(--accent) 16%, transparent), transparent 42%),
var(--bg-base);
overflow: hidden;
}
</style>
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;
@@ -0,0 +1,459 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { ArrowArcLeft, CaretLeft, CaretRight, Columns, List, MagnifyingGlass, SpinnerGap, TextAlignRight } from 'phosphor-svelte'
import { currentPageData as getCurrentPageData, progress as getProgress, readerState } from '$lib/state/reader.svelte'
import { ensureReaderSession } from '$lib/core/reader/chapterLoader'
import { getAdjacentChapters, goToNextReaderPage, goToPreviousReaderPage, setCurrentReaderPage } from '$lib/core/reader/navigation'
import { createReaderKeyHandler } from '$lib/core/reader/readerKeybinds'
import { adjustZoom, ZOOM_STEP } from '$lib/core/reader/zoomHelpers'
import { preloadPages } from '$lib/core/reader/pageLoader'
import { settingsState } from '$lib/state/settings.svelte'
import { addBookmark, getBookmark, removeBookmark } from '$lib/state/history.svelte'
import Button from '$lib/ui/primitives/Button.svelte'
let initializing = $state(true)
let routeError = $state<string | null>(null)
let requestVersion = 0
const currentPageData = $derived(getCurrentPageData())
const progress = $derived(getProgress())
const mangaId = $derived($page.params.mangaId ?? '')
const chapterId = $derived($page.params.chapterId ?? '')
const chapterNeighbors = $derived.by(() => getAdjacentChapters())
const currentPageNumber = $derived(readerState.currentPage + 1)
const totalPages = $derived(readerState.pages.length)
const progressPercent = $derived(Math.round(progress * 100))
const pageLabel = $derived(totalPages > 0 ? `${currentPageNumber} / ${totalPages}` : '0 / 0')
const chapterLabel = $derived(readerState.chapter ? `Ch. ${readerState.chapter.chapterNumber}` : 'Chapter')
const zoomPct = $derived(Math.round(readerState.zoom * 100))
$effect(() => {
const activeMangaId = mangaId
const activeChapterId = chapterId
if (!activeMangaId || !activeChapterId) return
const version = ++requestVersion
initializing = true
routeError = null
void ensureReaderSession(activeMangaId, activeChapterId)
.catch((error) => {
if (version !== requestVersion) return
routeError = error instanceof Error ? error.message : String(error)
})
.finally(() => {
if (version !== requestVersion) return
initializing = false
})
})
$effect(() => {
preloadPages(readerState.pages, readerState.currentPage, settingsState.preloadPages ?? 3)
})
async function stepForward() {
const advanced = await goToNextReaderPage()
if (advanced) return
if (chapterNeighbors.next && readerState.manga) {
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.next.id}`)
}
}
async function stepBackward() {
const moved = await goToPreviousReaderPage()
if (moved) return
if (chapterNeighbors.previous && readerState.manga) {
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.previous.id}`)
}
}
async function handleRangeInput(event: Event) {
const target = event.currentTarget as HTMLInputElement
await setCurrentReaderPage(Number(target.value) - 1)
}
function retryLoad() {
requestVersion += 1
initializing = true
routeError = null
void ensureReaderSession(mangaId, chapterId)
.catch((error) => {
routeError = error instanceof Error ? error.message : String(error)
})
.finally(() => {
initializing = false
})
}
async function returnToSeries() {
if (!readerState.manga) return
await goto(`/series/${readerState.manga.id}`)
}
function cycleMode() {
readerState.mode = readerState.mode === 'single' ? 'strip' : 'single'
}
function toggleBookmarkAction() {
if (!readerState.chapter || !readerState.manga) return
const currentChapterId = readerState.chapter.id
if (getBookmark(currentChapterId)) {
removeBookmark(currentChapterId)
return
}
addBookmark({
mangaId: readerState.manga.id,
chapterId: currentChapterId,
pageNumber: readerState.currentPage,
mangaTitle: readerState.manga.title,
chapterName: readerState.chapter.name,
thumbnailUrl: readerState.manga.thumbnailUrl,
})
}
const handleKeydown = createReaderKeyHandler({
goNext: () => void (readerState.direction === 'rtl' ? stepBackward() : stepForward()),
goPrev: () => void (readerState.direction === 'rtl' ? stepForward() : stepBackward()),
goToPage: (idx) => void setCurrentReaderPage(idx),
lastPage: () => readerState.pages.length - 1,
exitReader: () => void returnToSeries(),
chapterNext: () => {
const neighbors = getAdjacentChapters()
if (readerState.manga && neighbors.next) {
void goto(`/reader/${readerState.manga.id}/${neighbors.next.id}`)
}
},
chapterPrev: () => {
const neighbors = getAdjacentChapters()
if (readerState.manga && neighbors.previous) {
void goto(`/reader/${readerState.manga.id}/${neighbors.previous.id}`)
}
},
adjustZoom: (delta) => {
readerState.zoom = adjustZoom(readerState.zoom, delta)
},
resetZoom: () => {
readerState.zoom = 1
readerState.inspectScale = 1
readerState.inspectPanX = 0
readerState.inspectPanY = 0
},
cycleMode,
toggleDirection: () => {
readerState.direction = readerState.direction === 'ltr' ? 'rtl' : 'ltr'
},
openSettings: () => void goto('/settings/general'),
toggleBookmark: toggleBookmarkAction,
toggleAutoScroll: () => {
readerState.autoScrollActive = !readerState.autoScrollActive
},
getKeybinds: () => settingsState.keybinds,
})
</script>
<svelte:window onkeydown={handleKeydown} />
<section class="reader-page">
<header class="reader-toolbar">
<div class="reader-meta">
<Button variant="ghost" size="sm" onclick={returnToSeries}>
<ArrowArcLeft size={16} weight="bold" />
Back
</Button>
<div class="reader-titles">
<p class="eyebrow">{readerState.manga?.title ?? 'Reader'}</p>
<h1>{readerState.chapter?.name ?? 'Loading chapter'}</h1>
<p class="subcopy">{chapterLabel} · {pageLabel}</p>
</div>
</div>
<div class="reader-actions">
<div class="toggle-group">
<button class:active={readerState.mode === 'single'} type="button" onclick={() => (readerState.mode = 'single')}>
<Columns size={16} weight="bold" />
Single
</button>
<button class:active={readerState.mode === 'strip'} type="button" onclick={() => (readerState.mode = 'strip')}>
<List size={16} weight="bold" />
Strip
</button>
</div>
<button class="direction-toggle" type="button" onclick={() => (readerState.direction = readerState.direction === 'ltr' ? 'rtl' : 'ltr')}>
<TextAlignRight size={16} weight="bold" />
{readerState.direction.toUpperCase()}
</button>
<div class="zoom-controls">
<button class="zoom-btn" type="button" onclick={() => { readerState.zoom = adjustZoom(readerState.zoom, -ZOOM_STEP) }}></button>
<button class="zoom-label" type="button" onclick={() => { readerState.zoom = 1; readerState.inspectScale = 1 }}>
<MagnifyingGlass size={12} weight="bold" />
{zoomPct}%
</button>
<button class="zoom-btn" type="button" onclick={() => { readerState.zoom = adjustZoom(readerState.zoom, ZOOM_STEP) }}>+</button>
</div>
</div>
</header>
<div class="reader-progress">
<div class="progress-copy">
<span>{progressPercent}% read</span>
<span>{pageLabel}</span>
</div>
<input
type="range"
min="1"
max={Math.max(totalPages, 1)}
value={Math.min(Math.max(currentPageNumber, 1), Math.max(totalPages, 1))}
oninput={handleRangeInput}
disabled={totalPages === 0}
aria-label="Reader progress"
/>
</div>
<div class="reader-stage">
{#if initializing && readerState.pages.length === 0}
<div class="reader-status">
<span class="spin"><SpinnerGap size={22} weight="bold" /></span>
<p>Loading chapter pages...</p>
</div>
{:else if routeError || readerState.pagesError}
<div class="reader-status error">
<p>{routeError ?? readerState.pagesError}</p>
<Button onclick={retryLoad}>Retry</Button>
</div>
{:else if totalPages === 0}
<div class="reader-status">
<p>No pages were returned for this chapter.</p>
</div>
{:else if readerState.mode === 'strip'}
<div class="strip-view" style="zoom: {readerState.zoom}">
{#each readerState.pages as pageData, index (pageData.index)}
<button class="strip-page" class:current={index === readerState.currentPage} type="button" onclick={() => void setCurrentReaderPage(index)}>
<img src={pageData.imageData ?? pageData.url} alt={`Page ${index + 1}`} loading="lazy" data-page-index={index} />
<span>Page {index + 1}</span>
</button>
{/each}
</div>
{:else}
<div class="single-view">
<button class="edge-nav left" type="button" onclick={() => void stepBackward()} aria-label="Previous page">
<CaretLeft size={28} weight="bold" />
</button>
{#if currentPageData}
<img class="single-page" src={currentPageData.imageData ?? currentPageData.url} alt={`Page ${currentPageNumber}`} />
{/if}
<button class="edge-nav right" type="button" onclick={() => void stepForward()} aria-label="Next page">
<CaretRight size={28} weight="bold" />
</button>
</div>
{/if}
</div>
<footer class="reader-footer">
<Button variant="ghost" onclick={() => void stepBackward()}>
<CaretLeft size={16} weight="bold" />
{chapterNeighbors.previous && readerState.currentPage === 0 ? 'Prev chapter' : 'Prev page'}
</Button>
<Button onclick={() => void stepForward()}>
{readerState.currentPage >= totalPages - 1 && chapterNeighbors.next ? 'Next chapter' : 'Next page'}
<CaretRight size={16} weight="bold" />
</Button>
</footer>
</section>
<style>
.reader-page {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: var(--sp-3);
height: 100%;
padding: var(--sp-4);
color: var(--text-primary);
}
.reader-toolbar,
.reader-progress,
.reader-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
}
.reader-meta,
.reader-actions,
.reader-footer {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.reader-titles h1 {
margin: 2px 0;
font-family: var(--font-display);
font-size: var(--text-xl);
line-height: var(--leading-tight);
}
.eyebrow,
.subcopy,
.progress-copy,
.strip-page span {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.reader-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.toggle-group,
.direction-toggle,
.zoom-controls {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
background: var(--bg-base);
}
.toggle-group button,
.direction-toggle,
.zoom-btn,
.zoom-label {
height: 34px;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.toggle-group button.active {
color: var(--text-primary);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.reader-progress {
flex-direction: column;
align-items: stretch;
}
.progress-copy {
display: flex;
justify-content: space-between;
gap: var(--sp-2);
}
.reader-progress input {
width: 100%;
}
.reader-stage {
min-height: 0;
border: 1px solid var(--border-dim);
border-radius: var(--radius-2xl);
background: color-mix(in srgb, var(--bg-base) 92%, black 8%);
overflow: auto;
}
.reader-status,
.single-view,
.strip-view {
min-height: 100%;
}
.reader-status {
display: grid;
place-items: center;
gap: var(--sp-3);
padding: var(--sp-6);
text-align: center;
color: var(--text-muted);
}
.reader-status.error {
color: var(--color-error);
}
.spin {
animation: spin 0.8s linear infinite;
}
.single-view {
display: grid;
grid-template-columns: minmax(56px, 96px) 1fr minmax(56px, 96px);
align-items: center;
height: 100%;
}
.single-page {
width: 100%;
max-height: 100%;
object-fit: contain;
justify-self: center;
}
.edge-nav {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.strip-view {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
}
.strip-page {
display: grid;
gap: var(--sp-2);
justify-items: center;
padding: var(--sp-3);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
cursor: pointer;
}
.strip-page.current {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
}
.strip-page img {
width: min(100%, 1100px);
object-fit: contain;
}
</style>

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