mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d74790c3a0 | |||
| 0e93908bb2 | |||
| 074147f64f | |||
| f91b46cfa5 | |||
| 71ee4052f3 | |||
| 5e2114810e | |||
| b3fca70f27 | |||
| 68f25a2ea7 | |||
| 3d6b6430ed | |||
| 54307d4411 | |||
| f8f080eff3 | |||
| f41f8a9c22 | |||
| 8cef79b2b4 | |||
| 6c39ef538f | |||
| 081becdd60 | |||
| c891cb349c |
@@ -6,6 +6,7 @@ dist-tauri/
|
||||
target/
|
||||
bin/
|
||||
out/
|
||||
notes/
|
||||
|
||||
.direnv/
|
||||
result
|
||||
|
||||
+12
-1
@@ -22,12 +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": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
"@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
+171
-20
@@ -8,25 +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(@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)
|
||||
@@ -38,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==}
|
||||
|
||||
@@ -471,6 +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==}
|
||||
|
||||
@@ -480,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==}
|
||||
|
||||
@@ -503,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'}
|
||||
@@ -550,6 +625,9 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -687,6 +765,15 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
phosphor-svelte@3.1.0:
|
||||
resolution: {integrity: sha512-nldtxx+XCgNREvrb7O5xgDsefytXpSkPTx8Rnu3f2qQCUZLDV1rLxYSd2Jcwckuo9lZB1qKMqGR17P4UDC0PrA==}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0 || ^5.0.0-next.96
|
||||
vite: '>=5'
|
||||
peerDependenciesMeta:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -764,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}
|
||||
@@ -820,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
|
||||
@@ -1034,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
|
||||
@@ -1062,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': {}
|
||||
|
||||
@@ -1124,6 +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
|
||||
@@ -1133,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': {}
|
||||
@@ -1146,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
|
||||
@@ -1176,6 +1313,10 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -1270,6 +1411,14 @@ 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(@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(@types/node@25.9.1)
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
@@ -1397,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
|
||||
@@ -1410,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
+50
-1
@@ -1,4 +1,53 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
const __APP_VERSION__: string
|
||||
}
|
||||
export {};
|
||||
|
||||
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 {}
|
||||
+96
-24
@@ -1,31 +1,59 @@
|
||||
import { initRequestManager } from '$lib/request-manager'
|
||||
import { initPlatformService } from '$lib/platform-service'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
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';
|
||||
|
||||
interface SavedAuth {
|
||||
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';
|
||||
}
|
||||
|
||||
function loadSavedAuth(): SavedAuth {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? {mode: 'NONE'};
|
||||
} catch {
|
||||
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() {
|
||||
@@ -33,18 +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()
|
||||
appState.status = 'ready'
|
||||
initRequestManager(serverAdapter);
|
||||
initPlatformService(platformAdapter);
|
||||
|
||||
await Promise.all([
|
||||
initSettingsState(),
|
||||
initHistoryState(),
|
||||
]);
|
||||
|
||||
// appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web';
|
||||
appState.platform = isTauri() ? 'tauri' : 'web';
|
||||
appState.version = await platformAdapter.getVersion();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
updateSettings({
|
||||
serverUrl: savedUrl,
|
||||
serverAuthMode: savedAuth.mode,
|
||||
serverAuthUser: savedAuth.user ?? '',
|
||||
serverAuthPass: savedAuth.pass ?? '',
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (probe === 'unreachable') {
|
||||
appState.error = `Could not reach server at ${savedUrl}`;
|
||||
appState.status = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
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,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#091209"/>
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="translate(256,265) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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,388 @@
|
||||
export type AuthMode = 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(msg = 'Authentication required') {
|
||||
super(msg)
|
||||
this.name = 'AuthRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'moku_access_token'
|
||||
const UI_SESSION_KEY = 'moku_ui_auth_session'
|
||||
const REFRESH_SKEW_MS = 30_000
|
||||
|
||||
interface StoredToken {
|
||||
base: string
|
||||
token: string
|
||||
}
|
||||
|
||||
interface UiSession {
|
||||
base: string
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
clientMutationId?: string
|
||||
accessExpiresAt?: number | null
|
||||
refreshExpiresAt?: number | null
|
||||
}
|
||||
|
||||
interface JwtSettings {
|
||||
jwtAudience?: string | null
|
||||
jwtRefreshExpiry?: string | null
|
||||
jwtTokenExpiry?: string | null
|
||||
}
|
||||
|
||||
let _session: UiSession | null = null
|
||||
let _accessToken: string | null = null
|
||||
let _accessTokenBase: string | null = null
|
||||
let _refreshPromise: Promise<string | null> | null = null
|
||||
let _jwtSettings: JwtSettings | null = null
|
||||
let _jwtSettingsBase: string | null = null
|
||||
let _jwtSettingsFetchedAt = 0
|
||||
|
||||
let _serverBase = 'http://127.0.0.1:4567'
|
||||
let _authMode: AuthMode = 'NONE'
|
||||
let _basicUser = ''
|
||||
let _basicPass = ''
|
||||
|
||||
export function configureAuth(base: string, mode: AuthMode, user = '', pass = '') {
|
||||
_serverBase = base.replace(/\/$/, '')
|
||||
_authMode = mode
|
||||
_basicUser = user
|
||||
_basicPass = pass
|
||||
}
|
||||
|
||||
export function getServerBase(): string {
|
||||
return _serverBase
|
||||
}
|
||||
|
||||
export function getAuthMode(): AuthMode {
|
||||
return _authMode
|
||||
}
|
||||
|
||||
function timeoutSignal(ms: number): AbortSignal {
|
||||
return AbortSignal.timeout(ms)
|
||||
}
|
||||
|
||||
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||
return JSON.stringify({ query, variables })
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: 'Basic ' + btoa(`${user}:${pass}`) }
|
||||
}
|
||||
|
||||
function bearerHeader(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
function parseIsoDuration(d: string): number | null {
|
||||
const m = d.match(/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/)
|
||||
if (!m) return null
|
||||
let ms = 0
|
||||
if (m[1]) ms += +m[1] * 365.25 * 86400000
|
||||
if (m[2]) ms += +m[2] * 30.44 * 86400000
|
||||
if (m[3]) ms += +m[3] * 86400000
|
||||
if (m[4]) ms += +m[4] * 3600000
|
||||
if (m[5]) ms += +m[5] * 60000
|
||||
if (m[6]) ms += parseFloat(m[6]) * 1000
|
||||
return ms
|
||||
}
|
||||
|
||||
function decodeJwtExpiry(token: string): number | null {
|
||||
try {
|
||||
const part = token.split('.')[1]
|
||||
if (!part) return null
|
||||
const pad = part.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = JSON.parse(atob(pad.padEnd(pad.length + ((4 - pad.length % 4) % 4), '='))) as { exp?: number }
|
||||
return typeof json.exp === 'number' ? json.exp * 1000 : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function isExpired(at?: number | null, skew = REFRESH_SKEW_MS): boolean {
|
||||
if (!at || !Number.isFinite(at)) return false
|
||||
return Date.now() >= at - skew
|
||||
}
|
||||
|
||||
function readStoredSession(): UiSession | null {
|
||||
try { return JSON.parse(sessionStorage.getItem(UI_SESSION_KEY) ?? 'null') } catch { return null }
|
||||
}
|
||||
|
||||
function readStoredToken(): StoredToken | null {
|
||||
try { return JSON.parse(sessionStorage.getItem(TOKEN_KEY) ?? 'null') } catch { return null }
|
||||
}
|
||||
|
||||
export const uiAuth = {
|
||||
getSession(): UiSession | null {
|
||||
if (_session?.base === _serverBase) return _session
|
||||
const stored = readStoredSession()
|
||||
if (!stored || stored.base !== _serverBase) {
|
||||
sessionStorage.removeItem(UI_SESSION_KEY)
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
_session = _accessToken = _accessTokenBase = null
|
||||
return null
|
||||
}
|
||||
_session = stored
|
||||
_accessToken = stored.accessToken
|
||||
_accessTokenBase = stored.base
|
||||
return _session
|
||||
},
|
||||
|
||||
setSession(session: Omit<UiSession, 'base'>) {
|
||||
_session = { ...session, base: _serverBase }
|
||||
_accessToken = session.accessToken
|
||||
_accessTokenBase = _serverBase
|
||||
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_session))
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
},
|
||||
|
||||
getToken(): string | null {
|
||||
const s = uiAuth.getSession()
|
||||
if (!s || isExpired(s.accessExpiresAt, 0)) return null
|
||||
if (_accessToken && _accessTokenBase === _serverBase) return _accessToken
|
||||
const stored = readStoredToken()
|
||||
if (!stored || stored.base !== _serverBase) {
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
_accessToken = _accessTokenBase = null
|
||||
return null
|
||||
}
|
||||
_accessToken = stored.token
|
||||
_accessTokenBase = stored.base
|
||||
return _accessToken
|
||||
},
|
||||
|
||||
setToken(t: string) {
|
||||
const existing = uiAuth.getSession()
|
||||
if (existing?.refreshToken) {
|
||||
uiAuth.setSession({ ...existing, accessToken: t, ...expiryFromJwt(t, _jwtSettings) })
|
||||
return
|
||||
}
|
||||
_accessToken = t
|
||||
_accessTokenBase = _serverBase
|
||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base: _serverBase, token: t }))
|
||||
},
|
||||
|
||||
setLoginSession(
|
||||
payload: { accessToken: string; refreshToken: string; clientMutationId?: string },
|
||||
jwt: JwtSettings | null,
|
||||
) {
|
||||
uiAuth.setSession({
|
||||
accessToken: payload.accessToken,
|
||||
refreshToken: payload.refreshToken,
|
||||
clientMutationId: payload.clientMutationId,
|
||||
...expiryFromJwt(payload.accessToken, jwt),
|
||||
})
|
||||
},
|
||||
|
||||
updateAccessToken(
|
||||
payload: { accessToken: string; clientMutationId?: string },
|
||||
jwt: JwtSettings | null,
|
||||
) {
|
||||
const s = uiAuth.getSession()
|
||||
if (!s) return
|
||||
uiAuth.setSession({
|
||||
...s,
|
||||
accessToken: payload.accessToken,
|
||||
clientMutationId: payload.clientMutationId ?? s.clientMutationId,
|
||||
...expiryFromJwt(payload.accessToken, jwt),
|
||||
})
|
||||
},
|
||||
|
||||
clearToken() {
|
||||
_session = _accessToken = _accessTokenBase = null
|
||||
sessionStorage.removeItem(UI_SESSION_KEY)
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
},
|
||||
}
|
||||
|
||||
function expiryFromJwt(token: string, jwt: JwtSettings | null) {
|
||||
const now = Date.now()
|
||||
return {
|
||||
accessExpiresAt: decodeJwtExpiry(token) ?? (jwt?.jwtTokenExpiry ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null),
|
||||
refreshExpiresAt: jwt?.jwtRefreshExpiry ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJwtSettings(): Promise<JwtSettings | null> {
|
||||
try {
|
||||
const res = await fetchAuthenticated(`${_serverBase}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: gqlBody(`query { settings { jwtAudience jwtRefreshExpiry jwtTokenExpiry } }`),
|
||||
}, timeoutSignal(5000))
|
||||
if (!res.ok) return null
|
||||
const json = await res.json()
|
||||
const s = json?.data?.settings
|
||||
if (!s) return null
|
||||
return {
|
||||
jwtAudience: s.jwtAudience ?? null,
|
||||
jwtRefreshExpiry: s.jwtRefreshExpiry ?? null,
|
||||
jwtTokenExpiry: s.jwtTokenExpiry ?? null,
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
|
||||
const fresh = Date.now() - _jwtSettingsFetchedAt < 60_000
|
||||
if (!force && _jwtSettingsBase === _serverBase && _jwtSettings && fresh) return _jwtSettings
|
||||
_jwtSettings = await fetchJwtSettings()
|
||||
_jwtSettingsBase = _serverBase
|
||||
_jwtSettingsFetchedAt = Date.now()
|
||||
return _jwtSettings
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const baseHeaders = { ...(init.headers as Record<string, string> ?? {}) }
|
||||
|
||||
if (_authMode === 'BASIC_AUTH') {
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: 'omit',
|
||||
headers: { ...baseHeaders, ...(_basicUser && _basicPass ? basicHeader(_basicUser, _basicPass) : {}) },
|
||||
})
|
||||
}
|
||||
|
||||
if (_authMode === 'UI_LOGIN') {
|
||||
const token = await getUIAccessToken()
|
||||
if (!token) throw new AuthRequiredError()
|
||||
|
||||
let res = await fetch(url, {
|
||||
...init, signal, credentials: 'omit',
|
||||
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||
})
|
||||
|
||||
if (res.status !== 401) return res
|
||||
|
||||
const refreshed = await refreshUiAccessToken(true)
|
||||
if (!refreshed) return res
|
||||
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: 'omit',
|
||||
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
|
||||
})
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal, credentials: 'omit' })
|
||||
}
|
||||
|
||||
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
|
||||
const s = uiAuth.getSession()
|
||||
if (!s) return null
|
||||
if (forceRefresh || isExpired(s.accessExpiresAt)) return refreshUiAccessToken(true)
|
||||
return s.accessToken
|
||||
}
|
||||
|
||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||
const s = uiAuth.getSession()
|
||||
if (!s) return null
|
||||
if (!s.refreshToken) {
|
||||
if (force && isExpired(s.accessExpiresAt, 0)) return null
|
||||
return s.accessToken
|
||||
}
|
||||
if (!force && !isExpired(s.accessExpiresAt)) return s.accessToken
|
||||
if (isExpired(s.refreshExpiresAt)) { uiAuth.clearToken(); return null }
|
||||
if (_refreshPromise) return _refreshPromise
|
||||
|
||||
_refreshPromise = (async () => {
|
||||
const jwt = await getJwtSettings().catch(() => null)
|
||||
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||
method: 'POST', credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: gqlBody(
|
||||
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||
accessToken clientMutationId
|
||||
}
|
||||
}`,
|
||||
{ refreshToken: s.refreshToken, clientMutationId: s.clientMutationId },
|
||||
),
|
||||
signal: timeoutSignal(5000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) { uiAuth.clearToken(); return null }
|
||||
throw new Error(`Token refresh failed (${res.status})`)
|
||||
}
|
||||
const json = await res.json()
|
||||
const refreshed = json?.data?.refreshToken
|
||||
const next: string | undefined = refreshed?.accessToken
|
||||
if (!next) { uiAuth.clearToken(); return null }
|
||||
uiAuth.updateAccessToken({ accessToken: next, clientMutationId: refreshed?.clientMutationId }, jwt)
|
||||
return next
|
||||
})().finally(() => { _refreshPromise = null })
|
||||
|
||||
return _refreshPromise
|
||||
}
|
||||
|
||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||
method: 'POST', credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: gqlBody(
|
||||
`mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken refreshToken clientMutationId
|
||||
}
|
||||
}`,
|
||||
{ username: user, password: pass },
|
||||
),
|
||||
signal: timeoutSignal(8000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`)
|
||||
const json = await res.json()
|
||||
const payload = json?.data?.login
|
||||
if (!payload?.accessToken || !payload?.refreshToken) {
|
||||
throw new Error(json?.errors?.[0]?.message ?? 'Login failed')
|
||||
}
|
||||
const jwt = await getJwtSettings(true).catch(() => null)
|
||||
uiAuth.setLoginSession({
|
||||
accessToken: payload.accessToken,
|
||||
refreshToken: payload.refreshToken,
|
||||
clientMutationId: typeof payload.clientMutationId === 'string' ? payload.clientMutationId : undefined,
|
||||
}, jwt)
|
||||
_authMode = 'UI_LOGIN'
|
||||
_basicUser = user
|
||||
_basicPass = ''
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||
method: 'POST', credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json', ...basicHeader(user, pass) },
|
||||
body: gqlBody('{ __typename }'),
|
||||
signal: timeoutSignal(5000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`)
|
||||
_authMode = 'BASIC_AUTH'
|
||||
_basicUser = user
|
||||
_basicPass = pass
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
uiAuth.clearToken()
|
||||
_authMode = 'NONE'
|
||||
_basicUser = ''
|
||||
_basicPass = ''
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (_authMode === 'BASIC_AUTH' && _basicUser && _basicPass) {
|
||||
Object.assign(headers, basicHeader(_basicUser, _basicPass))
|
||||
} else if (_authMode === 'UI_LOGIN') {
|
||||
const token = await getUIAccessToken()
|
||||
if (!token) return 'auth_required'
|
||||
Object.assign(headers, bearerHeader(token))
|
||||
}
|
||||
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||
method: 'POST', credentials: 'omit', headers,
|
||||
body: gqlBody('{ __typename }'),
|
||||
signal: timeoutSignal(5000),
|
||||
})
|
||||
if (res.ok) return 'ok'
|
||||
if (res.status === 401) return 'auth_required'
|
||||
return 'unreachable'
|
||||
} catch { return 'unreachable' }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
export class CapacitorAdapter implements PlatformAdapter {
|
||||
async init() {}
|
||||
|
||||
isSupported(feature: PlatformFeature): boolean {
|
||||
const supported: PlatformFeature[] = ['biometric-auth', 'filesystem']
|
||||
return supported.includes(feature)
|
||||
}
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const result = await Filesystem.readFile({ path, directory: Directory.Data })
|
||||
const base64 = result.data as string
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array): Promise<void> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const binary = String.fromCharCode(...data)
|
||||
const base64 = btoa(binary)
|
||||
await Filesystem.writeFile({ path, data: base64, directory: Directory.Data })
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
await NativeBiometric.verifyIdentity({ reason: 'Authenticate to access Moku', title: 'Biometric Auth' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async storeCredential(key: string, value: string): Promise<void> {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
await NativeBiometric.setCredentials({ username: key, password: value, server: 'moku' })
|
||||
}
|
||||
|
||||
async getCredential(key: string): Promise<string | null> {
|
||||
try {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
const result = await NativeBiometric.getCredentials({ server: 'moku' })
|
||||
return result.username === key ? result.password : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async setTitle(_title: string) {}
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const { App } = await import('@capacitor/app')
|
||||
const info = await App.getInfo()
|
||||
return info.version
|
||||
}
|
||||
|
||||
async openExternal(url: string): Promise<void> {
|
||||
const { Browser } = await import('@capacitor/browser')
|
||||
await Browser.open({ url })
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async installAppUpdate(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
export class TauriAdapter implements PlatformAdapter {
|
||||
async init() {
|
||||
await invoke('init_app')
|
||||
}
|
||||
|
||||
isSupported(feature: PlatformFeature): boolean {
|
||||
const supported: PlatformFeature[] = [
|
||||
'server-management',
|
||||
'biometric-auth',
|
||||
'native-window',
|
||||
'filesystem',
|
||||
'app-updates',
|
||||
'discord-rpc',
|
||||
]
|
||||
return supported.includes(feature)
|
||||
}
|
||||
|
||||
async launchServer(config: ServerLaunchConfig) {
|
||||
await invoke('launch_server', { config })
|
||||
}
|
||||
|
||||
async stopServer() {
|
||||
await invoke('stop_server')
|
||||
}
|
||||
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return invoke('get_server_status')
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
return readFile(path)
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array) {
|
||||
await writeFile(path, data)
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
const result = await open({ directory: true, multiple: false })
|
||||
return typeof result === 'string' ? result : null
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return invoke('authenticate_biometric')
|
||||
}
|
||||
|
||||
async storeCredential(key: string, value: string) {
|
||||
await invoke('store_credential', { key, value })
|
||||
}
|
||||
|
||||
async getCredential(key: string): Promise<string | null> {
|
||||
return invoke('get_credential', { key })
|
||||
}
|
||||
|
||||
async setTitle(title: string) {
|
||||
await invoke('set_window_title', { title })
|
||||
}
|
||||
|
||||
async minimize() {
|
||||
await invoke('minimize_window')
|
||||
}
|
||||
|
||||
async maximize() {
|
||||
await invoke('maximize_window')
|
||||
}
|
||||
|
||||
async close() {
|
||||
await invoke('close_window')
|
||||
}
|
||||
|
||||
async setDiscordPresence(presence: DiscordPresence) {
|
||||
await invoke('set_discord_presence', { presence })
|
||||
}
|
||||
|
||||
async clearDiscordPresence() {
|
||||
await invoke('clear_discord_presence')
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
return getVersion()
|
||||
}
|
||||
|
||||
async openExternal(url: string) {
|
||||
await openUrl(url)
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
const update = await check()
|
||||
if (!update?.available) return null
|
||||
return {
|
||||
version: update.version,
|
||||
url: update.body ?? '',
|
||||
notes: update.body,
|
||||
}
|
||||
}
|
||||
|
||||
async installAppUpdate() {
|
||||
const update = await check()
|
||||
if (update?.available) {
|
||||
await update.downloadAndInstall()
|
||||
await relaunch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type PlatformFeature =
|
||||
| 'server-management'
|
||||
| 'biometric-auth'
|
||||
| 'native-window'
|
||||
| 'filesystem'
|
||||
| 'app-updates'
|
||||
| 'discord-rpc'
|
||||
|
||||
export interface ServerLaunchConfig {
|
||||
jarPath: string
|
||||
port: number
|
||||
dataPath: string
|
||||
}
|
||||
|
||||
export interface DiscordPresence {
|
||||
title: string
|
||||
chapter: string
|
||||
startTimestamp?: number
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
version: string
|
||||
url: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
init(): Promise<void>
|
||||
isSupported(feature: PlatformFeature): boolean
|
||||
|
||||
launchServer(config: ServerLaunchConfig): Promise<void>
|
||||
stopServer(): Promise<void>
|
||||
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
|
||||
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
|
||||
authenticateBiometric(): Promise<boolean>
|
||||
storeCredential(key: string, value: string): Promise<void>
|
||||
getCredential(key: string): Promise<string | null>
|
||||
|
||||
setTitle(title: string): Promise<void>
|
||||
minimize(): Promise<void>
|
||||
maximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
|
||||
setDiscordPresence(presence: DiscordPresence): Promise<void>
|
||||
clearDiscordPresence(): Promise<void>
|
||||
|
||||
getVersion(): Promise<string>
|
||||
openExternal(url: string): Promise<void>
|
||||
checkForAppUpdate(): Promise<AppUpdateInfo | null>
|
||||
installAppUpdate(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
export class WebAdapter implements PlatformAdapter {
|
||||
async init() {}
|
||||
|
||||
isSupported(_feature: PlatformFeature): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
async readFile(_path: string): Promise<Uint8Array> {
|
||||
return new Uint8Array()
|
||||
}
|
||||
|
||||
async writeFile(_path: string, _data: Uint8Array) {}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async storeCredential(_key: string, _value: string) {}
|
||||
|
||||
async getCredential(_key: string): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async setTitle(title: string) {
|
||||
document.title = title
|
||||
}
|
||||
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
return __APP_VERSION__
|
||||
}
|
||||
|
||||
async openExternal(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async installAppUpdate() {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
let adapter: PlatformAdapter
|
||||
|
||||
export function initPlatformService(a: PlatformAdapter) {
|
||||
adapter = a
|
||||
}
|
||||
|
||||
function getAdapter(): PlatformAdapter {
|
||||
if (!adapter) throw new Error('PlatformService not initialized')
|
||||
return adapter
|
||||
}
|
||||
|
||||
export function isSupported(feature: PlatformFeature): boolean {
|
||||
return getAdapter().isSupported(feature)
|
||||
}
|
||||
|
||||
export function launchServer(config: ServerLaunchConfig) {
|
||||
return getAdapter().launchServer(config)
|
||||
}
|
||||
|
||||
export function stopServer() {
|
||||
return getAdapter().stopServer()
|
||||
}
|
||||
|
||||
export function getServerStatus() {
|
||||
return getAdapter().getServerStatus()
|
||||
}
|
||||
|
||||
export function readFile(path: string) {
|
||||
return getAdapter().readFile(path)
|
||||
}
|
||||
|
||||
export function writeFile(path: string, data: Uint8Array) {
|
||||
return getAdapter().writeFile(path, data)
|
||||
}
|
||||
|
||||
export function pickFolder() {
|
||||
return getAdapter().pickFolder()
|
||||
}
|
||||
|
||||
export function authenticateBiometric() {
|
||||
return getAdapter().authenticateBiometric()
|
||||
}
|
||||
|
||||
export function storeCredential(key: string, value: string) {
|
||||
return getAdapter().storeCredential(key, value)
|
||||
}
|
||||
|
||||
export function getCredential(key: string) {
|
||||
return getAdapter().getCredential(key)
|
||||
}
|
||||
|
||||
export function setTitle(title: string) {
|
||||
return getAdapter().setTitle(title)
|
||||
}
|
||||
|
||||
export function minimize() {
|
||||
return getAdapter().minimize()
|
||||
}
|
||||
|
||||
export function maximize() {
|
||||
return getAdapter().maximize()
|
||||
}
|
||||
|
||||
export function close() {
|
||||
return getAdapter().close()
|
||||
}
|
||||
|
||||
export function setDiscordPresence(presence: DiscordPresence) {
|
||||
return getAdapter().setDiscordPresence(presence)
|
||||
}
|
||||
|
||||
export function clearDiscordPresence() {
|
||||
return getAdapter().clearDiscordPresence()
|
||||
}
|
||||
|
||||
export function getVersion() {
|
||||
return getAdapter().getVersion()
|
||||
}
|
||||
|
||||
export function openExternal(url: string) {
|
||||
return getAdapter().openExternal(url)
|
||||
}
|
||||
|
||||
export function checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return getAdapter().checkForAppUpdate()
|
||||
}
|
||||
|
||||
export function installAppUpdate() {
|
||||
return getAdapter().installAppUpdate()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ServerAdapter } from '$lib/server-adapters/types'
|
||||
|
||||
let adapter: ServerAdapter
|
||||
|
||||
export function initRequestManager(a: ServerAdapter) {
|
||||
adapter = a
|
||||
}
|
||||
|
||||
export function getAdapter(): ServerAdapter {
|
||||
if (!adapter) throw new Error('RequestManager not initialized')
|
||||
return adapter
|
||||
}
|
||||
@@ -47,12 +47,12 @@ export async function addToLibrary(mangaId: string) {
|
||||
|
||||
export async function removeFromLibrary(mangaId: string) {
|
||||
await getAdapter().removeFromLibrary(mangaId)
|
||||
libraryState.items = libraryState.items.filter(m => m.id !== mangaId)
|
||||
libraryState.items = libraryState.items.filter(m => String(m.id) !== mangaId)
|
||||
}
|
||||
|
||||
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||
await getAdapter().updateMangaMeta(id, meta)
|
||||
if (seriesState.current?.id === id) {
|
||||
if (String(seriesState.current?.id) === id) {
|
||||
await loadManga(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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/index';
|
||||
import type {TrackRecord} from '$lib/types/tracking';
|
||||
|
||||
function notImplemented(): never {
|
||||
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 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 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 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 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();}
|
||||
}
|
||||
@@ -8,310 +8,73 @@ 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,
|
||||
GET_CATEGORIES,
|
||||
FETCH_MANGA,
|
||||
UPDATE_MANGA,
|
||||
SET_MANGA_META,
|
||||
UPDATE_LIBRARY,
|
||||
} from './manga';
|
||||
import {
|
||||
GET_CHAPTERS,
|
||||
FETCH_CHAPTERS,
|
||||
FETCH_CHAPTER_PAGES,
|
||||
MARK_CHAPTER_READ,
|
||||
MARK_CHAPTERS_READ,
|
||||
UPDATE_CHAPTERS_PROGRESS,
|
||||
} from './chapters';
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS,
|
||||
ENQUEUE_DOWNLOAD,
|
||||
DEQUEUE_DOWNLOAD,
|
||||
CLEAR_DOWNLOADER,
|
||||
FETCH_SOURCE_MANGA,
|
||||
} from './downloads';
|
||||
import {
|
||||
GET_EXTENSIONS,
|
||||
GET_SOURCES,
|
||||
FETCH_EXTENSIONS,
|
||||
UPDATE_EXTENSION,
|
||||
} from './extensions';
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
BIND_TRACK,
|
||||
TRACK_PROGRESS,
|
||||
LOGIN_TRACKER_OAUTH,
|
||||
LOGIN_TRACKER_CREDENTIALS,
|
||||
LOGOUT_TRACKER,
|
||||
} from './tracking';
|
||||
import {
|
||||
mapManga,
|
||||
mapChapter,
|
||||
mapExtension,
|
||||
mapDownloadItem,
|
||||
} from './types';
|
||||
import type {GQLResponse} from './types';
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T
|
||||
errors?: { message: string }[]
|
||||
}
|
||||
|
||||
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_CHAPTER = `
|
||||
query GetChapter($id: Int!) {
|
||||
chapter(id: $id) {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,172 +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>> {
|
||||
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 }
|
||||
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 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)
|
||||
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>[] }
|
||||
}>(GET_DOWNLOAD_STATUS)
|
||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
||||
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) })
|
||||
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) {
|
||||
@@ -493,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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
export const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn isTokenExpired authUrl
|
||||
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!) {
|
||||
manga(id: $mangaId) {
|
||||
trackRecords {
|
||||
nodes {
|
||||
id trackerId remoteId title status score displayScore
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SEARCH_TRACKER = `
|
||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||
trackSearches {
|
||||
id trackerId remoteId title coverUrl summary
|
||||
publishingStatus publishingType startDate totalChapters trackingUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||
trackRecord { id trackerId remoteId }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TRACK_PROGRESS = `
|
||||
mutation TrackProgress($mangaId: Int!) {
|
||||
trackProgress(input: { mangaId: $mangaId }) {
|
||||
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) {
|
||||
updateTrack(input: {
|
||||
recordId: $recordId
|
||||
status: $status
|
||||
score: $score
|
||||
lastChapterRead: $lastChapterRead
|
||||
startDate: $startDate
|
||||
finishDate: $finishDate
|
||||
private: $private
|
||||
}) {
|
||||
trackRecord { id status score lastChapterRead }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UNLINK_TRACK = `
|
||||
mutation UnlinkTrack($trackRecordId: Int!) {
|
||||
unlinkTrack(input: { trackRecordId: $trackRecordId }) {
|
||||
trackRecord { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||
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!) {
|
||||
logoutTracker(input: { trackerId: $trackerId }) {
|
||||
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,10 +1,12 @@
|
||||
export type AppStatus = 'booting' | '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: '',
|
||||
})
|
||||
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,103 @@
|
||||
<script lang="ts">
|
||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { loginUI, loginBasic, configureAuth } from '$lib/core/auth'
|
||||
|
||||
let loginUser = $state('')
|
||||
let loginPass = $state('')
|
||||
let loginBusy = $state(false)
|
||||
let loginError = $state<string | null>(null)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginUser.trim() || !loginPass.trim()) return
|
||||
loginBusy = true
|
||||
loginError = null
|
||||
try {
|
||||
if (appState.authMode === 'UI_LOGIN') {
|
||||
await loginUI(loginUser.trim(), loginPass.trim())
|
||||
} else {
|
||||
await loginBasic(loginUser.trim(), loginPass.trim())
|
||||
}
|
||||
appState.authenticated = true
|
||||
appState.status = 'ready'
|
||||
} catch (e) {
|
||||
loginError = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loginBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleBypass() {
|
||||
appState.authenticated = false
|
||||
appState.status = 'ready'
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if appState.status === 'auth'}
|
||||
<div class="overlay">
|
||||
<div class="card anim-scale-in">
|
||||
<img src={logoUrl} alt="Moku" class="logo" />
|
||||
<p class="title">moku</p>
|
||||
<span class="mode-badge">
|
||||
{appState.authMode === 'UI_LOGIN' ? 'UI Login' : 'Basic Auth'}
|
||||
</span>
|
||||
<p class="host">{appState.serverUrl || 'localhost:4567'}</p>
|
||||
|
||||
{#if loginError}
|
||||
<p class="error">{loginError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="fields">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
bind:value={loginUser}
|
||||
disabled={loginBusy}
|
||||
autocomplete="username"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
bind:value={loginPass}
|
||||
disabled={loginBusy}
|
||||
autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
onclick={handleLogin}
|
||||
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}
|
||||
>
|
||||
{loginBusy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={handleBypass}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
|
||||
.card { pointer-events:auto; width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; }
|
||||
|
||||
.logo { width:56px; height:56px; border-radius:14px; display:block; }
|
||||
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
||||
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
||||
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
||||
.error { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--color-error); background:var(--color-error-bg); border:1px solid var(--color-error); border-radius:var(--radius-sm); padding:var(--sp-2) var(--sp-3); width:100%; box-sizing:border-box; }
|
||||
|
||||
.fields { display:flex; flex-direction:column; gap:var(--sp-2); width:100%; }
|
||||
.input { width:100%; background:var(--bg-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); padding:8px 12px; font-size:var(--text-sm); color:var(--text-primary); outline:none; box-sizing:border-box; transition:border-color var(--t-base), box-shadow var(--t-base); font-family:inherit; }
|
||||
.input:focus { border-color:var(--border-focus); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
.input:disabled { opacity:0.5; }
|
||||
|
||||
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
|
||||
.btn:hover:not(:disabled) { opacity:0.85; }
|
||||
.btn:disabled { opacity:0.35; cursor:default; }
|
||||
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
||||
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import {
|
||||
House, Books, MagnifyingGlass,
|
||||
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp, ClockCounterClockwise,
|
||||
} from 'phosphor-svelte'
|
||||
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
|
||||
|
||||
const TABS = [
|
||||
{ path: '/', label: 'Home', icon: House },
|
||||
{ path: '/library', label: 'Library', icon: Books },
|
||||
{ path: '/browse', label: 'Browse', icon: MagnifyingGlass },
|
||||
{ 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
|
||||
const TAB_GAP = 4
|
||||
|
||||
const activeIndex = $derived(
|
||||
TABS.findIndex(t => {
|
||||
if (t.path === '/') return $page.url.pathname === '/'
|
||||
return $page.url.pathname.startsWith(t.path)
|
||||
})
|
||||
)
|
||||
|
||||
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">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<nav class="nav">
|
||||
{#if activeIndex >= 0}
|
||||
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
|
||||
{/if}
|
||||
{#each TABS as tab (tab.path)}
|
||||
<a
|
||||
class="tab"
|
||||
class:active={isActive(tab.path)}
|
||||
title={tab.label}
|
||||
href={tab.path}
|
||||
aria-current={isActive(tab.path) ? 'page' : undefined}
|
||||
>
|
||||
<tab.icon size={18} weight="light" />
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="bottom">
|
||||
<a
|
||||
class="settings-btn"
|
||||
class:active={isActive('/settings')}
|
||||
href="/settings"
|
||||
title="Settings"
|
||||
aria-current={isActive('/settings') ? 'page' : undefined}
|
||||
>
|
||||
<GearSix size={18} weight="light" />
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--sp-4) 0;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--border-dim);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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); }
|
||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.logo-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: var(--accent);
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
filter: drop-shadow(0 0 8px rgba(107,143,107,0.35));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 0 var(--sp-2);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-muted);
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
z-index: 0;
|
||||
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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); }
|
||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.tab.active { color: var(--accent-fg); }
|
||||
.tab.active:hover { background: transparent; }
|
||||
|
||||
.bottom {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-2) 0;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
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>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||
import { mountCardCanvas, ringGeometry, animateRingProgress } from '$lib/ui/chrome/splashCanvas'
|
||||
|
||||
interface Props {
|
||||
mode?: 'loading' | 'idle'
|
||||
ringFull?: boolean
|
||||
failed?: boolean
|
||||
notConfigured?: boolean
|
||||
showCards?: boolean
|
||||
onReady?: () => void
|
||||
onRetry?: () => void
|
||||
onBypass?: () => void
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
mode = 'loading',
|
||||
ringFull = false,
|
||||
failed = false,
|
||||
notConfigured = false,
|
||||
showCards = true,
|
||||
onReady,
|
||||
onRetry,
|
||||
onBypass,
|
||||
onDismiss,
|
||||
}: Props = $props()
|
||||
|
||||
const EXIT_MS = 320
|
||||
const RING_R = 70
|
||||
const RING_PAD = 12
|
||||
const { size: ringSize, c: ringC, circ: ringCirc } = ringGeometry(RING_R, RING_PAD)
|
||||
|
||||
const LOGO_LOADING = 140
|
||||
const LOGO_IDLE = 128
|
||||
|
||||
let dots = $state('')
|
||||
let ringProg = $state(0.025)
|
||||
let exiting = $state(false)
|
||||
let exitLock = false
|
||||
let pinEntry = $state('')
|
||||
let pinShake = $state(false)
|
||||
let pinVisible = $state(false)
|
||||
let pinUnlocked = $state(false)
|
||||
|
||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999))
|
||||
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock) return
|
||||
exitLock = true
|
||||
exiting = true
|
||||
setTimeout(() => cb?.(), EXIT_MS)
|
||||
}
|
||||
|
||||
function submitPin(correctPin: string) {
|
||||
if (pinEntry === correctPin) {
|
||||
pinUnlocked = true
|
||||
pinEntry = ''
|
||||
if (mode === 'idle') triggerExit(onDismiss)
|
||||
} else {
|
||||
pinShake = true
|
||||
pinEntry = ''
|
||||
setTimeout(() => (pinShake = false), 500)
|
||||
}
|
||||
}
|
||||
|
||||
function onPinKey(e: KeyboardEvent, correctPin: string, pinLen: number) {
|
||||
if (e.key === 'Enter') { submitPin(correctPin); return }
|
||||
if (e.key === 'Backspace') { pinEntry = pinEntry.slice(0, -1); return }
|
||||
if (/^\d$/.test(e.key)) {
|
||||
pinEntry = (pinEntry + e.key).slice(0, 8)
|
||||
if (pinEntry.length >= pinLen) submitPin(correctPin)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!ringFull) {
|
||||
exitLock = false
|
||||
exiting = false
|
||||
return
|
||||
}
|
||||
if (failed || notConfigured) return
|
||||
triggerExit(onReady)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (pinUnlocked && mode !== 'idle') triggerExit(onReady)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const stopDots = setInterval(() => {
|
||||
dots = dots.length >= 3 ? '' : dots + '.'
|
||||
}, 420)
|
||||
|
||||
if (mode === 'loading' && !failed && !notConfigured) {
|
||||
const stopAnim = animateRingProgress(p => (ringProg = p))
|
||||
return () => { clearInterval(stopDots); stopAnim() }
|
||||
}
|
||||
|
||||
if (mode === 'idle' && onDismiss) {
|
||||
const handler = () => triggerExit(onDismiss)
|
||||
const t = setTimeout(() => {
|
||||
window.addEventListener('keydown', handler, { once: true })
|
||||
window.addEventListener('mousedown', handler, { once: true })
|
||||
window.addEventListener('touchstart', handler, { once: true })
|
||||
}, 200)
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
clearInterval(stopDots)
|
||||
window.removeEventListener('keydown', handler)
|
||||
window.removeEventListener('mousedown', handler)
|
||||
window.removeEventListener('touchstart', handler)
|
||||
}
|
||||
}
|
||||
|
||||
return () => clearInterval(stopDots)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="splash" class:exiting>
|
||||
{#if showCards}
|
||||
<canvas
|
||||
style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%"
|
||||
use:mountCardCanvas
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'idle'}
|
||||
<div class="center">
|
||||
<div class="logo-wrap" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;margin-bottom:32px">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;border-radius:28px" />
|
||||
</div>
|
||||
<p class="hint">press any key to continue</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
||||
{#if !failed && !notConfigured}
|
||||
<svg
|
||||
width={ringSize}
|
||||
height={ringSize}
|
||||
class="ring"
|
||||
class:ring-hide={pinVisible}
|
||||
style="position:absolute;top:0;left:0;pointer-events:none"
|
||||
>
|
||||
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--border-base)" stroke-width="2"/>
|
||||
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{ringArc} {ringCirc}"
|
||||
transform="rotate(-90 {ringC} {ringC})"
|
||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<img src={logoUrl} alt="Moku" style="width:{LOGO_LOADING}px;height:{LOGO_LOADING}px;border-radius:32px;display:block;position:relative"/>
|
||||
</div>
|
||||
|
||||
<div class="bottom-area">
|
||||
<div class="status-slot" class:status-slot-hide={pinVisible}>
|
||||
{#if failed || notConfigured}
|
||||
<div class="error-box anim-fade-up">
|
||||
<p class="error-label">{failed ? 'Could not reach server' : 'Server not configured'}</p>
|
||||
<div class="error-actions">
|
||||
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both;
|
||||
}
|
||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||
|
||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||
|
||||
.center { z-index:1; display:flex; flex-direction:column; align-items:center; }
|
||||
|
||||
.logo-wrap { position:relative; }
|
||||
.logo-glow { position:absolute; inset:-20px; border-radius:50%; background:radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation:logoBreathe 4s ease-in-out infinite; }
|
||||
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; display:block; position:relative; }
|
||||
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
|
||||
|
||||
.ring { transition:opacity 0.5s ease; }
|
||||
.ring-hide { opacity:0; }
|
||||
|
||||
.bottom-area { display:flex; align-items:center; justify-content:center; min-height:48px; position:relative; }
|
||||
.status-slot { display:flex; align-items:center; justify-content:center; transition:opacity 0.35s ease; position:absolute; }
|
||||
.status-slot-hide { opacity:0; pointer-events:none; }
|
||||
.status-text { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.12em; margin:0; min-width:160px; text-align:center; }
|
||||
|
||||
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
|
||||
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
|
||||
.error-actions { display:flex; gap:6px; }
|
||||
.err-btn { padding:5px 14px; border-radius:var(--radius-md); border:1px solid var(--border-base); background:transparent; color:var(--text-muted); cursor:pointer; font-family:var(--font-ui); font-size:11px; letter-spacing:0.04em; transition:border-color 0.15s, color 0.15s; }
|
||||
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
|
||||
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
||||
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { detectOs } from '$lib/ui/chrome/titlebarOs'
|
||||
import type { OsKind } from '$lib/ui/chrome/titlebarOs'
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props()
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
let os = $state<OsKind>('unknown')
|
||||
let isFullscreen = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
if (!isTauri) return
|
||||
|
||||
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()
|
||||
|
||||
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')
|
||||
const isWindows = $derived(os === 'windows')
|
||||
|
||||
async function minimize() {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
getCurrentWindow().minimize()
|
||||
}
|
||||
|
||||
async function toggleMaximize() {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
getCurrentWindow().toggleMaximize()
|
||||
}
|
||||
|
||||
async function exitFullscreen() {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
getCurrentWindow().setFullscreen(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isFullscreen}
|
||||
<div class="bar" data-tauri-drag-region>
|
||||
{#if isMac}<div class="mac-spacer" data-tauri-drag-region></div>{/if}
|
||||
<span class="title" data-tauri-drag-region>Moku</span>
|
||||
{#if !isMac}
|
||||
<div class="controls">
|
||||
<button onclick={minimize} title="Minimize" aria-label="Minimize">
|
||||
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
<button onclick={toggleMaximize} title="Maximize" aria-label="Maximize">
|
||||
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isWindows}
|
||||
<div class="fullscreen-controls">
|
||||
<button onclick={exitFullscreen} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--titlebar-height);
|
||||
padding: 0 6px 0 var(--sp-4);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mac-spacer { width: 70px; flex-shrink: 0; }
|
||||
.title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.controls { display: flex; align-items: center; gap: 2px; }
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
button:hover { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
.close:hover { color: #fff; background: #c0392b; }
|
||||
|
||||
.fullscreen-controls {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.fullscreen-controls:hover { opacity: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { dismissToast } from '$lib/state/notifications.svelte'
|
||||
import type { Toast } from '$lib/state/notifications.svelte'
|
||||
|
||||
let { toasts }: { toasts: Toast[] } = $props()
|
||||
|
||||
const EXIT_MS = 280
|
||||
const leaving = new Set<string>()
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
let detail = $state<Toast | null>(null)
|
||||
|
||||
function schedule(t: Toast) {
|
||||
if (timers.has(t.id)) return
|
||||
const dur = t.duration ?? 3500
|
||||
if (dur === 0) return
|
||||
timers.set(t.id, setTimeout(() => dismiss(t.id), dur))
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
if (leaving.has(id)) return
|
||||
leaving.add(id)
|
||||
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id) }
|
||||
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`)
|
||||
if (!el) { finalize(id); return }
|
||||
el.style.setProperty('--exit-h', `${el.offsetHeight}px`)
|
||||
el.classList.add('leaving')
|
||||
setTimeout(() => finalize(id), EXIT_MS)
|
||||
}
|
||||
|
||||
function finalize(id: string) {
|
||||
leaving.delete(id)
|
||||
dismissToast(id)
|
||||
}
|
||||
|
||||
function openDetail(e: MouseEvent, t: Toast) {
|
||||
e.preventDefault()
|
||||
detail = t
|
||||
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id) }
|
||||
}
|
||||
|
||||
function onBackdropKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') detail = null
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const activeIds = new Set(toasts.map(t => t.id))
|
||||
toasts.forEach(schedule)
|
||||
for (const [id, timer] of timers) {
|
||||
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id) }
|
||||
}
|
||||
if (detail && !activeIds.has(detail.id)) detail = null
|
||||
})
|
||||
|
||||
const icons: Record<Toast['kind'], string> = {
|
||||
success: 'M20 6L9 17l-5-5',
|
||||
error: 'M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z',
|
||||
info: 'M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z',
|
||||
download: 'M12 3v13M7 11l5 5 5-5M5 21h14',
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if toasts.length}
|
||||
<div class="toaster" aria-live="polite">
|
||||
{#each toasts as t (t.id)}
|
||||
<button
|
||||
class="toast toast-{t.kind}"
|
||||
data-toast-id={t.id}
|
||||
aria-label="{t.message}{t.detail ? ': ' + t.detail : ''}"
|
||||
onclick={() => dismiss(t.id)}
|
||||
oncontextmenu={(e) => openDetail(e, t)}
|
||||
>
|
||||
<div class="accent-bar"></div>
|
||||
<span class="icon">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={icons[t.kind]}/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="body">
|
||||
<p class="message">{t.message}</p>
|
||||
<p class="sub">{t.detail ?? '\u00a0'}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if detail}
|
||||
<div
|
||||
class="detail-backdrop"
|
||||
role="presentation"
|
||||
onclick={() => (detail = null)}
|
||||
onkeydown={onBackdropKey}
|
||||
>
|
||||
<div
|
||||
class="detail-panel detail-{detail.kind}"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={detail.message}
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="detail-accent"></div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-header">
|
||||
<span class="detail-kind">{detail.kind}</span>
|
||||
<button class="detail-close" onclick={() => (detail = null)} aria-label="Close">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="detail-message">{detail.message}</p>
|
||||
{#if detail.detail}
|
||||
<pre class="detail-text">{detail.detail}</pre>
|
||||
{/if}
|
||||
<div class="detail-actions">
|
||||
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.message}${detail!.detail ? '\n' + detail!.detail : ''}`)}>
|
||||
Copy
|
||||
</button>
|
||||
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); detail = null }}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toaster { position:fixed; bottom:var(--sp-5); right:var(--sp-5); z-index:9999; display:flex; flex-direction:column; gap:5px; pointer-events:none; }
|
||||
|
||||
.toast {
|
||||
display:flex; align-items:center; gap:10px; padding:12px var(--sp-3) 12px 0;
|
||||
border-radius:var(--radius-md); background:var(--bg-raised); border:1px solid var(--border-dim);
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||
pointer-events:all; width:280px; overflow:hidden; cursor:pointer;
|
||||
font-family:inherit; font-size:inherit; color:inherit; text-align:left;
|
||||
will-change:transform, opacity;
|
||||
animation:slideIn 0.35s cubic-bezier(0.16,1,0.3,1) both;
|
||||
transition:border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.toast:hover { border-color:var(--border-base); box-shadow:0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset; transform:translateX(-3px); }
|
||||
.toast:active { transform:translateX(0) scale(0.98); }
|
||||
|
||||
:global(.toast.leaving) { animation:slideOut 0.28s cubic-bezier(0.4,0,1,1) forwards !important; pointer-events:none; }
|
||||
|
||||
@keyframes slideIn { from { opacity:0; transform:translateX(20px) scale(0.96) } to { opacity:1; transform:translateX(0) scale(1) } }
|
||||
@keyframes slideOut {
|
||||
0% { opacity:1; transform:translateX(0) scale(1); max-height:var(--exit-h,80px); margin-bottom:0; }
|
||||
40% { opacity:0; transform:translateX(14px) scale(0.96); max-height:var(--exit-h,80px); margin-bottom:0; }
|
||||
100% { opacity:0; transform:translateX(14px) scale(0.96); max-height:0; margin-bottom:-5px; }
|
||||
}
|
||||
|
||||
.accent-bar { width:3px; align-self:stretch; flex-shrink:0; border-radius:0 2px 2px 0; }
|
||||
.toast-success .accent-bar { background:var(--accent-fg); }
|
||||
.toast-error .accent-bar { background:var(--color-error); }
|
||||
.toast-info .accent-bar { background:var(--text-faint); }
|
||||
.toast-download .accent-bar { background:var(--accent-fg); }
|
||||
|
||||
.icon { flex-shrink:0; display:flex; align-items:center; justify-content:center; }
|
||||
.toast-success .icon { color:var(--accent-fg); }
|
||||
.toast-error .icon { color:var(--color-error); }
|
||||
.toast-info .icon { color:var(--text-muted); }
|
||||
.toast-download .icon { color:var(--accent-fg); }
|
||||
|
||||
.body { flex:1; min-width:0; display:flex; flex-direction:column; gap:5px; }
|
||||
.message { font-size:var(--text-xs); font-family:var(--font-ui); color:var(--text-secondary); font-weight:var(--weight-medium); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.sub { font-family:var(--font-ui); font-size:var(--text-2xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
.detail-backdrop { position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,0.45); display:flex; align-items:center; justify-content:center; animation:fadeIn 0.15s ease both; }
|
||||
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
|
||||
|
||||
.detail-panel { display:flex; width:420px; max-width:calc(100vw - 32px); max-height:60vh; border-radius:var(--radius-lg); background:var(--bg-raised); border:1px solid var(--border-base); box-shadow:0 24px 64px rgba(0,0,0,0.7), 0 1px 0 rgba(255,255,255,0.05) inset; overflow:hidden; animation:popIn 0.2s cubic-bezier(0.16,1,0.3,1) both; }
|
||||
@keyframes popIn { from { opacity:0; transform:scale(0.95) } to { opacity:1; transform:scale(1) } }
|
||||
|
||||
.detail-accent { width:3px; flex-shrink:0; }
|
||||
.detail-error .detail-accent { background:var(--color-error); }
|
||||
.detail-success .detail-accent { background:var(--accent-fg); }
|
||||
.detail-info .detail-accent { background:var(--text-faint); }
|
||||
.detail-download .detail-accent { background:var(--accent-fg); }
|
||||
|
||||
.detail-body { flex:1; min-width:0; display:flex; flex-direction:column; padding:var(--sp-3); gap:var(--sp-2); overflow:hidden; }
|
||||
.detail-header { display:flex; align-items:center; justify-content:space-between; }
|
||||
.detail-kind { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--text-faint); }
|
||||
.detail-error .detail-kind { color:var(--color-error); }
|
||||
|
||||
.detail-close { display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:var(--radius-sm); background:none; border:none; color:var(--text-faint); cursor:pointer; transition:color var(--t-fast), background var(--t-fast); }
|
||||
.detail-close:hover { color:var(--text-primary); background:var(--bg-overlay); }
|
||||
|
||||
.detail-message { font-family:var(--font-ui); font-size:var(--text-sm); color:var(--text-secondary); font-weight:var(--weight-medium); line-height:var(--leading-snug); word-break:break-word; }
|
||||
|
||||
.detail-text { flex:1; min-height:0; overflow-y:auto; font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-muted); line-height:var(--leading-base); white-space:pre-wrap; word-break:break-all; background:var(--bg-void); border:1px solid var(--border-dim); border-radius:var(--radius-sm); padding:var(--sp-2) var(--sp-3); scrollbar-width:thin; margin:0; }
|
||||
|
||||
.detail-actions { display:flex; gap:var(--sp-2); margin-top:var(--sp-1); }
|
||||
.detail-copy, .detail-dismiss { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wide); padding:5px var(--sp-3); border-radius:var(--radius-sm); cursor:pointer; transition:color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.detail-copy { border:1px solid var(--border-dim); background:none; color:var(--text-muted); }
|
||||
.detail-copy:hover { color:var(--text-primary); border-color:var(--border-strong); background:var(--bg-overlay); }
|
||||
.detail-dismiss { border:1px solid color-mix(in srgb, var(--color-error) 40%, transparent); background:color-mix(in srgb, var(--color-error) 10%, transparent); color:var(--color-error); }
|
||||
.detail-dismiss:hover { background:color-mix(in srgb, var(--color-error) 18%, transparent); }
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
const CARD_COUNT = 18
|
||||
const CARD_W = 52
|
||||
const CARD_H = 72
|
||||
const CARD_RADIUS = 6
|
||||
const DRIFT_SPEED = 0.018
|
||||
|
||||
interface Card {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
rot: number
|
||||
vrot: number
|
||||
opacity: number
|
||||
scale: number
|
||||
hue: number
|
||||
}
|
||||
|
||||
function makeCard(w: number, h: number): Card {
|
||||
const side = Math.floor(Math.random() * 4)
|
||||
const margin = 80
|
||||
let x = 0, y = 0
|
||||
if (side === 0) { x = Math.random() * w; y = -margin }
|
||||
if (side === 1) { x = w + margin; y = Math.random() * h }
|
||||
if (side === 2) { x = Math.random() * w; y = h + margin }
|
||||
if (side === 3) { x = -margin; y = Math.random() * h }
|
||||
const cx = w / 2, cy = h / 2
|
||||
const dx = cx - x, dy = cy - y
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const spd = 0.12 + Math.random() * 0.1
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
vx: (dx / len) * spd * (0.3 + Math.random() * 0.4),
|
||||
vy: (dy / len) * spd * (0.3 + Math.random() * 0.4),
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vrot: (Math.random() - 0.5) * 0.006,
|
||||
opacity: 0.025 + Math.random() * 0.055,
|
||||
scale: 0.7 + Math.random() * 0.7,
|
||||
hue: 120 + Math.random() * 40,
|
||||
}
|
||||
}
|
||||
|
||||
function drawCard(ctx: CanvasRenderingContext2D, c: Card) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = c.opacity
|
||||
ctx.translate(c.x, c.y)
|
||||
ctx.rotate(c.rot)
|
||||
ctx.scale(c.scale, c.scale)
|
||||
|
||||
const w = CARD_W, h = CARD_H, r = CARD_RADIUS
|
||||
const x = -w / 2, y = -h / 2
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.lineTo(x + w - r, y)
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
||||
ctx.lineTo(x + w, y + h - r)
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
||||
ctx.lineTo(x + r, y + h)
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
||||
ctx.lineTo(x, y + r)
|
||||
ctx.quadraticCurveTo(x, y, x + r, y)
|
||||
ctx.closePath()
|
||||
|
||||
ctx.strokeStyle = `hsla(${c.hue}, 28%, 62%, 0.9)`
|
||||
ctx.lineWidth = 1 / c.scale
|
||||
ctx.stroke()
|
||||
|
||||
const grad = ctx.createLinearGradient(x, y, x, y + h)
|
||||
grad.addColorStop(0, `hsla(${c.hue}, 20%, 40%, 0.18)`)
|
||||
grad.addColorStop(1, `hsla(${c.hue}, 20%, 20%, 0.06)`)
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
export function mountCardCanvas(canvas: HTMLCanvasElement) {
|
||||
const ctx = canvas.getContext('2d')!
|
||||
let cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth || 800, canvas.offsetHeight || 600))
|
||||
let raf = 0
|
||||
let running = true
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = canvas.offsetWidth * dpr
|
||||
canvas.height = canvas.offsetHeight * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth, canvas.offsetHeight))
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!running) return
|
||||
const w = canvas.offsetWidth, h = canvas.offsetHeight
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
for (const c of cards) {
|
||||
c.x += c.vx
|
||||
c.y += c.vy
|
||||
c.rot += c.vrot
|
||||
const pad = 120
|
||||
if (c.x < -pad || c.x > w + pad || c.y < -pad || c.y > h + pad) {
|
||||
Object.assign(c, makeCard(w, h))
|
||||
}
|
||||
drawCard(ctx, c)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(canvas)
|
||||
resize()
|
||||
tick()
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function ringGeometry(r: number, pad: number) {
|
||||
const size = (r + pad) * 2
|
||||
const c = size / 2
|
||||
const circ = 2 * Math.PI * r
|
||||
return { size, c, circ }
|
||||
}
|
||||
|
||||
const RING_STEPS = [
|
||||
{ target: 0.15, duration: 400 },
|
||||
{ target: 0.45, duration: 800 },
|
||||
{ target: 0.72, duration: 600 },
|
||||
{ target: 0.88, duration: 1000 },
|
||||
{ target: 0.96, duration: 700 },
|
||||
]
|
||||
|
||||
export function animateRingProgress(onProgress: (p: number) => void): () => void {
|
||||
let current = 0.025
|
||||
let stepIdx = 0
|
||||
let start = performance.now()
|
||||
let raf = 0
|
||||
let stopped = false
|
||||
|
||||
function ease(t: number) {
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
if (stopped) return
|
||||
if (stepIdx >= RING_STEPS.length) return
|
||||
|
||||
const step = RING_STEPS[stepIdx]
|
||||
const elapsed = now - start
|
||||
const t = Math.min(elapsed / step.duration, 1)
|
||||
const from = stepIdx === 0 ? 0.025 : RING_STEPS[stepIdx - 1].target
|
||||
current = from + (step.target - from) * ease(t)
|
||||
onProgress(current)
|
||||
|
||||
if (t >= 1) {
|
||||
stepIdx++
|
||||
start = now
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => { stopped = true; cancelAnimationFrame(raf) }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type OsKind = 'macos' | 'windows' | 'linux' | 'unknown'
|
||||
|
||||
export async function detectOs(): Promise<OsKind> {
|
||||
try {
|
||||
const { platform } = await import('@tauri-apps/plugin-os')
|
||||
const p = await platform()
|
||||
if (p === 'macos') return 'macos'
|
||||
if (p === 'windows') return 'windows'
|
||||
if (p === 'linux') return 'linux'
|
||||
return 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user