mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d74790c3a0 | |||
| 0e93908bb2 | |||
| 074147f64f | |||
| f91b46cfa5 | |||
| 71ee4052f3 | |||
| 5e2114810e | |||
| b3fca70f27 | |||
| 68f25a2ea7 | |||
| 3d6b6430ed | |||
| 54307d4411 | |||
| f8f080eff3 | |||
| f41f8a9c22 | |||
| 8cef79b2b4 |
@@ -6,6 +6,7 @@ dist-tauri/
|
||||
target/
|
||||
bin/
|
||||
out/
|
||||
notes/
|
||||
|
||||
.direnv/
|
||||
result
|
||||
|
||||
@@ -22,14 +22,23 @@
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/browser": "^8.0.3",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@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-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"capacitor-native-biometric": "^4.2.2",
|
||||
"phosphor-svelte": "^3.1.0"
|
||||
}
|
||||
}
|
||||
Generated
+137
-23
@@ -8,31 +8,58 @@ importers:
|
||||
|
||||
.:
|
||||
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':
|
||||
specifier: ^2.0.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':
|
||||
specifier: ^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:
|
||||
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:
|
||||
'@sveltejs/adapter-node':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
specifier: ^2.0.0
|
||||
version: 2.11.2
|
||||
'@types/node':
|
||||
specifier: ^25.9.1
|
||||
version: 25.9.1
|
||||
svelte:
|
||||
specifier: ^5.55.2
|
||||
version: 5.55.5(@typescript-eslint/types@8.57.1)
|
||||
@@ -44,10 +71,31 @@ importers:
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.7
|
||||
version: 8.0.10
|
||||
version: 8.0.10(@types/node@25.9.1)
|
||||
|
||||
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':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -477,9 +525,21 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
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':
|
||||
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':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -489,6 +549,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/node@25.9.1':
|
||||
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
|
||||
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
@@ -512,6 +575,9 @@ packages:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
capacitor-native-biometric@4.2.2:
|
||||
resolution: {integrity: sha512-stg0h48UxgkNuNcCAgCXLp2DUspRQs79bCBPntpCBhsDxk2bhDRUu+J/QpFtDQHG4M4DioSUcYaAsVw2N6N7wA==}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
@@ -785,6 +851,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
vite@8.0.10:
|
||||
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -841,6 +910,25 @@ packages:
|
||||
|
||||
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':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -1055,23 +1143,23 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
'@rollup/plugin-commonjs': 29.0.2(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)
|
||||
'@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
|
||||
|
||||
'@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:
|
||||
'@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:
|
||||
'@standard-schema/spec': 1.1.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
|
||||
acorn: 8.16.0
|
||||
cookie: 0.6.0
|
||||
@@ -1083,18 +1171,18 @@ snapshots:
|
||||
set-cookie-parser: 3.1.0
|
||||
sirv: 3.0.2
|
||||
svelte: 5.55.5(@typescript-eslint/types@8.57.1)
|
||||
vite: 8.0.10
|
||||
vite: 8.0.10(@types/node@25.9.1)
|
||||
optionalDependencies:
|
||||
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:
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
svelte: 5.55.5(@typescript-eslint/types@8.57.1)
|
||||
vite: 8.0.10
|
||||
vitefu: 1.1.3(vite@8.0.10)
|
||||
vite: 8.0.10(@types/node@25.9.1)
|
||||
vitefu: 1.1.3(vite@8.0.10(@types/node@25.9.1))
|
||||
|
||||
'@tauri-apps/api@2.11.0': {}
|
||||
|
||||
@@ -1145,10 +1233,26 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.7.1':
|
||||
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':
|
||||
dependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -1158,6 +1262,10 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/node@25.9.1':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
@@ -1171,6 +1279,10 @@ snapshots:
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
|
||||
capacitor-native-biometric@4.2.2:
|
||||
dependencies:
|
||||
'@capacitor/core': 3.9.0
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
@@ -1299,13 +1411,13 @@ snapshots:
|
||||
|
||||
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:
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
svelte: 5.55.5(@typescript-eslint/types@8.57.1)
|
||||
optionalDependencies:
|
||||
vite: 8.0.10
|
||||
vite: 8.0.10(@types/node@25.9.1)
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
@@ -1434,12 +1546,13 @@ snapshots:
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
vite@8.0.10:
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
vite@8.0.10(@types/node@25.9.1):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -1447,10 +1560,11 @@ snapshots:
|
||||
rolldown: 1.0.0-rc.17
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.1
|
||||
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:
|
||||
vite: 8.0.10
|
||||
vite: 8.0.10(@types/node@25.9.1)
|
||||
|
||||
zimmerframe@1.1.4: {}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.9.4",
|
||||
"identifier": "io.github.MokuProject.Moku",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"frontendDist": "../build",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
},
|
||||
"app": {
|
||||
|
||||
+12
-251
@@ -1,262 +1,23 @@
|
||||
@import './lib/design/index.css';
|
||||
|
||||
: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);
|
||||
|
||||
--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;
|
||||
--ui-zoom: 1;
|
||||
--ui-scale: 1;
|
||||
--visual-vh: 100vh;
|
||||
}
|
||||
|
||||
[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;
|
||||
html,
|
||||
body,
|
||||
#svelte {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
[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);
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#svelte {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
Vendored
+48
@@ -2,4 +2,52 @@ declare global {
|
||||
namespace App {}
|
||||
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 {}
|
||||
+71
-49
@@ -1,53 +1,59 @@
|
||||
import { initRequestManager } from '$lib/request-manager'
|
||||
import { initPlatformService } from '$lib/platform-service'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { configureAuth, probeServer } from '$lib/core/auth'
|
||||
import {initRequestManager} from '$lib/request-manager';
|
||||
import {initPlatformService} from '$lib/platform-service';
|
||||
import {appState} from '$lib/state/app.svelte';
|
||||
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_AUTH_KEY = 'moku_auth_config'
|
||||
const SAVED_URL_KEY = 'moku_server_url';
|
||||
const SAVED_AUTH_KEY = 'moku_auth_config';
|
||||
|
||||
interface SavedAuth {
|
||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||
user?: string
|
||||
pass?: string
|
||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN';
|
||||
user?: 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 {
|
||||
return '__TAURI_INTERNALS__' in window
|
||||
return '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
function isCapacitor(): boolean {
|
||||
return 'Capacitor' in window
|
||||
return 'Capacitor' in window;
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' }
|
||||
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? {mode: 'NONE'};
|
||||
} catch {
|
||||
return { mode: 'NONE' }
|
||||
return {mode: 'NONE'};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlatformAdapter() {
|
||||
if (isTauri()) {
|
||||
const { TauriAdapter } = await import('$lib/platform-adapters/tauri')
|
||||
return new TauriAdapter()
|
||||
const {TauriAdapter} = await import('$lib/platform-adapters/tauri');
|
||||
return new TauriAdapter();
|
||||
}
|
||||
if (isCapacitor()) {
|
||||
const { CapacitorAdapter } = await import('$lib/platform-adapters/capacitor')
|
||||
return new CapacitorAdapter()
|
||||
}
|
||||
const { WebAdapter } = await import('$lib/platform-adapters/web')
|
||||
return new WebAdapter()
|
||||
// if (isCapacitor()) {
|
||||
// const {CapacitorAdapter} = await import('$lib/platform-adapters/capacitor');
|
||||
// return new CapacitorAdapter();
|
||||
// }
|
||||
const {WebAdapter} = await import('$lib/platform-adapters/web');
|
||||
return new WebAdapter();
|
||||
}
|
||||
|
||||
async function resolveServerAdapter() {
|
||||
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
||||
return new SuwayomiAdapter()
|
||||
const {SuwayomiAdapter} = await import('$lib/server-adapters/suwayomi');
|
||||
return new SuwayomiAdapter();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
@@ -55,46 +61,62 @@ async function boot() {
|
||||
const [serverAdapter, platformAdapter] = await Promise.all([
|
||||
resolveServerAdapter(),
|
||||
resolvePlatformAdapter(),
|
||||
])
|
||||
]);
|
||||
|
||||
initRequestManager(serverAdapter)
|
||||
initPlatformService(platformAdapter)
|
||||
await platformAdapter.init();
|
||||
|
||||
appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web'
|
||||
appState.version = await platformAdapter.getVersion()
|
||||
initRequestManager(serverAdapter);
|
||||
initPlatformService(platformAdapter);
|
||||
|
||||
const savedUrl = loadSavedServerUrl()
|
||||
const savedAuth = loadSavedAuth()
|
||||
await Promise.all([
|
||||
initSettingsState(),
|
||||
initHistoryState(),
|
||||
]);
|
||||
|
||||
appState.serverUrl = savedUrl
|
||||
appState.authMode = savedAuth.mode
|
||||
// appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web';
|
||||
appState.platform = isTauri() ? 'tauri' : 'web';
|
||||
appState.version = await platformAdapter.getVersion();
|
||||
|
||||
if (isTauri() && platformAdapter.isSupported('server-management')) {
|
||||
await platformAdapter.launchServer({ url: savedUrl }).catch(() => {})
|
||||
}
|
||||
const legacyAuth = loadSavedAuth();
|
||||
const savedUrl = settingsState.serverUrl || loadSavedServerUrl();
|
||||
const savedAuth: SavedAuth = {
|
||||
mode: normalizeAuthMode(settingsState.serverAuthMode || legacyAuth.mode),
|
||||
user: settingsState.serverAuthUser || legacyAuth.user,
|
||||
pass: settingsState.serverAuthPass || legacyAuth.pass,
|
||||
};
|
||||
|
||||
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
||||
await serverAdapter.connect({ baseUrl: savedUrl })
|
||||
updateSettings({
|
||||
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') {
|
||||
appState.status = 'auth'
|
||||
return
|
||||
appState.status = 'auth';
|
||||
return;
|
||||
}
|
||||
|
||||
if (probe === 'unreachable') {
|
||||
appState.error = `Could not reach server at ${savedUrl}`
|
||||
appState.status = 'error'
|
||||
return
|
||||
appState.error = `Could not reach server at ${savedUrl}`;
|
||||
appState.status = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
appState.authenticated = true
|
||||
appState.status = 'ready'
|
||||
appState.authenticated = true;
|
||||
appState.status = 'ready';
|
||||
} catch (e) {
|
||||
appState.error = String(e)
|
||||
appState.status = 'error'
|
||||
appState.error = String(e);
|
||||
appState.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
boot()
|
||||
boot();
|
||||
@@ -0,0 +1 @@
|
||||
export * from './selectPortal';
|
||||
@@ -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,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 {
|
||||
return (item) => predicates.every((p) => p(item));
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './fetchWithRetry';
|
||||
export * from './batchRequests';
|
||||
export * from './createPaginatedQuery';
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Vendored
+153
@@ -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;
|
||||
}
|
||||
Vendored
+4
@@ -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';
|
||||
Vendored
+119
@@ -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();
|
||||
}
|
||||
Vendored
+31
-31
@@ -1,18 +1,18 @@
|
||||
interface Entry<T> {
|
||||
promise: Promise<T>;
|
||||
promise: Promise<T>;
|
||||
fetchedAt: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const keyToGroups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
|
||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
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[]) {
|
||||
if (!group) return;
|
||||
@@ -40,7 +40,7 @@ export const cache = {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
}) 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);
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
@@ -62,7 +62,7 @@ export const cache = {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing) return;
|
||||
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(() => {});
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ export const cache = {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
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(() => {});
|
||||
return promise;
|
||||
},
|
||||
@@ -88,13 +88,13 @@ export const cache = {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
has(key: string): boolean { return store.has(key); },
|
||||
has(key: string): boolean {return store.has(key);},
|
||||
|
||||
ageOf(key: string): number | undefined {
|
||||
const e = store.get(key);
|
||||
@@ -146,16 +146,16 @@ export const CACHE_GROUPS = {
|
||||
} as const;
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
LIBRARY: "library",
|
||||
RECENT_UPDATES: "recent_updates",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
SEARCH: "search_all_manga",
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
GENRE: (genre: string) => `genre:${genre}`,
|
||||
MANGA: (id: number) => `manga:${id}`,
|
||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||
SEARCH: "search_all_manga",
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
GENRE: (genre: string) => `genre:${genre}`,
|
||||
MANGA: (id: number) => `manga:${id}`,
|
||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||
|
||||
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
|
||||
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||
@@ -189,24 +189,24 @@ export interface PageSet {
|
||||
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
|
||||
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
|
||||
return {
|
||||
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); },
|
||||
pages() { return new Set(_pageSets.get(key) ?? []); },
|
||||
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
|
||||
clear() { _pageSets.delete(key); },
|
||||
add(page) {if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page);},
|
||||
pages() {return new Set(_pageSets.get(key) ?? []);},
|
||||
next() {const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1;},
|
||||
clear() {_pageSets.delete(key);},
|
||||
};
|
||||
}
|
||||
|
||||
const FRECENCY_KEY = "moku-source-frecency";
|
||||
const FRECENCY_KEY = "moku-source-frecency";
|
||||
const MAX_FRECENCY_SOURCES = 4;
|
||||
type FrecencyMap = Record<string, number>;
|
||||
|
||||
function loadFrecency(): FrecencyMap {
|
||||
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
|
||||
catch { return {}; }
|
||||
try {const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {};}
|
||||
catch {return {};}
|
||||
}
|
||||
|
||||
function saveFrecency(map: FrecencyMap) {
|
||||
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
|
||||
try {localStorage.setItem(FRECENCY_KEY, JSON.stringify(map));} catch {}
|
||||
}
|
||||
|
||||
export function recordSourceAccess(sourceId: string) {
|
||||
@@ -216,9 +216,9 @@ export function recordSourceAccess(sourceId: string) {
|
||||
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 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)) {
|
||||
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);
|
||||
|
||||
if (thumbnailUrl) {
|
||||
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
|
||||
const {revokeBlobUrl, getBlobUrl} = await import('$lib/core/cache/imageCache');
|
||||
revokeBlobUrl(thumbnailUrl);
|
||||
getBlobUrl(thumbnailUrl, 999).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
export function eventToKeybind(e: KeyboardEvent): string {
|
||||
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey) parts.push("ctrl");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.ctrlKey) parts.push("ctrl");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.shiftKey) parts.push("shift");
|
||||
if (e.metaKey) parts.push("meta");
|
||||
if (e.metaKey) parts.push("meta");
|
||||
parts.push(e.key);
|
||||
return parts.join("+");
|
||||
}
|
||||
@@ -12,3 +12,21 @@ export function eventToKeybind(e: KeyboardEvent): string {
|
||||
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,17 @@ interface StoredVault {
|
||||
data: string;
|
||||
}
|
||||
|
||||
function toB64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
function toB64(data: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
function fromB64(s: string): Uint8Array {
|
||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
function fromB64(s: string): ArrayBuffer {
|
||||
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 keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
@@ -42,7 +44,7 @@ export function vaultExists(): boolean {
|
||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
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 cipher = await crypto.subtle.encrypt(
|
||||
@@ -65,10 +67,12 @@ export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
try {
|
||||
const stored = JSON.parse(raw) as StoredVault;
|
||||
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(
|
||||
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
cipher,
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||
} catch {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -16,12 +16,26 @@ export function applyZoom(uiZoom: number) {
|
||||
|
||||
export function zoomDelta(e: KeyboardEvent, current: number): number | 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.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 === "=" || 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 === "0") {e.preventDefault(); return 1.0;}
|
||||
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 {
|
||||
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(
|
||||
containerEl: HTMLElement | null,
|
||||
style: string,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
out: {el: HTMLElement | null; offset: number;},
|
||||
) {
|
||||
if (!containerEl || style !== "longstrip") return;
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
|
||||
if (rect.bottom > containerTop) {out.el = img; out.offset = rect.top - containerTop; return;}
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreZoomAnchor(
|
||||
containerEl: HTMLElement | null,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
out: {el: HTMLElement | null; offset: number;},
|
||||
) {
|
||||
if (!out.el || !containerEl) return;
|
||||
const el = out.el;
|
||||
|
||||
+32
-32
@@ -1,17 +1,17 @@
|
||||
import type { Manga, Source } from "$lib/types";
|
||||
import type { Settings } from "$lib/types";
|
||||
import type {Manga, Source} from '$lib/types/index';
|
||||
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 {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", {month: "short", day: "numeric"});
|
||||
}
|
||||
|
||||
export function dayLabel(ts: number): string {
|
||||
@@ -19,11 +19,11 @@ export function dayLabel(ts: number): string {
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
return d.toLocaleDateString("en-US", {weekday: "long", month: "long", day: "numeric"});
|
||||
}
|
||||
|
||||
export function formatReadTime(m: number): string {
|
||||
if (m < 1) return "< 1 min";
|
||||
if (m < 1) return "< 1 min";
|
||||
if (m < 60) return `${m} min`;
|
||||
const h = Math.floor(m / 60), r = m % 60;
|
||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||
@@ -46,7 +46,7 @@ type ContentFilterSettings = Pick<
|
||||
>;
|
||||
|
||||
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
||||
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
||||
return [];
|
||||
}
|
||||
@@ -59,7 +59,7 @@ function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean
|
||||
const idx = norm.indexOf(tag);
|
||||
if (idx === -1) return false;
|
||||
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
||||
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||
return before && after;
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ export function shouldHideNsfw(
|
||||
): boolean {
|
||||
if (settings.contentLevel === "unrestricted") return false;
|
||||
|
||||
const srcId = manga.source?.id;
|
||||
const srcId = manga.source?.id;
|
||||
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
||||
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
||||
|
||||
@@ -99,19 +99,19 @@ export function shouldHideSource(
|
||||
}
|
||||
|
||||
export function dedupeSourcesByLang(
|
||||
sources: Source[],
|
||||
sources: Source[],
|
||||
preferredLang: string,
|
||||
settings: ContentFilterSettings,
|
||||
applyHide = false,
|
||||
settings: ContentFilterSettings,
|
||||
applyHide = false,
|
||||
): Source[] {
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
if (s.id === "0") continue;
|
||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
if (!existing) {map.set(s.name, s); continue;}
|
||||
const existingPref = existing.lang === preferredLang;
|
||||
const newPref = s.lang === preferredLang;
|
||||
const newPref = s.lang === preferredLang;
|
||||
if (newPref && !existingPref) map.set(s.name, s);
|
||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
@@ -159,36 +159,36 @@ function authorFingerprint(author?: string | null, artist?: string | null): stri
|
||||
}
|
||||
|
||||
export function dedupeMangaByTitle<T extends {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
artist?: string | null;
|
||||
inLibrary?: boolean;
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
artist?: string | null;
|
||||
inLibrary?: boolean;
|
||||
downloadCount?: number;
|
||||
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
||||
const byTitle = new Map<string, number>();
|
||||
const byDesc = new Map<string, number>();
|
||||
const byTitle = new Map<string, number>();
|
||||
const byDesc = new Map<string, number>();
|
||||
const byAuthorDesc = new Map<string, number>();
|
||||
const byId = new Map<number, number>();
|
||||
const out: T[] = [];
|
||||
const byId = new Map<number, number>();
|
||||
const out: T[] = [];
|
||||
|
||||
for (const m of items) {
|
||||
const tk = normalizeTitle(m.title);
|
||||
const dk = descFingerprint(m.description);
|
||||
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
||||
|
||||
const linkedIds = links[m.id] ?? [];
|
||||
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
||||
const linkedIds = links[m.id] ?? [];
|
||||
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
||||
const existingIdx =
|
||||
linkedIdx ??
|
||||
byTitle.get(tk) ??
|
||||
(dk ? byDesc.get(dk) : undefined) ??
|
||||
(dk ? byDesc.get(dk) : undefined) ??
|
||||
(ak ? byAuthorDesc.get(ak) : undefined);
|
||||
|
||||
if (existingIdx !== undefined) {
|
||||
const existing = out[existingIdx];
|
||||
const mBetter =
|
||||
const mBetter =
|
||||
(m.inLibrary && !existing.inLibrary) ||
|
||||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
||||
|
||||
@@ -213,11 +213,11 @@ export function dedupeMangaByTitle<T extends {
|
||||
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 out: T[] = [];
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@import './reset.css';
|
||||
@import './animations.css';
|
||||
@import './scrollbars.css';
|
||||
@import './typography.css';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@import './base/index.css';
|
||||
@import './tokens/index.css';
|
||||
@import './themes/index.css';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import './original.css';
|
||||
@import './dark.css';
|
||||
@import './light.css';
|
||||
@import './light-contrast.css';
|
||||
@import './midnight.css';
|
||||
@import './warm.css';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,5 @@
|
||||
:root {
|
||||
--t-fast: 0.08s ease;
|
||||
--t-base: 0.14s ease;
|
||||
--t-slow: 0.22s ease;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
:root {
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 7px;
|
||||
--radius-xl: 10px;
|
||||
--radius-2xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
:root {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
:root {
|
||||
--z-reader: 50;
|
||||
--z-modal: 100;
|
||||
--z-settings: 150;
|
||||
}
|
||||
@@ -1,40 +1,66 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import { readerState } from '$lib/state/reader.svelte'
|
||||
import {getAdapter} from '$lib/request-manager';
|
||||
import {seriesState} from '$lib/state/series.svelte';
|
||||
import {readerState} from '$lib/state/reader.svelte';
|
||||
|
||||
export async function loadChapters(mangaId: string) {
|
||||
seriesState.chaptersLoading = true
|
||||
seriesState.chaptersError = null
|
||||
seriesState.chaptersLoading = true;
|
||||
seriesState.chaptersError = null;
|
||||
try {
|
||||
seriesState.chapters = await getAdapter().getChapters(mangaId)
|
||||
seriesState.chapters = await getAdapter().getChapters(mangaId);
|
||||
} catch (e) {
|
||||
seriesState.chaptersError = String(e)
|
||||
seriesState.chaptersError = String(e);
|
||||
} finally {
|
||||
seriesState.chaptersLoading = false
|
||||
seriesState.chaptersLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadChapterPages(chapterId: string) {
|
||||
readerState.pagesLoading = true
|
||||
readerState.pagesError = null
|
||||
readerState.pagesLoading = true;
|
||||
readerState.pagesError = null;
|
||||
try {
|
||||
readerState.pages = await getAdapter().getChapterPages(chapterId)
|
||||
readerState.pages = await getAdapter().getChapterPages(chapterId);
|
||||
} catch (e) {
|
||||
readerState.pagesError = String(e)
|
||||
readerState.pagesError = String(e);
|
||||
} 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) {
|
||||
await getAdapter().markChapterRead(id, read)
|
||||
const chapter = seriesState.chapters.find(c => c.id === id)
|
||||
if (chapter) chapter.read = read
|
||||
await getAdapter().markChapterRead(id, read);
|
||||
const chapter = seriesState.chapters.find(c => String(c.id) === id);
|
||||
if (chapter) chapter.read = read;
|
||||
}
|
||||
|
||||
export async function markManyRead(ids: string[], read: boolean) {
|
||||
await getAdapter().markChaptersRead(ids, read)
|
||||
await getAdapter().markChaptersRead(ids, read);
|
||||
for (const c of seriesState.chapters) {
|
||||
if (ids.includes(c.id)) c.read = read
|
||||
if (ids.includes(String(c.id))) c.read = read;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { trackingState } from '$lib/state/tracking.svelte'
|
||||
import {getAdapter} from '$lib/request-manager';
|
||||
import {trackingState} from '$lib/state/tracking.svelte';
|
||||
import type {TrackRecord} from '$lib/types/index';
|
||||
|
||||
export async function loadTrackers() {
|
||||
trackingState.loading = true
|
||||
trackingState.error = null
|
||||
trackingState.loading = true;
|
||||
trackingState.error = null;
|
||||
try {
|
||||
trackingState.trackers = await getAdapter().getTrackers()
|
||||
trackingState.trackers = await getAdapter().getTrackers();
|
||||
} catch (e) {
|
||||
trackingState.error = String(e)
|
||||
trackingState.error = String(e);
|
||||
} 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) {
|
||||
await getAdapter().linkTracker(mangaId, trackerId, remoteId)
|
||||
await loadTrackers()
|
||||
await getAdapter().linkTracker(mangaId, trackerId, remoteId);
|
||||
await loadTrackers();
|
||||
}
|
||||
|
||||
export async function syncTracking(mangaId: string) {
|
||||
trackingState.syncing = true
|
||||
trackingState.syncing = true;
|
||||
try {
|
||||
await getAdapter().syncTracking(mangaId)
|
||||
await getAdapter().syncTracking(mangaId);
|
||||
} finally {
|
||||
trackingState.syncing = false
|
||||
trackingState.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,46 +8,52 @@ import type {
|
||||
Page,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
} from '$lib/server-adapters/types';
|
||||
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types/index';
|
||||
import type {TrackRecord} from '$lib/types/tracking';
|
||||
|
||||
function notImplemented(): never {
|
||||
throw new Error('MokuAdapter: not implemented')
|
||||
throw new Error('MokuAdapter: not implemented');
|
||||
}
|
||||
|
||||
export class MokuAdapter implements ServerAdapter {
|
||||
async connect(_config: ServerConfig): Promise<void> { notImplemented() }
|
||||
async getStatus(): Promise<ServerStatus> { return notImplemented() }
|
||||
async connect(_config: ServerConfig): Promise<void> {notImplemented();}
|
||||
async getStatus(): Promise<ServerStatus> {return notImplemented();}
|
||||
|
||||
async getManga(_id: string): Promise<Manga> { return notImplemented() }
|
||||
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
||||
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() }
|
||||
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() }
|
||||
async getManga(_id: string): Promise<Manga> {return notImplemented();}
|
||||
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
||||
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> {return notImplemented();}
|
||||
async addToLibrary(_mangaId: string): Promise<void> {notImplemented();}
|
||||
async removeFromLibrary(_mangaId: string): Promise<void> {notImplemented();}
|
||||
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> {notImplemented();}
|
||||
|
||||
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
|
||||
async getChapter(_id: string): Promise<Chapter> { return notImplemented() }
|
||||
async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() }
|
||||
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() }
|
||||
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() }
|
||||
async getChapters(_mangaId: string): Promise<Chapter[]> {return notImplemented();}
|
||||
async getChapter(_id: string): Promise<Chapter> {return notImplemented();}
|
||||
async getChapterPages(_id: string): Promise<Page[]> {return notImplemented();}
|
||||
async markChapterRead(_id: 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 enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async clearDownloads(): Promise<void> { notImplemented() }
|
||||
async getDownloads(): Promise<DownloadItem[]> {return notImplemented();}
|
||||
async enqueueDownload(_chapterId: string): Promise<void> {notImplemented();}
|
||||
async dequeueDownload(_chapterId: string): Promise<void> {notImplemented();}
|
||||
async clearDownloads(): Promise<void> {notImplemented();}
|
||||
|
||||
async getExtensions(): Promise<Extension[]> { return notImplemented() }
|
||||
async installExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async uninstallExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async updateExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async getExtensions(): Promise<Extension[]> {return notImplemented();}
|
||||
async installExtension(_id: string): Promise<void> {notImplemented();}
|
||||
async uninstallExtension(_id: string): Promise<void> {notImplemented();}
|
||||
async updateExtension(_id: string): Promise<void> {notImplemented();}
|
||||
|
||||
async getSources(): Promise<Source[]> { return notImplemented() }
|
||||
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
||||
async getSources(): Promise<Source[]> {return notImplemented();}
|
||||
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> { return notImplemented() }
|
||||
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() }
|
||||
async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async getTrackers(): Promise<Tracker[]> {return notImplemented();}
|
||||
async getTrackerRecords(): Promise<TrackRecord[]> {return 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();}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import type {
|
||||
Page,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
} from '$lib/server-adapters/types';
|
||||
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types/index';
|
||||
import type {TrackRecord} from '$lib/types/tracking';
|
||||
import {
|
||||
GET_LIBRARY,
|
||||
GET_MANGA,
|
||||
@@ -18,39 +19,43 @@ import {
|
||||
UPDATE_MANGA,
|
||||
SET_MANGA_META,
|
||||
UPDATE_LIBRARY,
|
||||
FETCH_SOURCE_MANGA,
|
||||
} from './manga'
|
||||
} from './manga';
|
||||
import {
|
||||
GET_CHAPTERS,
|
||||
FETCH_CHAPTERS,
|
||||
FETCH_CHAPTER_PAGES,
|
||||
MARK_CHAPTER_READ,
|
||||
MARK_CHAPTERS_READ,
|
||||
} from './chapters'
|
||||
UPDATE_CHAPTERS_PROGRESS,
|
||||
} from './chapters';
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS,
|
||||
ENQUEUE_DOWNLOAD,
|
||||
DEQUEUE_DOWNLOAD,
|
||||
CLEAR_DOWNLOADER,
|
||||
} from './downloads'
|
||||
FETCH_SOURCE_MANGA,
|
||||
} from './downloads';
|
||||
import {
|
||||
GET_EXTENSIONS,
|
||||
GET_SOURCES,
|
||||
FETCH_EXTENSIONS,
|
||||
UPDATE_EXTENSION,
|
||||
} from './extensions'
|
||||
} from './extensions';
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
BIND_TRACK,
|
||||
TRACK_PROGRESS,
|
||||
} from './tracking'
|
||||
LOGIN_TRACKER_OAUTH,
|
||||
LOGIN_TRACKER_CREDENTIALS,
|
||||
LOGOUT_TRACKER,
|
||||
} from './tracking';
|
||||
import {
|
||||
GQLResponse,
|
||||
mapManga,
|
||||
mapChapter,
|
||||
mapExtension,
|
||||
mapDownloadItem,
|
||||
} from './types'
|
||||
} from './types';
|
||||
import type {GQLResponse} from './types';
|
||||
|
||||
const GET_CHAPTER = `
|
||||
query GetChapter($id: Int!) {
|
||||
@@ -59,17 +64,17 @@ const GET_CHAPTER = `
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export class SuwayomiAdapter implements ServerAdapter {
|
||||
private baseUrl = 'http://127.0.0.1:4567'
|
||||
private authHeader: string | null = null
|
||||
private baseUrl = 'http://127.0.0.1:4567';
|
||||
private authHeader: string | null = null;
|
||||
|
||||
async connect(config: ServerConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
if (config.credentials) {
|
||||
const { username, password } = config.credentials
|
||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
|
||||
const {username, password} = config.credentials;
|
||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,154 +83,187 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||
})
|
||||
return res.ok ? 'connected' : 'error'
|
||||
body: JSON.stringify({query: '{ aboutServer { name } }'}),
|
||||
});
|
||||
return res.ok ? 'connected' : 'error';
|
||||
} catch {
|
||||
return 'disconnected'
|
||||
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
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
async getManga(id: string): Promise<Manga> {
|
||||
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
|
||||
return mapManga(data.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>> {
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
let items = data.mangas.nodes.map(mapManga)
|
||||
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.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0)
|
||||
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId)
|
||||
return { items, hasNextPage: false }
|
||||
const data = await this.gql<{mangas: {nodes: Record<string, unknown>[];};}>(GET_LIBRARY);
|
||||
let items = data.mangas.nodes.map(mapManga);
|
||||
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.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId);
|
||||
return {items, hasNextPage: false};
|
||||
}
|
||||
|
||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||
if (!sourceId) return []
|
||||
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)
|
||||
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 })
|
||||
await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: true});
|
||||
}
|
||||
|
||||
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>) {
|
||||
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) })
|
||||
if (value === undefined) continue;
|
||||
await this.gql(SET_MANGA_META, {mangaId: Number(id), key, value: String(value)});
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 data = await this.gql<{ chapter: Record<string, unknown> }>(
|
||||
GET_CHAPTER, { id: Number(id) }
|
||||
)
|
||||
return mapChapter(data.chapter)
|
||||
const data = await this.gql<{chapter: Record<string, unknown>;}>(
|
||||
GET_CHAPTER, {id: Number(id)}
|
||||
);
|
||||
return mapChapter(data.chapter);
|
||||
}
|
||||
|
||||
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 }))
|
||||
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 })
|
||||
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) {
|
||||
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[]> {
|
||||
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
|
||||
const data = await this.gql<{downloadStatus: {queue: Record<string, unknown>[];};}>(
|
||||
GET_DOWNLOAD_STATUS
|
||||
)
|
||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
||||
);
|
||||
return data.downloadStatus.queue.map(mapDownloadItem);
|
||||
}
|
||||
|
||||
async enqueueDownload(chapterId: string) {
|
||||
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
await this.gql(ENQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
|
||||
}
|
||||
|
||||
async dequeueDownload(chapterId: string) {
|
||||
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
await this.gql(DEQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
|
||||
}
|
||||
|
||||
async clearDownloads() {
|
||||
await this.gql(CLEAR_DOWNLOADER)
|
||||
await this.gql(CLEAR_DOWNLOADER);
|
||||
}
|
||||
|
||||
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)
|
||||
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 })
|
||||
await this.gql(UPDATE_EXTENSION, {id, install: true});
|
||||
}
|
||||
|
||||
async uninstallExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
|
||||
await this.gql(UPDATE_EXTENSION, {id, uninstall: true});
|
||||
}
|
||||
|
||||
async updateExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, update: true })
|
||||
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
|
||||
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 })
|
||||
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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> {
|
||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||
return data.trackers.nodes
|
||||
const data = await this.gql<{trackers: {nodes: Tracker[];};}>(GET_TRACKERS);
|
||||
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) {
|
||||
@@ -233,25 +271,25 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
mangaId: Number(mangaId),
|
||||
trackerId: Number(trackerId),
|
||||
remoteId,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
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[]> {
|
||||
if (mangaIds?.length) {
|
||||
const results: UpdateResult[] = []
|
||||
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 })
|
||||
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
|
||||
return results;
|
||||
}
|
||||
await this.gql(UPDATE_LIBRARY)
|
||||
return []
|
||||
await this.gql(UPDATE_LIBRARY);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,17 @@ export const GET_TRACKERS = `
|
||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||
scores
|
||||
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 = `
|
||||
query GetMangaTrackRecords($mangaId: Int!) {
|
||||
@@ -22,7 +29,7 @@ export const GET_MANGA_TRACK_RECORDS = `
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export const SEARCH_TRACKER = `
|
||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||
@@ -33,7 +40,7 @@ export const SEARCH_TRACKER = `
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
@@ -41,7 +48,7 @@ export const BIND_TRACK = `
|
||||
trackRecord { id trackerId remoteId }
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export const TRACK_PROGRESS = `
|
||||
mutation TrackProgress($mangaId: Int!) {
|
||||
@@ -49,7 +56,7 @@ export const TRACK_PROGRESS = `
|
||||
trackRecords { id trackerId lastChapterRead status }
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export const UPDATE_TRACK = `
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export const UNLINK_TRACK = `
|
||||
mutation UnlinkTrack($trackRecordId: Int!) {
|
||||
@@ -73,7 +80,7 @@ export const UNLINK_TRACK = `
|
||||
trackRecord { id }
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||
@@ -81,7 +88,15 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
isLoggedIn
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_OAUTH = `
|
||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||
isLoggedIn
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGOUT_TRACKER = `
|
||||
mutation LogoutTracker($trackerId: Int!) {
|
||||
@@ -89,4 +104,4 @@ export const LOGOUT_TRACKER = `
|
||||
isLoggedIn
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
@@ -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'
|
||||
|
||||
export interface GQLResponse<T> {
|
||||
|
||||
@@ -4,91 +4,97 @@ import type {
|
||||
Extension,
|
||||
Source,
|
||||
Tracker,
|
||||
} from '$lib/types'
|
||||
} from '$lib/types/index';
|
||||
import type {TrackRecord} from '$lib/types/tracking';
|
||||
|
||||
export interface ServerConfig {
|
||||
baseUrl: string
|
||||
credentials?: { username: string; password: string }
|
||||
baseUrl: string;
|
||||
credentials?: {username: string; password: string;};
|
||||
}
|
||||
|
||||
export type ServerStatus = 'connected' | 'disconnected' | 'error'
|
||||
export type ServerStatus = 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export interface MangaFilters {
|
||||
inLibrary?: boolean
|
||||
status?: MangaStatus
|
||||
tags?: string[]
|
||||
unread?: boolean
|
||||
sourceId?: string
|
||||
inLibrary?: boolean;
|
||||
status?: MangaStatus;
|
||||
tags?: string[];
|
||||
unread?: boolean;
|
||||
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> {
|
||||
items: T[]
|
||||
hasNextPage: boolean
|
||||
total?: number
|
||||
items: T[];
|
||||
hasNextPage: boolean;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface MangaMeta {
|
||||
customTitle?: string
|
||||
customCover?: string
|
||||
notes?: string
|
||||
[key: string]: unknown
|
||||
customTitle?: string;
|
||||
customCover?: string;
|
||||
notes?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
index: number
|
||||
url: string
|
||||
imageData?: string
|
||||
index: number;
|
||||
url: string;
|
||||
imageData?: string;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
chapterId: string
|
||||
mangaId: string
|
||||
chapterName: string
|
||||
mangaTitle: string
|
||||
progress: number
|
||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
||||
chapterId: string;
|
||||
mangaId: string;
|
||||
chapterName: string;
|
||||
mangaTitle: string;
|
||||
progress: number;
|
||||
state: 'queued' | 'downloading' | 'finished' | 'error';
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
mangaId: string
|
||||
newChapters: number
|
||||
mangaId: string;
|
||||
newChapters: number;
|
||||
}
|
||||
|
||||
export interface ServerAdapter {
|
||||
connect(config: ServerConfig): Promise<void>
|
||||
getStatus(): Promise<ServerStatus>
|
||||
connect(config: ServerConfig): Promise<void>;
|
||||
getStatus(): Promise<ServerStatus>;
|
||||
|
||||
getManga(id: string): Promise<Manga>
|
||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||
addToLibrary(mangaId: string): Promise<void>
|
||||
removeFromLibrary(mangaId: string): Promise<void>
|
||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
|
||||
getManga(id: string): Promise<Manga>;
|
||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>;
|
||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>;
|
||||
addToLibrary(mangaId: string): Promise<void>;
|
||||
removeFromLibrary(mangaId: string): Promise<void>;
|
||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>;
|
||||
|
||||
getChapters(mangaId: string): Promise<Chapter[]>
|
||||
getChapter(id: string): Promise<Chapter>
|
||||
getChapterPages(id: string): Promise<Page[]>
|
||||
markChapterRead(id: string, read: boolean): Promise<void>
|
||||
markChaptersRead(ids: string[], read: boolean): Promise<void>
|
||||
getChapters(mangaId: string): Promise<Chapter[]>;
|
||||
getChapter(id: string): Promise<Chapter>;
|
||||
getChapterPages(id: string): Promise<Page[]>;
|
||||
markChapterRead(id: string, read: boolean): Promise<void>;
|
||||
updateChapterProgress(id: string, lastPageRead: number, read?: boolean): Promise<void>;
|
||||
markChaptersRead(ids: string[], read: boolean): Promise<void>;
|
||||
|
||||
getDownloads(): Promise<DownloadItem[]>
|
||||
enqueueDownload(chapterId: string): Promise<void>
|
||||
dequeueDownload(chapterId: string): Promise<void>
|
||||
clearDownloads(): Promise<void>
|
||||
getDownloads(): Promise<DownloadItem[]>;
|
||||
enqueueDownload(chapterId: string): Promise<void>;
|
||||
dequeueDownload(chapterId: string): Promise<void>;
|
||||
clearDownloads(): Promise<void>;
|
||||
|
||||
getExtensions(): Promise<Extension[]>
|
||||
installExtension(id: string): Promise<void>
|
||||
uninstallExtension(id: string): Promise<void>
|
||||
updateExtension(id: string): Promise<void>
|
||||
getExtensions(): Promise<Extension[]>;
|
||||
installExtension(id: string): Promise<void>;
|
||||
uninstallExtension(id: string): Promise<void>;
|
||||
updateExtension(id: string): Promise<void>;
|
||||
|
||||
getSources(): Promise<Source[]>
|
||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
||||
getSources(): Promise<Source[]>;
|
||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>;
|
||||
|
||||
getTrackers(): Promise<Tracker[]>
|
||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
||||
syncTracking(mangaId: string): Promise<void>
|
||||
getTrackers(): Promise<Tracker[]>;
|
||||
getTrackerRecords(): Promise<TrackRecord[]>;
|
||||
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[]>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
|
||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error';
|
||||
|
||||
export const appState = $state({
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
version: '',
|
||||
})
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
version: '',
|
||||
idle: false,
|
||||
});
|
||||
@@ -5,12 +5,24 @@ export const downloadsState = $state({
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
export const activeDownloads = $derived(
|
||||
const activeDownloadsValue = $derived(
|
||||
downloadsState.items.filter(d => d.state === 'downloading')
|
||||
)
|
||||
|
||||
export const queuedDownloads = $derived(
|
||||
const queuedDownloadsValue = $derived(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
items: [] as Extension[],
|
||||
@@ -16,21 +18,25 @@ export const extensionsState = $state({
|
||||
browseLoading: false,
|
||||
browseError: null as string | null,
|
||||
browseHasMore: false,
|
||||
})
|
||||
});
|
||||
|
||||
export const filteredExtensions = $derived.by(() => {
|
||||
let result = extensionsState.items
|
||||
const filteredExtensionsValue = $derived.by(() => {
|
||||
let result = extensionsState.items;
|
||||
|
||||
if (extensionsState.filter.installed) {
|
||||
result = result.filter(e => e.installed)
|
||||
result = result.filter(e => e.isInstalled);
|
||||
}
|
||||
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) {
|
||||
const q = extensionsState.filter.query.toLowerCase()
|
||||
result = result.filter(e => e.name.toLowerCase().includes(q))
|
||||
const q = extensionsState.filter.query.toLowerCase();
|
||||
result = result.filter(e => e.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
return result;
|
||||
});
|
||||
|
||||
export function filteredExtensions() {
|
||||
return filteredExtensionsValue;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Manga } from '$lib/types'
|
||||
import type { MangaStatus } from '$lib/server-adapters/types'
|
||||
import type {Manga} from '$lib/types/index';
|
||||
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({
|
||||
items: [] as Manga[],
|
||||
@@ -18,36 +20,42 @@ export const libraryState = $state({
|
||||
sortDesc: false,
|
||||
view: 'grid' as 'grid' | 'list',
|
||||
selected: new Set<string>(),
|
||||
})
|
||||
});
|
||||
|
||||
export const filteredItems = $derived.by(() => {
|
||||
let result = libraryState.items
|
||||
const filteredItemsValue = $derived.by(() => {
|
||||
let result = libraryState.items;
|
||||
|
||||
result = result.filter(m => !shouldHideNsfw(m, settingsState));
|
||||
|
||||
if (libraryState.filter.unread) {
|
||||
result = result.filter(m => m.unreadCount > 0)
|
||||
result = result.filter(m => (m.unreadCount ?? 0) > 0);
|
||||
}
|
||||
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) {
|
||||
result = result.filter(m =>
|
||||
libraryState.filter.tags.every(tag => m.tags?.includes(tag))
|
||||
)
|
||||
);
|
||||
}
|
||||
if (libraryState.filter.query) {
|
||||
const q = libraryState.filter.query.toLowerCase()
|
||||
result = result.filter(m => m.title.toLowerCase().includes(q))
|
||||
const q = libraryState.filter.query.toLowerCase();
|
||||
result = result.filter(m => m.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
const sorted = [...result].sort((a, b) => {
|
||||
switch (libraryState.sort) {
|
||||
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0)
|
||||
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0)
|
||||
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0)
|
||||
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
|
||||
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0);
|
||||
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Manga, Chapter } from '$lib/types'
|
||||
import type { Page } from '$lib/server-adapters/types'
|
||||
import type {Manga, Chapter} from '$lib/types/index';
|
||||
import type {Page} from '$lib/server-adapters/types';
|
||||
|
||||
export type ReadMode = 'single' | 'strip'
|
||||
export type FitMode = 'width' | 'height' | 'original'
|
||||
export type ReadDirection = 'ltr' | 'rtl'
|
||||
export type ReadMode = 'single' | 'strip';
|
||||
export type FitMode = 'width' | 'height' | 'original';
|
||||
export type ReadDirection = 'ltr' | 'rtl';
|
||||
|
||||
export const readerState = $state({
|
||||
manga: null as Manga | null,
|
||||
@@ -20,22 +20,47 @@ export const readerState = $state({
|
||||
direction: 'ltr' as ReadDirection,
|
||||
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,
|
||||
showSettings: false,
|
||||
fullscreen: false,
|
||||
})
|
||||
});
|
||||
|
||||
export const currentPageData = $derived(
|
||||
const currentPageDataValue = $derived(
|
||||
readerState.pages[readerState.currentPage] ?? null
|
||||
)
|
||||
);
|
||||
|
||||
export const progress = $derived(
|
||||
const progressValue = $derived(
|
||||
readerState.pages.length > 0
|
||||
? (readerState.currentPage + 1) / readerState.pages.length
|
||||
: 0
|
||||
)
|
||||
);
|
||||
|
||||
export const hasPrev = $derived(readerState.currentPage > 0)
|
||||
export const hasNext = $derived(
|
||||
const hasPrevValue = $derived(readerState.currentPage > 0);
|
||||
const hasNextValue = $derived(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Manga, Chapter } from '$lib/types'
|
||||
import type { Manga, Chapter } from '$lib/types/index'
|
||||
|
||||
export const seriesState = $state({
|
||||
current: null as Manga | null,
|
||||
@@ -17,7 +17,7 @@ export const seriesState = $state({
|
||||
chapterSortDesc: true,
|
||||
})
|
||||
|
||||
export const filteredChapters = $derived.by(() => {
|
||||
const filteredChaptersValue = $derived.by(() => {
|
||||
let result = seriesState.chapters
|
||||
|
||||
if (seriesState.chapterFilter.unread) {
|
||||
@@ -34,3 +34,7 @@ export const filteredChapters = $derived.by(() => {
|
||||
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
|
||||
return seriesState.chapterSortDesc ? sorted.reverse() : sorted
|
||||
})
|
||||
|
||||
export function filteredChapters() {
|
||||
return filteredChaptersValue
|
||||
}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { Tracker } from '$lib/types'
|
||||
import type { Tracker } from '$lib/types/index'
|
||||
|
||||
export const trackingState = $state({
|
||||
trackers: [] as Tracker[],
|
||||
|
||||
+28
-539
@@ -1,539 +1,28 @@
|
||||
import type {
|
||||
ServerAdapter,
|
||||
ServerConfig,
|
||||
ServerStatus,
|
||||
MangaFilters,
|
||||
MangaMeta,
|
||||
PaginatedResult,
|
||||
Page,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
|
||||
// ─── GQL client ────────────────────────────────────────────────────────────
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T
|
||||
errors?: { message: string }[]
|
||||
}
|
||||
|
||||
// ─── Queries ────────────────────────────────────────────────────────────────
|
||||
|
||||
const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||
description status author artist genre inLibraryAt lastFetchedAt
|
||||
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 []
|
||||
}
|
||||
}
|
||||
export type { Settings, MangaPrefs } from './settings';
|
||||
|
||||
export type { Manga, MangaDetail, Category, ChapterRef } from './manga';
|
||||
export type { Chapter } from './chapter';
|
||||
export type { Extension, Source } from './extension';
|
||||
export type { Tracker, TrackRecord, TrackerStatus } from './tracking';
|
||||
|
||||
export type {
|
||||
DownloadQueueItem,
|
||||
DownloadStatus,
|
||||
Connection,
|
||||
PageInfo,
|
||||
PaginatedConnection,
|
||||
MetaEntry,
|
||||
UpdaterJobsInfo,
|
||||
UpdateStatus,
|
||||
AboutServer,
|
||||
ServerUpdateEntry,
|
||||
} from './api';
|
||||
export type {
|
||||
HistoryEntry,
|
||||
BookmarkEntry,
|
||||
MarkerColor,
|
||||
MarkerEntry,
|
||||
ReadLogEntry,
|
||||
ReadingStats,
|
||||
LibraryUpdateEntry,
|
||||
} from './history';
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface Manga {
|
||||
lastReadChapter?: ChapterRef | null
|
||||
firstUnreadChapter?: 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 {
|
||||
|
||||
+62
-19
@@ -1,12 +1,13 @@
|
||||
import type { Keybinds } from "$lib/core/keybinds/defaultBinds";
|
||||
import type {Keybinds} from "$lib/core/keybinds/defaultBinds";
|
||||
|
||||
export type PageStyle = "single" | "double" | "longstrip";
|
||||
export type FitMode = "width" | "height" | "screen" | "original";
|
||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||
export type PageStyle = "single" | "double" | "longstrip";
|
||||
export type FitMode = "width" | "height" | "screen" | "original";
|
||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||
export type ReadingDirection = "ltr" | "rtl";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||
export type ContentLevel = "strict" | "moderate" | "unrestricted";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||
export type ContentLevel = "strict" | "moderate" | "unrestricted";
|
||||
export type CloseAction = "ask" | "tray" | "quit";
|
||||
|
||||
export type LibrarySortMode =
|
||||
| "az" | "unreadCount" | "totalChapters"
|
||||
@@ -14,11 +15,11 @@ export type LibrarySortMode =
|
||||
|
||||
export type LibrarySortDir = "asc" | "desc";
|
||||
|
||||
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
||||
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
||||
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked";
|
||||
|
||||
export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm";
|
||||
export type Theme = BuiltinTheme | string;
|
||||
export type Theme = BuiltinTheme | string;
|
||||
|
||||
export interface ThemeTokens {
|
||||
"bg-void": string;
|
||||
@@ -98,6 +99,16 @@ export interface MangaPrefs {
|
||||
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 = {
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
@@ -113,20 +124,30 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
autoDownloadScanlators: [],
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTOMATION_DEFAULTS: AutomationDefaults = {
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
maxKeepChapters: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: "weekly",
|
||||
};
|
||||
|
||||
export interface ReaderSettings {
|
||||
pageStyle: PageStyle;
|
||||
fitMode: FitMode;
|
||||
readingDirection: ReadingDirection;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
pageStyle: PageStyle;
|
||||
fitMode: FitMode;
|
||||
readingDirection: ReadingDirection;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
barPosition?: "top" | "left" | "right";
|
||||
barPosition?: "top" | "left" | "right";
|
||||
}
|
||||
|
||||
export interface ReaderPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string;
|
||||
name: string;
|
||||
settings: ReaderSettings;
|
||||
}
|
||||
|
||||
@@ -135,6 +156,8 @@ export interface Settings {
|
||||
readingDirection: ReadingDirection;
|
||||
fitMode: FitMode;
|
||||
readerZoom: number;
|
||||
overlayBars: boolean;
|
||||
tapToToggleBar: boolean;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
@@ -147,6 +170,8 @@ export interface Settings {
|
||||
sourceOverridesEnabled: boolean;
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
libraryShowAllInSaved: boolean;
|
||||
libraryHideCompletedInSaved: boolean;
|
||||
discordRpc: boolean;
|
||||
chapterSortDir: ChapterSortDir;
|
||||
chapterSortMode: ChapterSortMode;
|
||||
@@ -154,6 +179,7 @@ export interface Settings {
|
||||
uiZoom: number;
|
||||
compactSidebar: boolean;
|
||||
gpuAcceleration: boolean;
|
||||
closeAction: CloseAction;
|
||||
serverUrl: string;
|
||||
serverBinary: string;
|
||||
serverBinaryArgs: string;
|
||||
@@ -168,6 +194,9 @@ export interface Settings {
|
||||
readerDebounceMs: number;
|
||||
autoBookmark: boolean;
|
||||
theme: Theme;
|
||||
systemThemeSync: boolean;
|
||||
systemThemeDark: Theme;
|
||||
systemThemeLight: Theme;
|
||||
libraryBranches: boolean;
|
||||
renderLimit: number;
|
||||
heroSlots: (number | null)[];
|
||||
@@ -194,7 +223,7 @@ export interface Settings {
|
||||
hiddenCategoryIds: number[];
|
||||
defaultLibraryCategoryId: number | null;
|
||||
savedIsDefaultCategory: boolean;
|
||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||
libraryTabSort: Record<string, {mode: LibrarySortMode; dir: LibrarySortDir;}>;
|
||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||
maxPageWidth?: number;
|
||||
@@ -220,6 +249,9 @@ export interface Settings {
|
||||
autoScroll?: boolean;
|
||||
autoScrollSpeed?: number;
|
||||
disableAutoComplete: boolean;
|
||||
automationEnabled: boolean;
|
||||
automationEnforceGlobal: boolean;
|
||||
automationDefaults: AutomationDefaults;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -227,6 +259,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
readingDirection: "ltr",
|
||||
fitMode: "width",
|
||||
readerZoom: 1.0,
|
||||
overlayBars: false,
|
||||
tapToToggleBar: false,
|
||||
pageGap: true,
|
||||
optimizeContrast: false,
|
||||
offsetDoubleSpreads: false,
|
||||
@@ -239,6 +273,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
sourceOverridesEnabled: false,
|
||||
nsfwAllowedSourceIds: [],
|
||||
nsfwBlockedSourceIds: [],
|
||||
libraryShowAllInSaved: true,
|
||||
libraryHideCompletedInSaved: false,
|
||||
discordRpc: false,
|
||||
chapterSortDir: "desc",
|
||||
chapterSortMode: "source",
|
||||
@@ -246,6 +282,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
uiZoom: 1.0,
|
||||
compactSidebar: false,
|
||||
gpuAcceleration: true,
|
||||
closeAction: "ask",
|
||||
serverUrl: "http://localhost:4567",
|
||||
serverBinary: "",
|
||||
serverBinaryArgs: "",
|
||||
@@ -260,6 +297,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
readerDebounceMs: 120,
|
||||
autoBookmark: true,
|
||||
theme: "dark",
|
||||
systemThemeSync: false,
|
||||
systemThemeDark: "dark",
|
||||
systemThemeLight: "light",
|
||||
libraryBranches: true,
|
||||
renderLimit: 48,
|
||||
heroSlots: [null, null, null, null],
|
||||
@@ -309,4 +349,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
autoScroll: false,
|
||||
autoScrollSpeed: 5,
|
||||
disableAutoComplete: false,
|
||||
automationEnabled: false,
|
||||
automationEnforceGlobal: false,
|
||||
automationDefaults: DEFAULT_AUTOMATION_DEFAULTS,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import {
|
||||
House, Books, MagnifyingGlass, ClockCounterClockwise,
|
||||
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
|
||||
House, Books, MagnifyingGlass,
|
||||
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp, ClockCounterClockwise,
|
||||
} from 'phosphor-svelte'
|
||||
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
|
||||
|
||||
@@ -14,6 +13,7 @@
|
||||
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple },
|
||||
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
|
||||
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp },
|
||||
{ path: '/history', label: 'History', icon: ClockCounterClockwise },
|
||||
] as const
|
||||
|
||||
const TAB_SIZE = 36
|
||||
@@ -27,33 +27,45 @@
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<nav class="nav">
|
||||
{#if activeIndex >= 0}
|
||||
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
|
||||
{/if}
|
||||
{#each TABS as tab}
|
||||
<button
|
||||
{#each TABS as tab (tab.path)}
|
||||
<a
|
||||
class="tab"
|
||||
class:active={activeIndex === TABS.indexOf(tab)}
|
||||
class:active={isActive(tab.path)}
|
||||
title={tab.label}
|
||||
onclick={() => goto(tab.path)}
|
||||
href={tab.path}
|
||||
aria-current={isActive(tab.path) ? 'page' : undefined}
|
||||
>
|
||||
<tab.icon size={18} weight="light" />
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<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" />
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -80,6 +92,7 @@
|
||||
margin-bottom: var(--sp-4);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||
.logo:active { transform: scale(0.92); }
|
||||
@@ -140,6 +153,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab:active { transform: scale(0.88); }
|
||||
@@ -167,7 +181,9 @@
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
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:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.settings-btn.active { color: var(--accent-fg); background: var(--accent-muted); transform: none; }
|
||||
</style>
|
||||
|
||||
@@ -7,19 +7,37 @@
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
let os: OsKind = $state('unknown')
|
||||
let os = $state<OsKind>('unknown')
|
||||
let isFullscreen = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
if (!isTauri) return
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
const win = getCurrentWindow()
|
||||
os = await detectOs()
|
||||
isFullscreen = await win.isFullscreen()
|
||||
const unlisten = await win.onResized(async () => {
|
||||
|
||||
let disposed = false
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
void (async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
const win = getCurrentWindow()
|
||||
os = await detectOs()
|
||||
isFullscreen = await win.isFullscreen()
|
||||
})
|
||||
return unlisten
|
||||
|
||||
const stop = await win.onResized(async () => {
|
||||
isFullscreen = await win.isFullscreen()
|
||||
})
|
||||
|
||||
if (disposed) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
unlisten = stop
|
||||
})()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unlisten?.()
|
||||
}
|
||||
})
|
||||
|
||||
const isMac = $derived(os === 'macos')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+293
-23
@@ -1,6 +1,17 @@
|
||||
<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 { settingsState, updateSettings } from '$lib/state/settings.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 AuthGate from '$lib/ui/chrome/AuthGate.svelte'
|
||||
import Sidebar from '$lib/ui/chrome/Sidebar.svelte'
|
||||
@@ -10,17 +21,78 @@
|
||||
|
||||
let { children } = $props()
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const ringFull = $derived(appState.status !== 'booting')
|
||||
|
||||
let splashVisible = $state(true)
|
||||
let bypassed = $state(false)
|
||||
let closeDialogOpen = $state(false)
|
||||
|
||||
const showApp = $derived(
|
||||
appState.status === 'ready' ||
|
||||
appState.status === 'auth' ||
|
||||
bypassed
|
||||
)
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const pathname = $derived($page.url.pathname as string)
|
||||
const hideShellChrome = $derived(pathname === '/reader' || pathname.startsWith('/reader/'))
|
||||
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() {
|
||||
splashVisible = false
|
||||
@@ -30,38 +102,188 @@
|
||||
bypassed = true
|
||||
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>
|
||||
|
||||
{#if splashVisible}
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
{#if showSplash && splashVisible}
|
||||
<SplashScreen
|
||||
mode="loading"
|
||||
{ringFull}
|
||||
failed={appState.status === 'error'}
|
||||
showCards={splashCards}
|
||||
onReady={onSplashReady}
|
||||
onBypass={onSplashBypass}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showApp}
|
||||
<div class="frame">
|
||||
<div class="shell">
|
||||
{#if isTauri}
|
||||
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
|
||||
{/if}
|
||||
<div class="body">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
{@render children()}
|
||||
</main>
|
||||
{#if showShell}
|
||||
{#if hideShellChrome}
|
||||
<main class="reader-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
{:else}
|
||||
<div class="frame">
|
||||
<div class="shell">
|
||||
{#if isTauri}
|
||||
<TitleBar onClose={handleClose} />
|
||||
{/if}
|
||||
<div class="body">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showAuthGate}
|
||||
<AuthGate />
|
||||
{/if}
|
||||
<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}
|
||||
|
||||
<AuthGate />
|
||||
<Toaster toasts={notificationsState.toasts} />
|
||||
|
||||
<style>
|
||||
.frame {
|
||||
display: flex;
|
||||
@@ -99,4 +321,52 @@
|
||||
contain: layout style;
|
||||
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>
|
||||
@@ -1,2 +1,2 @@
|
||||
export const ssr = false
|
||||
export const prerender = true
|
||||
// export const prerender = true
|
||||
|
||||
+300
-1
@@ -1,3 +1,302 @@
|
||||
<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'
|
||||
|
||||
</script>
|
||||
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>
|
||||
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user