Compare commits

...

16 Commits

Author SHA1 Message Date
Zerebos d74790c3a0 Make it build 2026-05-23 22:32:17 -04:00
Zerebos 0e93908bb2 Phase d cleanup 2026-05-23 22:16:40 -04:00
Zerebos 074147f64f Cleanup core utilities and abstractions 2026-05-23 21:47:54 -04:00
Zerebos f91b46cfa5 Reader core parity 2026-05-23 21:33:02 -04:00
Zerebos 71ee4052f3 Cleanup routes and ux 2026-05-23 21:19:07 -04:00
Zerebos 5e2114810e Polish the migration 2026-05-23 21:09:08 -04:00
Zerebos b3fca70f27 Migrate remaining routes 2026-05-23 17:15:02 -04:00
Zerebos 68f25a2ea7 Migrate remaining feature routes 2026-05-23 16:37:09 -04:00
Zerebos 3d6b6430ed Reader route migration 2026-05-23 16:21:09 -04:00
Zerebos 54307d4411 Implement series route 2026-05-23 16:12:15 -04:00
Zerebos f8f080eff3 Finish phase 3 2026-05-23 02:48:31 -04:00
Zerebos f41f8a9c22 Finish phase 2 2026-05-23 02:30:27 -04:00
Zerebos 8cef79b2b4 Implement phase 1 2026-05-23 02:18:36 -04:00
Youwes09 6c39ef538f Fix: Splashscreen Appears on Boot 2026-05-22 21:39:29 -05:00
Youwes09 081becdd60 Chore: Basic Layout/Chrome + Stubs (WIP) 2026-05-22 21:30:40 -05:00
Youwes09 c891cb349c Chore: Implement Server Adapters & Request Manager 2026-05-22 20:44:55 -05:00
140 changed files with 12271 additions and 1474 deletions
+1
View File
@@ -6,6 +6,7 @@ dist-tauri/
target/
bin/
out/
notes/
.direnv/
result
+12 -1
View File
@@ -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"
}
}
+171 -20
View File
@@ -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: {}
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.9.4",
"identifier": "io.github.MokuProject.Moku",
"build": {
"frontendDist": "../dist",
"frontendDist": "../build",
"beforeBuildCommand": "pnpm build"
},
"app": {
+12 -251
View File
@@ -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 {
+50 -1
View File
@@ -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
View File
@@ -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();
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#091209"/>
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 1.5 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" 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

+1
View File
@@ -0,0 +1 @@
export * from './selectPortal';
+40
View File
@@ -0,0 +1,40 @@
import type {Attachment} from 'svelte/attachments';
export function selectPortal(triggerEl: HTMLElement & {__selectMenuEl?: HTMLElement | null;}): Attachment<Element> {
return (menuEl: Element) => {
const menu = menuEl as HTMLElement;
function position() {
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
const rect = triggerEl.getBoundingClientRect();
const top = rect.bottom / zoom + 4;
const right = rect.right / zoom;
const width = menu.offsetWidth;
const left = Math.max(8, right - width);
menu.style.position = 'fixed';
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
}
menu.style.visibility = 'hidden';
document.body.appendChild(menu);
triggerEl.__selectMenuEl = menu;
requestAnimationFrame(() => {
position();
menu.style.visibility = '';
});
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
return () => {
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
triggerEl.__selectMenuEl = null;
menu.remove();
};
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from '$lib/core/util';
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item));
+39
View File
@@ -0,0 +1,39 @@
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let index = 0;
async function worker() {
while (index < items.length) {
if (signal.aborted) return;
const item = items[index++];
await fn(item).catch(() => {});
}
}
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, worker));
}
const inflight = new Map<string, Promise<unknown>>();
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
if (typeof keyOrFn === 'function') {
const fn = keyOrFn;
return (key: string) => dedupeRequest(key, () => fn(key));
}
const key = keyOrFn;
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const request = factory!().finally(() => inflight.delete(key));
inflight.set(key, request);
return request;
}
@@ -0,0 +1,27 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>;
reset(): void;
hasMore(): boolean;
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{items: T[]; hasNextPage: boolean;}>;
}
export function createPaginatedQuery<T>(config: PaginatedQueryConfig<T>): PaginatedQuery<T> {
let hasMore = true;
return {
async fetchPage(page) {
const {items, hasNextPage} = await config.fetcher(page);
hasMore = hasNextPage;
return items;
},
reset() {
hasMore = true;
},
hasMore() {
return hasMore;
},
};
}
+36
View File
@@ -0,0 +1,36 @@
export interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
}
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
shouldRetry = () => true,
} = options;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !shouldRetry(error, attempt)) {
throw error;
}
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
+388
View File
@@ -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' }
}
+83
View File
@@ -0,0 +1,83 @@
import type {Settings} from '$lib/types/settings';
export interface HistoryBackupPayload {
history: unknown[];
bookmarks: unknown[];
markers: unknown[];
readLog: unknown[];
readingStats: Record<string, unknown>;
dailyReadCounts: Record<string, number>;
}
export interface AppDataBackup {
version: 1;
exportedAt: string;
settings: Settings;
history: HistoryBackupPayload;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function buildAppDataBackup(settings: Settings, history: HistoryBackupPayload): AppDataBackup {
return {
version: 1,
exportedAt: new Date().toISOString(),
settings,
history,
};
}
export function parseAppDataBackup(raw: string): AppDataBackup {
const parsed = JSON.parse(raw) as unknown;
if (!isObject(parsed)) throw new Error('Backup file is not a valid object');
if (parsed.version !== 1) throw new Error('Unsupported backup format version');
if (!isObject(parsed.settings)) throw new Error('Backup is missing settings data');
if (!isObject(parsed.history)) throw new Error('Backup is missing history data');
const history = parsed.history;
return {
version: 1,
exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : new Date().toISOString(),
settings: parsed.settings as unknown as Settings,
history: {
history: Array.isArray(history.history) ? history.history : [],
bookmarks: Array.isArray(history.bookmarks) ? history.bookmarks : [],
markers: Array.isArray(history.markers) ? history.markers : [],
readLog: Array.isArray(history.readLog) ? history.readLog : [],
readingStats: isObject(history.readingStats) ? history.readingStats : {},
dailyReadCounts: isObject(history.dailyReadCounts) ? (history.dailyReadCounts as Record<string, number>) : {},
},
};
}
export function downloadAppDataBackup(backup: AppDataBackup, filename = 'moku-app-backup.json'): void {
const blob = new Blob([JSON.stringify(backup, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function pickAppDataBackupFile(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', () => {
const file = input.files?.[0] ?? null;
resolve(file);
}, {once: true});
input.click();
});
}
+153
View File
@@ -0,0 +1,153 @@
import {fetchAuthenticated, getAuthMode} from '$lib/core/auth';
import {resolveImageUrl} from '$lib/core/image';
interface CacheEntry {
value: string;
revokable: boolean;
}
interface QueueEntry {
url: string;
priority: number;
resolve: (value: string) => void;
reject: (error: unknown) => void;
}
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<string>>();
const queue: QueueEntry[] = [];
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
async function doFetch(url: string): Promise<string> {
const resolved = resolveImageUrl(url) ?? url;
if (getAuthMode() === 'NONE') {
cache.set(url, {value: resolved, revokable: false});
return resolved;
}
const response = await fetchAuthenticated(resolved);
if (!response.ok) throw new Error(String(response.status));
const blob = await response.blob();
if (clearing) throw new DOMException('Cancelled', 'AbortError');
const objectUrl = URL.createObjectURL(blob);
cache.set(url, {value: objectUrl, revokable: true});
return objectUrl;
}
function insertSorted(entry: QueueEntry) {
let lo = 0;
let hi = queue.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (queue[mid].priority > entry.priority) lo = mid + 1;
else hi = mid;
}
queue.splice(lo, 0, entry);
}
function drain() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift();
if (!entry) break;
active += 1;
void doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => {
active -= 1;
drain();
});
}
}
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
requestAnimationFrame(drain);
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
insertSorted({url, priority, resolve, reject});
}).catch((error) => {
inflight.delete(url);
return Promise.reject(error);
});
inflight.set(url, promise);
scheduleDrain();
return promise;
}
export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve('');
const cached = cache.get(url);
if (cached) return Promise.resolve(cached.value);
const existing = inflight.get(url);
if (existing) {
const queueIndex = queue.findIndex((entry) => entry.url === url);
if (queueIndex !== -1 && priority > queue[queueIndex].priority) {
const [entry] = queue.splice(queueIndex, 1);
if (entry) {
entry.priority = priority;
insertSorted(entry);
}
}
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, index) => {
if (!url || cache.has(url) || inflight.has(url)) return;
void enqueue(url, basePriority - index);
});
}
export function revokeBlobUrl(url: string): void {
const entry = cache.get(url);
if (!entry) return;
if (entry.revokable) URL.revokeObjectURL(entry.value);
cache.delete(url);
}
export function deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority);
}
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException('Cancelled', 'AbortError'));
}
}
export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
for (const [url, entry] of cache.entries()) {
if (entry.revokable) URL.revokeObjectURL(entry.value);
cache.delete(url);
}
inflight.clear();
clearing = false;
}
+4
View File
@@ -0,0 +1,4 @@
export * from '$lib/core/cache/memoryCache';
export * from '$lib/core/cache/pageCache';
export * from '$lib/core/cache/imageCache';
export * from '$lib/core/cache/queryCache';
+119
View File
@@ -0,0 +1,119 @@
import type {Page} from '$lib/server-adapters/types';
import {getAdapter} from '$lib/request-manager';
import {resolveImageUrl} from '$lib/core/image';
import {getBlobUrl, preloadBlobUrls} from '$lib/core/cache/imageCache';
const pageCache = new Map<number, Page[]>();
const inflight = new Map<number, Promise<Page[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
const absoluteUrl = resolveImageUrl(url) ?? url;
if (!useBlob) return Promise.resolve(absoluteUrl);
const cached = resolvedUrlCache.get(absoluteUrl);
if (cached) return cached;
const promise = getBlobUrl(absoluteUrl, priority).catch((error) => {
resolvedUrlCache.delete(absoluteUrl);
return Promise.reject(error);
});
resolvedUrlCache.set(absoluteUrl, promise);
return promise;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<Page[]> {
const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
if (!inflight.has(chapterId)) {
const request = getAdapter()
.getChapterPages(String(chapterId))
.then((pages) => {
const normalized = pages.map((page) => ({
...page,
url: resolveImageUrl(page.url) ?? page.url,
}));
if (useBlob && normalized[priorityPage]?.url) {
void getBlobUrl(normalized[priorityPage].url, 999);
}
pageCache.set(chapterId, normalized);
return normalized;
})
.finally(() => inflight.delete(chapterId));
inflight.set(chapterId, request);
}
const base = inflight.get(chapterId);
if (!base) return Promise.resolve([]);
if (!signal) return base;
return new Promise<Page[]>((resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {once: true});
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
const absoluteUrl = resolveImageUrl(url) ?? url;
if (aspectCache.has(absoluteUrl)) return Promise.resolve(aspectCache.get(absoluteUrl) ?? 0.67);
return resolveUrl(absoluteUrl, useBlob).then(
(src) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const ratio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
aspectCache.set(absoluteUrl, ratio);
resolve(ratio);
};
img.onerror = () => resolve(0.67);
img.src = src;
}),
);
}
export function preloadImage(url: string, useBlob: boolean): void {
const absoluteUrl = resolveImageUrl(url) ?? url;
if (useBlob) {
preloadBlobUrls([absoluteUrl], 0);
return;
}
void resolveUrl(absoluteUrl, false)
.then((src) => {
const img = new Image();
img.src = src;
})
.catch(() => {});
}
export function clearResolvedUrlCache(): void {
resolvedUrlCache.clear();
aspectCache.clear();
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
inflight.delete(chapterId);
return;
}
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
aspectCache.clear();
}
+31 -31
View File
@@ -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(() => {});
}
+30
View File
@@ -0,0 +1,30 @@
import {fetchAuthenticated, getAuthMode, getServerBase} from '$lib/core/auth';
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//.test(value);
}
export function resolveImageUrl(path: string | null | undefined): string | undefined {
if (!path) return undefined;
if (isAbsoluteUrl(path)) return path;
const normalizedBase = getServerBase().replace(/\/$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${normalizedBase}${normalizedPath}`;
}
export async function loadImageObjectUrl(path: string, signal?: AbortSignal): Promise<string> {
const resolved = resolveImageUrl(path);
if (!resolved) throw new Error('Image URL is missing');
if (getAuthMode() === 'NONE') {
return resolved;
}
const response = await fetchAuthenticated(resolved, {}, signal);
if (!response.ok) {
throw new Error(`Failed to load image: ${response.status}`);
}
return URL.createObjectURL(await response.blob());
}
+21 -3
View File
@@ -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);
}
}
+12 -8
View File
@@ -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 {
+77
View File
@@ -0,0 +1,77 @@
import {isSupported, readFile, writeFile} from '$lib/platform-service';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const STORAGE_PREFIX = 'moku:';
function localStorageKey(key: string): string {
return `${STORAGE_PREFIX}${key}`;
}
function fileName(key: string): string {
return `moku.${key}.json`;
}
function canUseLocalStorage(): boolean {
return typeof window !== 'undefined' && typeof localStorage !== 'undefined';
}
function canUseFilesystem(): boolean {
try {
return isSupported('filesystem');
} catch {
return false;
}
}
export async function loadPersistentState<T>(key: string): Promise<T | null> {
if (canUseFilesystem()) {
try {
const data = await readFile(fileName(key));
if (data.length > 0) {
return JSON.parse(decoder.decode(data)) as T;
}
} catch {
// Fall back to localStorage when the file does not exist or the adapter cannot read it yet.
}
}
if (!canUseLocalStorage()) return null;
try {
const raw = localStorage.getItem(localStorageKey(key));
return raw ? JSON.parse(raw) as T : null;
} catch {
return null;
}
}
export async function savePersistentState<T>(key: string, value: T): Promise<void> {
const json = JSON.stringify(value);
if (canUseLocalStorage()) {
localStorage.setItem(localStorageKey(key), json);
}
if (!canUseFilesystem()) return;
try {
await writeFile(fileName(key), encoder.encode(json));
} catch {
// LocalStorage remains the fallback when a platform adapter cannot persist to files.
}
}
export async function clearPersistentState(key: string): Promise<void> {
if (canUseLocalStorage()) {
localStorage.removeItem(localStorageKey(key));
}
if (!canUseFilesystem()) return;
try {
await writeFile(fileName(key), encoder.encode('null'));
} catch {
// Ignore native persistence failures during cleanup.
}
}
+54
View File
@@ -0,0 +1,54 @@
import {getAdapter} from '$lib/request-manager';
import {loadChapterPages} from '$lib/request-manager/chapters';
import {readerState} from '$lib/state/reader.svelte';
import {sortChapters} from './navigation';
/**
* Load (or resume) a reader session for the given manga and chapter.
* Caches manga/chapter list when the manga ID hasn't changed to avoid redundant fetches.
* Resumes at the reader's last saved page position.
*/
export async function ensureReaderSession(
mangaId: string,
chapterId: string,
): Promise<void> {
const adapter = getAdapter();
const mangaPromise =
readerState.manga && String(readerState.manga.id) === mangaId
? Promise.resolve(readerState.manga)
: adapter.getManga(mangaId);
const chaptersPromise =
readerState.chapters.length > 0 &&
String(readerState.chapters[0]?.mangaId) === mangaId
? Promise.resolve(readerState.chapters)
: adapter.getChapters(mangaId);
const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]);
const chapter =
chapters.find((ch) => String(ch.id) === chapterId) ??
(String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ??
(await adapter.getChapter(chapterId));
readerState.manga = manga;
readerState.chapters = chapters;
readerState.chapter = chapter;
readerState.pages = [];
readerState.currentPage = 0;
readerState.pagesError = null;
await loadChapterPages(chapterId);
if (readerState.pages.length > 0) {
const resumeIndex = Math.max(0, (chapter.lastPageRead ?? 1) - 1);
readerState.currentPage = Math.min(resumeIndex, readerState.pages.length - 1);
}
}
/**
* Return the sorted chapter list for the current manga ordered by source order.
* Convenience re-export for callers that only need adjacent chapter lookups.
*/
export {sortChapters};
+104
View File
@@ -0,0 +1,104 @@
import {getAdapter} from '$lib/request-manager';
import {loadChapterPages, updateProgress} from '$lib/request-manager/chapters';
import {readerState} from '$lib/state/reader.svelte';
import type {Chapter} from '$lib/types/index';
export function sortChapters(chapters: Chapter[]): Chapter[] {
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
function currentChapterIndex(): number {
if (!readerState.chapter) return -1;
return sortChapters(readerState.chapters).findIndex(
(ch) => String(ch.id) === String(readerState.chapter?.id),
);
}
function clampPageIndex(index: number): number {
if (readerState.pages.length === 0) return 0;
return Math.min(Math.max(index, 0), readerState.pages.length - 1);
}
export function getAdjacentChapters(): {
previous: Chapter | null;
next: Chapter | null;
} {
const chapters = sortChapters(readerState.chapters);
const index = currentChapterIndex();
return {
previous: index > 0 ? (chapters[index - 1] ?? null) : null,
next: index >= 0 && index < chapters.length - 1 ? (chapters[index + 1] ?? null) : null,
};
}
export async function setCurrentReaderPage(index: number): Promise<void> {
const nextIndex = clampPageIndex(index);
readerState.currentPage = nextIndex;
if (!readerState.chapter || readerState.pages.length === 0) return;
const lastPageRead = nextIndex + 1;
const completed = lastPageRead >= readerState.pages.length;
if (
readerState.chapter.lastPageRead === lastPageRead &&
readerState.chapter.read === completed
) {
return;
}
try {
await updateProgress(String(readerState.chapter.id), lastPageRead, completed);
} catch (error) {
readerState.pagesError = error instanceof Error ? error.message : String(error);
}
}
export async function goToNextReaderPage(): Promise<boolean> {
if (readerState.currentPage >= readerState.pages.length - 1) return false;
await setCurrentReaderPage(readerState.currentPage + 1);
return true;
}
export async function goToPreviousReaderPage(): Promise<boolean> {
if (readerState.currentPage <= 0) return false;
await setCurrentReaderPage(readerState.currentPage - 1);
return true;
}
export async function ensureReaderSession(
mangaId: string,
chapterId: string,
): Promise<void> {
const adapter = getAdapter();
const mangaPromise =
readerState.manga && String(readerState.manga.id) === mangaId
? Promise.resolve(readerState.manga)
: adapter.getManga(mangaId);
const chaptersPromise =
readerState.chapters.length > 0 &&
String(readerState.chapters[0]?.mangaId) === mangaId
? Promise.resolve(readerState.chapters)
: adapter.getChapters(mangaId);
const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]);
const chapter =
chapters.find((ch) => String(ch.id) === chapterId) ??
(String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ??
(await adapter.getChapter(chapterId));
readerState.manga = manga;
readerState.chapters = chapters;
readerState.chapter = chapter;
readerState.pages = [];
readerState.currentPage = 0;
readerState.pagesError = null;
await loadChapterPages(chapterId);
if (readerState.pages.length > 0) {
readerState.currentPage = clampPageIndex((chapter.lastPageRead ?? 1) - 1);
}
}
+50
View File
@@ -0,0 +1,50 @@
import type {Page} from '$lib/server-adapters/types';
/**
* Build double-page spread groups for a given page count.
* Groups are 1-based page numbers. Wide pages (aspect ratio > 1.2) get their own group.
* `offsetSpreads` causes the first pairing to start at page 2 (common for manga with a cover).
*/
export function buildPageGroups(
count: number,
aspects: number[],
offsetSpreads: boolean,
): number[][] {
if (count === 0) return [];
const groups: number[][] = [[1]];
if (offsetSpreads && count > 1) groups.push([2]);
let i = offsetSpreads ? 3 : 2;
while (i <= count) {
const aspect = aspects[i - 1] ?? 1;
if (aspect > 1.2 || i === count) {
groups.push([i++]);
} else {
groups.push([i, i + 1]);
i += 2;
}
}
return groups;
}
/**
* Imperatively kick off browser image preloading for a URL.
* Fire-and-forget; errors are silently swallowed.
*/
export function preloadImage(url: string): void {
if (!url || typeof document === 'undefined') return;
const img = new Image();
img.src = url;
}
/**
* Preload a window of pages ahead of the current position.
*/
export function preloadPages(pages: Page[], currentIndex: number, windowSize = 3): void {
const end = Math.min(currentIndex + windowSize, pages.length);
for (let i = currentIndex + 1; i < end; i++) {
const p = pages[i];
if (p) preloadImage(p.imageData ?? p.url);
}
}
+68
View File
@@ -0,0 +1,68 @@
import {createPinchGesture} from '$lib/core/ui/touchscreen';
import type {PinchGesture} from '$lib/core/ui/touchscreen';
import {clampZoom, ZOOM_MIN, ZOOM_MAX} from './zoomHelpers';
export type {PinchGesture as PinchTracker};
/** Max zoom level allowed in single-page inspect mode (pan+zoom overlay). */
const INSPECT_ZOOM_MAX = 8;
export interface PinchTrackerOptions {
/** Get the current reader-level zoom (longstrip scaling). */
getZoom: () => number;
/** Set a new reader-level zoom. */
setZoom: (value: number) => void;
/** Get the current inspect-mode zoom scale for single-page view. */
getInspectScale: () => number;
/** Set inspect-mode zoom scale. */
setInspectScale: (value: number) => void;
/** Reset inspect-mode pan offsets to origin. */
resetInspectPan: () => void;
/** Returns true when the reader is in longstrip mode. */
isLongstrip: () => boolean;
}
/**
* Create a pinch-gesture tracker that drives reader zoom.
*
* In longstrip mode pinch controls the global strip zoom level.
* In single/double mode pinch controls the in-page inspect zoom.
*
* Usage — wire the returned handler methods to the container element:
* ```svelte
* <div
* onpointerdown={tracker.onPointerDown}
* onpointermove={tracker.onPointerMove}
* onpointerup={tracker.onPointerUp}
* onpointercancel={tracker.onPointerUp}
* >
* ```
*/
export function createPinchTracker(opts: PinchTrackerOptions): PinchGesture {
let startZoom = 0;
let startInspect = 0;
return createPinchGesture({
onPinch(scale) {
if (startZoom === 0) {
startZoom = opts.getZoom();
startInspect = opts.getInspectScale();
}
if (opts.isLongstrip()) {
opts.setZoom(clampZoom(startZoom * scale, ZOOM_MIN, ZOOM_MAX));
} else {
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale));
if (next !== opts.getInspectScale()) {
if (next <= 1) opts.resetInspectPan();
opts.setInspectScale(next);
}
}
},
onPinchEnd() {
startZoom = 0;
startInspect = 0;
},
});
}
+115
View File
@@ -0,0 +1,115 @@
import {matchesKeybind, toggleFullscreen} from '$lib/core/keybinds/keybindEngine';
import type {Keybinds} from '$lib/core/keybinds/defaultBinds';
export interface ReaderKeyActions {
/** Navigate one step forward (respects RTL). */
goNext: () => void;
/** Navigate one step backward (respects RTL). */
goPrev: () => void;
/** Jump to a specific 0-based page index. */
goToPage: (index: number) => void;
/** Return the 0-based index of the last page. */
lastPage: () => number;
/** Close the reader and return to the series page. */
exitReader: () => void;
/** Jump to the next chapter. */
chapterNext: () => void;
/** Jump to the previous chapter. */
chapterPrev: () => void;
/** Adjust reader zoom by delta (positive = zoom in, negative = zoom out). */
adjustZoom: (delta: number) => void;
/** Reset zoom to 1.0. */
resetZoom: () => void;
/** Cycle through available page display modes. */
cycleMode: () => void;
/** Toggle between LTR and RTL reading direction. */
toggleDirection: () => void;
/** Open the settings panel or navigate to /settings. */
openSettings: () => void;
/** Toggle the bookmark on the current chapter/page. */
toggleBookmark: () => void;
/** Toggle auto-scroll in longstrip mode. */
toggleAutoScroll: () => void;
/** Return the current keybind configuration. */
getKeybinds: () => Keybinds;
}
const CTRL_ZOOM_STEP = 0.1;
/**
* Create a keydown event handler for the reader with the given action callbacks.
* Suitable for use as `svelte:window onkeydown={handler}` in the reader page.
*/
export function createReaderKeyHandler(
actions: ReaderKeyActions,
): (event: KeyboardEvent) => void {
return function onKey(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
// Ctrl +/-/0 zoom shortcuts (standard browser-style overrides)
if (event.ctrlKey) {
if (event.key === '=' || event.key === '+') {
event.preventDefault();
actions.adjustZoom(CTRL_ZOOM_STEP);
return;
}
if (event.key === '-') {
event.preventDefault();
actions.adjustZoom(-CTRL_ZOOM_STEP);
return;
}
if (event.key === '0') {
event.preventDefault();
actions.resetZoom();
return;
}
}
const kb = actions.getKeybinds();
if (matchesKeybind(event, kb.exitReader)) {
event.preventDefault();
actions.exitReader();
} else if (event.key === 'Escape') {
event.preventDefault();
actions.exitReader();
} else if (matchesKeybind(event, kb.turnPageRight)) {
event.preventDefault();
actions.goNext();
} else if (matchesKeybind(event, kb.turnPageLeft)) {
event.preventDefault();
actions.goPrev();
} else if (matchesKeybind(event, kb.firstPage)) {
event.preventDefault();
actions.goToPage(0);
} else if (matchesKeybind(event, kb.lastPage)) {
event.preventDefault();
actions.goToPage(actions.lastPage());
} else if (matchesKeybind(event, kb.turnChapterRight)) {
event.preventDefault();
actions.chapterNext();
} else if (matchesKeybind(event, kb.turnChapterLeft)) {
event.preventDefault();
actions.chapterPrev();
} else if (matchesKeybind(event, kb.togglePageStyle)) {
event.preventDefault();
actions.cycleMode();
} else if (matchesKeybind(event, kb.toggleReadingDirection)) {
event.preventDefault();
actions.toggleDirection();
} else if (matchesKeybind(event, kb.toggleFullscreen)) {
event.preventDefault();
void toggleFullscreen();
} else if (matchesKeybind(event, kb.openSettings)) {
event.preventDefault();
actions.openSettings();
} else if (matchesKeybind(event, kb.toggleBookmark)) {
event.preventDefault();
actions.toggleBookmark();
} else if (matchesKeybind(event, kb.toggleAutoScroll)) {
event.preventDefault();
actions.toggleAutoScroll();
}
};
}
+142
View File
@@ -0,0 +1,142 @@
/** Fraction from the top of the viewport used as the "active page" read line. */
export const READ_LINE_PCT = 0.5;
export interface StripChapter {
chapterId: string;
chapterName: string;
pageCount: number;
}
export interface ScrollHandlerCallbacks {
/** Called when the visible page index changes (0-based). */
onPageChange: (pageIndex: number) => void;
/** Called when the visible chapter changes in multi-chapter strip mode. */
onChapterChange: (chapterId: string) => void;
/** Called when a chapter has been fully scrolled past (auto-mark-read). */
onMarkRead: (chapterId: string) => void;
/** Called when the reader is near the bottom and should load the next chapter. */
onAppend: () => void;
/** Return the current list of strip chapters for auto-mark calculations. */
getStripChapters: () => StripChapter[];
/** Whether to automatically mark chapters read on scroll. */
shouldAutoMark: () => boolean;
}
/**
* Attach scroll-position tracking to a longstrip container element.
* Returns a cleanup function to remove all listeners.
*
* Images in the container must have `data-page-index` (0-based) and optionally
* `data-chapter-id` attributes for multi-chapter strip tracking.
*/
export function setupScrollTracking(
containerEl: HTMLElement,
callbacks: ScrollHandlerCallbacks,
): () => void {
const {
onPageChange,
onChapterChange,
onMarkRead,
onAppend,
getStripChapters,
shouldAutoMark,
} = callbacks;
let rafId: number | null = null;
function tick() {
rafId = null;
const imgs = containerEl.querySelectorAll<HTMLElement>('img[data-page-index]');
if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
// Binary search for the last image whose top edge is above the read line
let lo = 0, hi = imgs.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if ((imgs[mid] as HTMLElement).getBoundingClientRect().top <= readLineY) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
const active = imgs[best] as HTMLElement;
const pageIndex = Number(active.dataset.pageIndex);
const chapterId = active.dataset.chapterId ?? null;
onPageChange(pageIndex);
if (chapterId) onChapterChange(chapterId);
if (shouldAutoMark() && chapterId) {
const chunks = getStripChapters();
const chunk = chunks.find((c) => c.chapterId === chapterId);
if (chunk && pageIndex >= chunk.pageCount - 1) {
onMarkRead(chapterId);
}
const atBottom =
containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 60;
if (atBottom) {
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
}
// Trigger appending next chapter when 80% scrolled
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.8) onAppend();
}
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(tick);
}
containerEl.addEventListener('scroll', onScroll, {passive: true});
return () => {
containerEl.removeEventListener('scroll', onScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}
/**
* Append the next chapter's pages to a strip view.
*
* Finds the chapter after the last currently-loaded strip chapter, fetches its
* pages, and calls `onAppended` with the new chunk. Calls `onDone` when finished
* (success or no-op).
*/
export async function appendNextChapter(
stripChapters: StripChapter[],
chapterList: {id: string; name: string;}[],
fetchPageCount: (chapterId: string) => Promise<number>,
onAppended: (next: StripChapter) => void,
onDone: () => void,
): Promise<void> {
if (!stripChapters.length) {onDone(); return; }
const lastChunk = stripChapters[stripChapters.length - 1];
if (!lastChunk) {onDone(); return; }
const lastIdx = chapterList.findIndex((c) => c.id === lastChunk.chapterId);
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) {onDone(); return; }
const next = chapterList[lastIdx + 1];
if (!next || stripChapters.some((c) => c.chapterId === next.id)) {onDone(); return; }
try {
const pageCount = await fetchPageCount(next.id);
if (stripChapters.some((c) => c.chapterId === next.id)) {onDone(); return; }
onAppended({chapterId: next.id, chapterName: next.name, pageCount});
} catch {
// swallow caller retries on next scroll trigger
} finally {
onDone();
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* @deprecated Import directly from the specific reader core modules:
* - chapterLoader.ts → ensureReaderSession, sortChapters
* - navigation.ts → getAdjacentChapters, setCurrentReaderPage, goToNextReaderPage, goToPreviousReaderPage
*
* This file is kept for backward-compatibility only.
*/
export {
ensureReaderSession,
} from './chapterLoader';
export {
sortChapters,
getAdjacentChapters,
setCurrentReaderPage,
goToNextReaderPage,
goToPreviousReaderPage,
} from './navigation';
+30
View File
@@ -0,0 +1,30 @@
export const ZOOM_MIN = 0.25;
export const ZOOM_MAX = 4.0;
export const ZOOM_STEP = 0.1;
/**
* Clamp a zoom value between the reader's min/max bounds.
*/
export function clampZoom(value: number, min = ZOOM_MIN, max = ZOOM_MAX): number {
return Math.max(min, Math.min(max, value));
}
/**
* Return the next zoom level after applying a delta, clamped to valid bounds.
* Rounded to avoid floating point drift.
*/
export function adjustZoom(current: number, delta: number): number {
return clampZoom(Math.round((current + delta) * 1000) / 1000);
}
/**
* Snap to a list of named presets (0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0).
* Returns the nearest preset value to the given zoom.
*/
export const ZOOM_PRESETS = [0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] as const;
export function snapToPreset(value: number): number {
return ZOOM_PRESETS.reduce((best, preset) =>
Math.abs(preset - value) < Math.abs(best - value) ? preset : best
);
}
+79
View File
@@ -0,0 +1,79 @@
import {settingsState, updateSettings} from '$lib/state/settings.svelte';
import type {CustomTheme, Theme} from '$lib/types/settings';
let themeStyleEl: HTMLStyleElement | null = null;
function ensureThemeStyleEl(): HTMLStyleElement {
if (themeStyleEl) return themeStyleEl;
themeStyleEl = document.createElement('style');
themeStyleEl.id = 'moku-custom-theme';
document.head.appendChild(themeStyleEl);
return themeStyleEl;
}
function removeCustomThemeCss() {
themeStyleEl?.remove();
themeStyleEl = null;
}
function resolveBuiltinTheme(theme: Theme): string {
if (theme === 'light-contrast') return 'light';
return theme || 'dark';
}
export function applyTheme(theme: Theme, customThemes: CustomTheme[] = []) {
const activeTheme = theme || 'dark';
const customThemeId = activeTheme.startsWith('custom:') ? activeTheme.slice(7) : activeTheme;
const customTheme = customThemes.find(entry => entry.id === customThemeId);
if (!customTheme) {
removeCustomThemeCss();
document.documentElement.setAttribute('data-theme', resolveBuiltinTheme(activeTheme));
return;
}
const css = Object.entries(customTheme.tokens)
.map(([token, value]) => ` --${token}: ${value};`)
.join('\n');
ensureThemeStyleEl().textContent = `[data-theme="custom"] {\n${css}\n}`;
document.documentElement.setAttribute('data-theme', 'custom');
}
let systemThemeMedia: MediaQueryList | null = null;
let systemThemeHandler: ((event: MediaQueryListEvent) => void) | null = null;
function applySystemTheme(isDark: boolean) {
const themeId = isDark
? (settingsState.systemThemeDark ?? 'dark')
: (settingsState.systemThemeLight ?? 'light');
updateSettings({theme: themeId});
}
export function mountSystemThemeSync() {
if (typeof window === 'undefined') return;
if (systemThemeMedia && systemThemeHandler) {
systemThemeMedia.removeEventListener('change', systemThemeHandler);
systemThemeMedia = null;
systemThemeHandler = null;
}
if (!settingsState.systemThemeSync) return;
systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
systemThemeHandler = (event) => applySystemTheme(event.matches);
systemThemeMedia.addEventListener('change', systemThemeHandler);
applySystemTheme(systemThemeMedia.matches);
}
export function unmountSystemThemeSync() {
if (systemThemeMedia && systemThemeHandler) {
systemThemeMedia.removeEventListener('change', systemThemeHandler);
}
systemThemeMedia = null;
systemThemeHandler = null;
}
+49
View File
@@ -0,0 +1,49 @@
const IDLE_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const;
export function mountIdleDetection(
getTimeoutMinutes: () => number | undefined,
onIdle: () => void,
onActive: () => void,
): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let idle = false;
const markActive = () => {
if (!idle) return;
idle = false;
onActive();
};
const resetTimer = () => {
if (timer) clearTimeout(timer);
const timeoutMinutes = getTimeoutMinutes() ?? 5;
const timeoutMs = Math.max(0, timeoutMinutes) * 60 * 1000;
if (timeoutMs === 0) {
markActive();
return;
}
markActive();
timer = setTimeout(() => {
if (idle) return;
idle = true;
onIdle();
}, timeoutMs);
};
IDLE_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, resetTimer, {passive: true});
});
resetTimer();
return () => {
if (timer) clearTimeout(timer);
IDLE_EVENTS.forEach((eventName) => {
window.removeEventListener(eventName, resetTimer);
});
};
}
+20 -6
View File
@@ -16,12 +16,26 @@ export function applyZoom(uiZoom: number) {
export function zoomDelta(e: KeyboardEvent, current: number): number | null {
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
View File
@@ -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;
}
+48
View File
@@ -0,0 +1,48 @@
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-down { animation: fadeDown 0.18s ease both; }
.anim-scale-in { animation: scaleIn 0.14s ease both; }
.anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; }
.skeleton {
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm);
}
+4
View File
@@ -0,0 +1,4 @@
@import './reset.css';
@import './animations.css';
@import './scrollbars.css';
@import './typography.css';
+48
View File
@@ -0,0 +1,48 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#svelte {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol {
list-style: none;
}
img, svg {
display: block;
max-width: 100%;
}
p {
margin: 0;
}
+22
View File
@@ -0,0 +1,22 @@
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar {
width: 4px;
height: 4px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 99px;
}
*::-webkit-scrollbar-thumb:hover {
background: transparent;
}
+9
View File
@@ -0,0 +1,9 @@
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
+3
View File
@@ -0,0 +1,3 @@
@import './base/index.css';
@import './tokens/index.css';
@import './themes/index.css';
+25
View File
@@ -0,0 +1,25 @@
[data-theme='dark'] {
--bg-void: #000000;
--bg-base: #080808;
--bg-surface: #0d0d0d;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
}
+6
View File
@@ -0,0 +1,6 @@
@import './original.css';
@import './dark.css';
@import './light.css';
@import './light-contrast.css';
@import './midnight.css';
@import './warm.css';
+29
View File
@@ -0,0 +1,29 @@
[data-theme='light-contrast'] {
--bg-void: #f3efe7;
--bg-base: #fbf7f1;
--bg-surface: #ffffff;
--bg-raised: #fffdfa;
--bg-overlay: #ffffff;
--bg-subtle: #efe8dc;
--border-dim: #c0b8ab;
--border-base: #8f8677;
--border-strong: #60594f;
--border-focus: #234c23;
--text-primary: #050402;
--text-secondary: #14110b;
--text-muted: #3e3529;
--text-faint: #655c50;
--text-disabled: #a39b8f;
--accent: #244f24;
--accent-dim: #b7d2b7;
--accent-muted: #d5e6d5;
--accent-fg: #173717;
--accent-bright: #173f17;
--color-error: #7f1010;
--color-error-bg: #fdeaea;
--color-read: #ece5da;
}
+29
View File
@@ -0,0 +1,29 @@
[data-theme='light'] {
--bg-void: #d8d4ce;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme='midnight'] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
+31
View File
@@ -0,0 +1,31 @@
[data-theme='original'] {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme='warm'] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
+35
View File
@@ -0,0 +1,35 @@
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
}
+7
View File
@@ -0,0 +1,7 @@
@import './colors.css';
@import './typography.css';
@import './spacing.css';
@import './radius.css';
@import './motion.css';
@import './shadows.css';
@import './zindex.css';
+5
View File
@@ -0,0 +1,5 @@
:root {
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
}
+8
View File
@@ -0,0 +1,8 @@
:root {
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
}
+2
View File
@@ -0,0 +1,2 @@
:root {
}
+13
View File
@@ -0,0 +1,13 @@
:root {
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
--titlebar-height: 36px;
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--font-ui: 'DM Mono', 'Fira Mono', ui-monospace, monospace;
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
}
+5
View File
@@ -0,0 +1,5 @@
:root {
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
@@ -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> {}
}
+119
View File
@@ -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()
}
}
}
+55
View File
@@ -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>
}
+66
View File
@@ -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() {}
}
+98
View File
@@ -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()
}
+44 -18
View File
@@ -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;
}
}
+12
View File
@@ -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
}
+3 -3
View File
@@ -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)
}
}
}
+32 -12
View File
@@ -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;
}
}
+59
View File
@@ -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();}
}
+166 -388
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import type { Manga, Chapter, Extension } from '$lib/types'
import type { Manga, Chapter, Extension } from '$lib/types/index'
import type { DownloadItem } from '$lib/server-adapters/types'
export interface GQLResponse<T> {
+61 -55
View File
@@ -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[]>;
}
+4 -2
View File
@@ -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,
});
+15 -3
View File
@@ -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
}
+16 -10
View File
@@ -1,4 +1,6 @@
import type { Extension, Source, Manga } from '$lib/types'
import type {Extension, Source, Manga} from '$lib/types/index';
import {shouldHideSource} from '$lib/core/util';
import {settingsState} from '$lib/state/settings.svelte';
export const extensionsState = $state({
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;
}
+168
View File
@@ -0,0 +1,168 @@
import {untrack} from 'svelte';
import {
DEFAULT_READING_STATS,
type BookmarkEntry,
type HistoryEntry,
type MarkerEntry,
type ReadLogEntry,
type ReadingStats,
} from '$lib/types/history';
import {loadPersistentState, savePersistentState} from '$lib/core/persistence/persist';
const HISTORY_STORAGE_KEY = 'history';
const AVG_MIN_PER_CHAPTER = 5;
interface PersistedHistory {
history: HistoryEntry[];
bookmarks: BookmarkEntry[];
markers: MarkerEntry[];
readLog: ReadLogEntry[];
readingStats: ReadingStats;
dailyReadCounts: Record<string, number>;
}
function localDateString(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function emptyHistoryState(): PersistedHistory {
return {
history: [],
bookmarks: [],
markers: [],
readLog: [],
readingStats: {...DEFAULT_READING_STATS},
dailyReadCounts: {},
};
}
export const historyState = $state(emptyHistoryState());
export const historyStatus = $state({
ready: false,
loading: false,
error: null as string | null,
});
let initialized = false;
let persistQueued = false;
function queueHistoryPersist() {
if (!historyStatus.ready || historyStatus.loading || persistQueued) return;
persistQueued = true;
queueMicrotask(() => {
persistQueued = false;
if (!historyStatus.ready || historyStatus.loading) return;
const snapshot = JSON.stringify(historyState);
void savePersistentState(HISTORY_STORAGE_KEY, JSON.parse(snapshot) as PersistedHistory);
});
}
export async function initHistoryState() {
if (initialized || historyStatus.loading) return;
historyStatus.loading = true;
try {
const persisted = await loadPersistentState<PersistedHistory>(HISTORY_STORAGE_KEY);
untrack(() => {
Object.assign(historyState, {
...emptyHistoryState(),
...persisted,
readingStats: persisted?.readingStats ?? {...DEFAULT_READING_STATS},
dailyReadCounts: persisted?.dailyReadCounts ?? {},
});
});
initialized = true;
historyStatus.ready = true;
historyStatus.error = null;
} catch (error) {
historyStatus.ready = true;
historyStatus.error = String(error);
} finally {
historyStatus.loading = false;
}
}
export function addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
historyState.history = [entry, ...historyState.history.filter(item => item.chapterId !== entry.chapterId)].slice(0, 500);
if (!completed || historyState.readLog.some(item => item.chapterId === entry.chapterId)) {
queueHistoryPersist();
return;
}
historyState.readLog = [
...historyState.readLog,
{mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes},
];
const totalMinutes = historyState.readLog.reduce((sum, item) => sum + item.minutes, 0);
const uniqueChapters = new Set(historyState.readLog.map(item => item.chapterId));
const uniqueManga = new Set(historyState.readLog.map(item => item.mangaId));
const today = localDateString(new Date());
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayKey = localDateString(yesterday);
const previousStreakDate = historyState.readingStats.lastStreakDate;
const streak = previousStreakDate === today
? historyState.readingStats.currentStreakDays
: previousStreakDate === yesterdayKey
? historyState.readingStats.currentStreakDays + 1
: 1;
historyState.readingStats = {
totalChaptersRead: uniqueChapters.size,
totalMangaRead: uniqueManga.size,
totalMinutesRead: totalMinutes,
firstReadAt: historyState.readingStats.firstReadAt || entry.readAt,
lastReadAt: entry.readAt,
currentStreakDays: streak,
longestStreakDays: Math.max(historyState.readingStats.longestStreakDays, streak),
lastStreakDate: today,
};
historyState.dailyReadCounts = {
...historyState.dailyReadCounts,
[today]: (historyState.dailyReadCounts[today] ?? 0) + 1,
};
queueHistoryPersist();
}
export function addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) {
historyState.bookmarks = [
{...entry, savedAt: Date.now(), label},
...historyState.bookmarks.filter(item => item.chapterId !== entry.chapterId),
].slice(0, 200);
queueHistoryPersist();
}
export function removeBookmark(chapterId: number) {
historyState.bookmarks = historyState.bookmarks.filter(item => item.chapterId !== chapterId);
queueHistoryPersist();
}
export function getBookmark(chapterId: number): BookmarkEntry | undefined {
return historyState.bookmarks.find(item => item.chapterId === chapterId);
}
export function clearBookmarks() {
historyState.bookmarks = [];
queueHistoryPersist();
}
export function clearHistory() {
historyState.history = [];
historyState.readLog = [];
historyState.dailyReadCounts = {};
historyState.readingStats = {...DEFAULT_READING_STATS};
queueHistoryPersist();
}
+26 -18
View File
@@ -1,7 +1,9 @@
import type { Manga } from '$lib/types'
import type { 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;
}
+40
View File
@@ -0,0 +1,40 @@
import {DEFAULT_MANGA_PREFS, type MangaPrefs} from '$lib/types/settings';
import {settingsState} from '$lib/state/settings.svelte';
export function getMangaPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
const prefs = settingsState.mangaPrefs[mangaId] ?? {};
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
export function getMangaPrefs(mangaId: number): MangaPrefs {
return {
...DEFAULT_MANGA_PREFS,
...(settingsState.mangaPrefs[mangaId] ?? {}),
};
}
export function setMangaPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
settingsState.mangaPrefs = {
...settingsState.mangaPrefs,
[mangaId]: {
...(settingsState.mangaPrefs[mangaId] ?? {}),
[key]: value,
},
};
}
export function replaceMangaPrefs(mangaId: number, prefs: Partial<MangaPrefs>) {
settingsState.mangaPrefs = {
...settingsState.mangaPrefs,
[mangaId]: {
...(settingsState.mangaPrefs[mangaId] ?? {}),
...prefs,
},
};
}
export function clearMangaPrefs(mangaId: number) {
const next = {...settingsState.mangaPrefs};
delete next[mangaId];
settingsState.mangaPrefs = next;
}
+38 -13
View File
@@ -1,9 +1,9 @@
import type { Manga, Chapter } from '$lib/types'
import type { 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;
}
+6 -2
View File
@@ -1,4 +1,4 @@
import type { Manga, Chapter } from '$lib/types'
import type { Manga, Chapter } from '$lib/types/index'
export const seriesState = $state({
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
}
+141
View File
@@ -0,0 +1,141 @@
import {untrack} from 'svelte';
import {DEFAULT_KEYBINDS} from '$lib/core/keybinds/defaultBinds';
import {savePersistentState, loadPersistentState} from '$lib/core/persistence/persist';
import {applyTheme} from '$lib/core/theme';
import {applyZoom} from '$lib/core/ui/zoom';
import {DEFAULT_AUTOMATION_DEFAULTS, DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS, type MangaPrefs, type Settings} from '$lib/types/settings';
const SETTINGS_STORAGE_KEY = 'settings';
const SETTINGS_STORE_VERSION = 1;
interface PersistedSettings {
settings: Partial<Settings> | null;
storeVersion: number | null;
}
function mergeSettings(saved: Partial<Settings> | null | undefined): Settings {
return {
...DEFAULT_SETTINGS,
...saved,
keybinds: {...DEFAULT_KEYBINDS, ...(saved?.keybinds ?? {})},
heroSlots: saved?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.mangaLinks ?? {},
mangaPrefs: saved?.mangaPrefs ?? {},
customThemes: saved?.customThemes ?? [],
hiddenCategoryIds: saved?.hiddenCategoryIds ?? [],
nsfwAllowedSourceIds: saved?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.nsfwBlockedSourceIds ?? [],
libraryTabSort: saved?.libraryTabSort ?? {},
libraryTabStatus: saved?.libraryTabStatus ?? {},
libraryTabFilters: saved?.libraryTabFilters ?? {},
extraScanDirs: saved?.extraScanDirs ?? [],
pinnedSourceIds: saved?.pinnedSourceIds ?? [],
readerPresets: saved?.readerPresets ?? [],
mangaReaderSettings: saved?.mangaReaderSettings ?? {},
hiddenLibraryTabs: saved?.hiddenLibraryTabs ?? [],
libraryPinnedTabOrder: saved?.libraryPinnedTabOrder ?? [],
automationDefaults: saved?.automationDefaults ?? DEFAULT_AUTOMATION_DEFAULTS,
};
}
export const settingsState = $state<Settings>(mergeSettings(null));
export const settingsStatus = $state({
ready: false,
loading: false,
error: null as string | null,
});
let initialized = false;
let persistQueued = false;
function persistSettings() {
const snapshot = JSON.stringify(settingsState);
void savePersistentState(SETTINGS_STORAGE_KEY, {
settings: JSON.parse(snapshot) as Settings,
storeVersion: SETTINGS_STORE_VERSION,
} satisfies PersistedSettings);
}
function applySettingsVisuals() {
if (!settingsStatus.ready || typeof document === 'undefined') return;
applyTheme(settingsState.theme, settingsState.customThemes);
applyZoom(settingsState.uiZoom);
}
function queueSettingsSync() {
applySettingsVisuals();
if (!settingsStatus.ready || settingsStatus.loading || persistQueued) return;
persistQueued = true;
queueMicrotask(() => {
persistQueued = false;
if (!settingsStatus.ready || settingsStatus.loading) return;
persistSettings();
});
}
export async function initSettingsState() {
if (initialized || settingsStatus.loading) return;
settingsStatus.loading = true;
try {
const persisted = await loadPersistentState<PersistedSettings>(SETTINGS_STORAGE_KEY);
untrack(() => {
Object.assign(settingsState, mergeSettings(persisted?.settings));
});
initialized = true;
settingsStatus.ready = true;
settingsStatus.error = null;
applySettingsVisuals();
} catch (error) {
settingsStatus.ready = true;
settingsStatus.error = String(error);
} finally {
settingsStatus.loading = false;
}
}
export function updateSettings(patch: Partial<Settings>) {
Object.assign(settingsState, patch);
queueSettingsSync();
}
export function resetSettings() {
Object.assign(settingsState, mergeSettings(null));
queueSettingsSync();
}
export function getMangaPrefs(mangaId: number): MangaPrefs {
return {
...DEFAULT_MANGA_PREFS,
...(settingsState.mangaPrefs[mangaId] ?? {}),
};
}
export function updateMangaPrefs(mangaId: number, patch: Partial<MangaPrefs>) {
settingsState.mangaPrefs = {
...settingsState.mangaPrefs,
[mangaId]: {
...(settingsState.mangaPrefs[mangaId] ?? {}),
...patch,
},
};
queueSettingsSync();
}
export function clearMangaPrefs(mangaId: number) {
const next = {...settingsState.mangaPrefs};
delete next[mangaId];
settingsState.mangaPrefs = next;
queueSettingsSync();
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Tracker } from '$lib/types'
import type { Tracker } from '$lib/types/index'
export const trackingState = $state({
trackers: [] as Tracker[],
+28 -539
View File
@@ -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';
+1 -1
View File
@@ -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
View File
@@ -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,
};
+103
View File
@@ -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>
+333
View File
@@ -0,0 +1,333 @@
<script lang="ts">
interface MenuItem {
label: string
icon?: any
onClick: () => void
danger?: boolean
disabled?: boolean
separator?: never
children?: MenuEntry[]
}
interface MenuSeparator {
separator: true
}
type MenuEntry = MenuItem | MenuSeparator
interface Props {
x: number
y: number
items: MenuEntry[]
onClose: () => void
}
let { x, y, items, onClose }: Props = $props()
let focused = $state(-1)
let el = $state<HTMLDivElement | undefined>(undefined)
let measured = $state(false)
let pos = $state({ left: 0, top: 0 })
let subOpen = $state(-1)
let subEls = $state<(HTMLDivElement | null)[]>([])
const actionable = $derived(
items
.map((_, index) => index)
.filter((index) => !('separator' in items[index]) && !(items[index] as MenuItem).disabled)
)
$effect(() => {
if (actionable.length && focused === -1) focused = actionable[0]
})
function getZoom(): number {
const raw = parseFloat(document.documentElement.style.zoom || '1') || 1
return raw > 10 ? raw / 100 : raw
}
$effect(() => {
if (!el) return
const zoom = getZoom()
const style = getComputedStyle(document.documentElement)
const sidebarWidth = parseFloat(style.getPropertyValue('--sidebar-width')) || 52
const titlebarHeight = parseFloat(style.getPropertyValue('--titlebar-height')) || 36
const viewportWidth = window.innerWidth / zoom
const viewportHeight = window.innerHeight / zoom
const screenX = x / zoom - sidebarWidth / zoom
const screenY = y / zoom - titlebarHeight / zoom
const menuWidth = el.offsetWidth
const menuHeight = el.offsetHeight
pos = {
left: Math.max(4, screenX + menuWidth > viewportWidth ? screenX - menuWidth : screenX),
top: Math.max(4, screenY + menuHeight > viewportHeight ? screenY - menuHeight : screenY),
}
measured = true
})
$effect(() => {
if (subOpen < 0) return
const submenu = subEls[subOpen]
if (!submenu) return
requestAnimationFrame(() => {
const zoom = getZoom()
const viewportWidth = window.innerWidth / zoom
const rect = submenu.getBoundingClientRect()
if (rect.right / zoom > viewportWidth) submenu.classList.add('sub-flip')
else submenu.classList.remove('sub-flip')
})
})
function handlePointerOutside(target: EventTarget | null) {
const inMain = el?.contains(target as Node)
const inSubmenu = subOpen >= 0 && subEls[subOpen]?.contains(target as Node)
if (!inMain && !inSubmenu) onClose()
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation()
if (subOpen >= 0) subOpen = -1
else onClose()
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
const current = actionable.indexOf(focused)
focused = actionable[(current + 1) % actionable.length] ?? actionable[0]
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const current = actionable.indexOf(focused)
focused = actionable[(current - 1 + actionable.length) % actionable.length] ?? actionable[0]
return
}
if (event.key === 'ArrowRight' && focused >= 0) {
const item = items[focused] as MenuItem
if (item.children?.length) subOpen = focused
return
}
if (event.key === 'ArrowLeft') {
subOpen = -1
return
}
if (event.key === 'Enter' && focused >= 0) {
event.preventDefault()
const item = items[focused] as MenuItem
if (item.children?.length) {
subOpen = focused
return
}
if (!item.disabled) {
item.onClick()
onClose()
}
}
}
$effect(() => {
const onMouseDown = (event: MouseEvent) => handlePointerOutside(event.target)
const onTouchStart = (event: TouchEvent) => handlePointerOutside(event.target)
document.addEventListener('mousedown', onMouseDown, true)
document.addEventListener('touchstart', onTouchStart, true)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onMouseDown, true)
document.removeEventListener('touchstart', onTouchStart, true)
document.removeEventListener('keydown', onKey, true)
}
})
</script>
<div bind:this={el} class="menu" role="menu" tabindex="-1" style={`left:${pos.left}px;top:${pos.top}px;visibility:${measured ? 'visible' : 'hidden'}`} oncontextmenu={(event) => event.preventDefault()}>
{#each items as item, index (index)}
{#if 'separator' in item}
<div class="sep"></div>
{:else}
{@const menuItem = item as MenuItem}
{@const hasSubmenu = !!menuItem.children?.length}
<div class="item-wrap">
<button
class="item"
class:danger={menuItem.danger}
class:disabled={menuItem.disabled}
class:focused={focused === index}
class:has-sub={hasSubmenu}
disabled={menuItem.disabled}
onclick={() => {
if (menuItem.disabled) return
if (hasSubmenu) {
subOpen = subOpen === index ? -1 : index
return
}
menuItem.onClick()
onClose()
}}
onmouseenter={() => {
if (menuItem.disabled) return
focused = index
subOpen = hasSubmenu ? index : -1
}}
onmouseleave={() => {
focused = -1
}}
>
<span class="icon" class:icon-danger={menuItem.danger}>
{#if menuItem.icon}
<menuItem.icon size={13} weight="light" />
{/if}
</span>
<span class="label">{menuItem.label}</span>
{#if hasSubmenu}
<span class="sub-arrow"></span>
{/if}
</button>
{#if hasSubmenu && subOpen === index}
<div bind:this={subEls[index]} class="menu submenu" role="menu" tabindex="-1" onmouseenter={() => { subOpen = index }}>
{#each menuItem.children as child, childIndex (childIndex)}
{#if 'separator' in child}
<div class="sep"></div>
{:else}
{@const childItem = child as MenuItem}
<button class="item" class:danger={childItem.danger} class:disabled={childItem.disabled} disabled={childItem.disabled} onclick={() => {
if (childItem.disabled) return
childItem.onClick()
onClose()
}}>
<span class="icon" class:icon-danger={childItem.danger}>
{#if childItem.icon}
<childItem.icon size={13} weight="light" />
{/if}
</span>
<span class="label">{childItem.label}</span>
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
<style>
.menu {
position: fixed;
z-index: 200;
min-width: 190px;
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
background: var(--bg-raised);
padding: var(--sp-1);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.35), 0 16px 40px rgba(0, 0, 0, 0.25);
animation: scaleIn 0.1s ease both;
transform-origin: top left;
}
.item-wrap {
position: relative;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
z-index: 201;
animation: scaleIn 0.08s ease both;
transform-origin: top left;
}
:global(.submenu.sub-flip) {
left: auto;
right: 100%;
transform-origin: top right;
}
.item {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 5px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: left;
background: none;
border: none;
outline: none;
transition: background var(--t-fast), color var(--t-fast);
}
.item:hover:not(.disabled),
.item.focused:not(.disabled) {
background: var(--bg-overlay);
color: var(--text-primary);
}
.item.danger {
color: var(--color-error);
}
.item.danger:hover:not(.disabled),
.item.danger.focused:not(.disabled) {
background: var(--color-error-bg);
}
.item.disabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-faint);
border-radius: var(--radius-sm);
}
.icon-danger {
color: var(--color-error);
opacity: 0.7;
}
.label {
flex: 1;
line-height: 1.3;
}
.sub-arrow {
font-size: 14px;
color: var(--text-faint);
line-height: 1;
margin-left: auto;
padding-left: var(--sp-1);
}
.sep {
height: 1px;
background: var(--border-dim);
margin: 3px var(--sp-1);
}
</style>
+189
View File
@@ -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>
+220
View File
@@ -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>
+152
View File
@@ -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>
+202
View File
@@ -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>
+171
View File
@@ -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) }
}
+14
View File
@@ -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'
}
}
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { BookmarkSimple, BookOpen, DownloadSimple } from 'phosphor-svelte'
import type { Manga } from '$lib/types/manga'
import Thumbnail from '$lib/ui/manga/Thumbnail.svelte'
interface Props {
manga: Manga
href?: string
compact?: boolean
showMeta?: boolean
}
let {
manga,
href,
compact = false,
showMeta = true,
}: Props = $props()
const unreadCount = $derived(manga.unreadCount ?? 0)
const downloadCount = $derived(manga.downloadCount ?? 0)
const bookmarkCount = $derived(manga.bookmarkCount ?? 0)
</script>
{#if href}
<a class:compact class="card" {href} aria-label={manga.title}>
<Thumbnail class="manga-card-cover" src={manga.thumbnailUrl} alt={manga.title} />
<div class="body">
<p class="title">{manga.title}</p>
{#if showMeta}
<div class="meta">
<span><BookOpen size={12} weight="light" /> {unreadCount}</span>
<span><DownloadSimple size={12} weight="light" /> {downloadCount}</span>
<span><BookmarkSimple size={12} weight="light" /> {bookmarkCount}</span>
</div>
{/if}
{#if manga.source?.displayName}
<p class="source">{manga.source.displayName}</p>
{/if}
</div>
</a>
{:else}
<div class:compact class="card">
<Thumbnail class="manga-card-cover" src={manga.thumbnailUrl} alt={manga.title} />
<div class="body">
<p class="title">{manga.title}</p>
{#if showMeta}
<div class="meta">
<span><BookOpen size={12} weight="light" /> {unreadCount}</span>
<span><DownloadSimple size={12} weight="light" /> {downloadCount}</span>
<span><BookmarkSimple size={12} weight="light" /> {bookmarkCount}</span>
</div>
{/if}
{#if manga.source?.displayName}
<p class="source">{manga.source.displayName}</p>
{/if}
</div>
</div>
{/if}
<style>
.card {
display: flex;
flex-direction: column;
gap: var(--sp-3);
min-width: 0;
padding: var(--sp-3);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-overlay) 75%, transparent), transparent),
var(--bg-raised);
transition: border-color var(--t-base), transform var(--t-base), background var(--t-base);
}
.card:hover {
border-color: var(--border-strong);
transform: translateY(-2px);
}
.card.compact {
gap: var(--sp-2);
padding: var(--sp-2);
}
:global(.manga-card-cover) {
aspect-ratio: 3 / 4;
width: 100%;
border-radius: var(--radius-lg);
object-fit: cover;
background: var(--bg-overlay);
}
.body {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.title {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
line-height: var(--leading-snug);
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-2xs);
}
.meta span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.source {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
children?: Snippet
class?: string
}
let { children, class: className = '' }: Props = $props()
</script>
<div class={`hover-3d ${className}`.trim()}>
<div class="hover-3d-content">
{@render children?.()}
</div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<style>
.hover-3d {
display: inline-grid;
perspective: 75rem;
--transform: 0, 0;
--shine: 100% 100%;
--shadow: 0rem 0rem 0rem;
--ease-out: linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
--ease-hover: linear(0, 0.708 15.2%, 0.927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);
filter:
drop-shadow(var(--shadow) 0.1rem #00000020)
drop-shadow(var(--shadow) 0.2rem #00000015)
drop-shadow(var(--shadow) 0.3rem #00000010);
transition: filter ease-out 400ms;
}
.hover-3d > :nth-child(n + 2) {
isolation: isolate;
z-index: 1;
scale: 1.2;
}
.hover-3d > :nth-child(2) { grid-area: 1 / 1 / 2 / 2; }
.hover-3d > :nth-child(3) { grid-area: 1 / 2 / 2 / 3; }
.hover-3d > :nth-child(4) { grid-area: 1 / 3 / 2 / 4; }
.hover-3d > :nth-child(5) { grid-area: 2 / 1 / 3 / 2; }
.hover-3d > :nth-child(6) { grid-area: 2 / 3 / 3 / 4; }
.hover-3d > :nth-child(7) { grid-area: 3 / 1 / 4 / 2; }
.hover-3d > :nth-child(8) { grid-area: 3 / 2 / 4 / 3; }
.hover-3d > :nth-child(9) { grid-area: 3 / 3 / 4 / 4; }
.hover-3d-content {
grid-area: 1 / 1 / 4 / 4;
overflow: hidden;
border-radius: inherit;
position: relative;
transform: rotate3d(var(--transform), 0, 10deg);
transition: transform var(--ease-out) 500ms, scale var(--ease-out) 500ms, outline-color ease-out 500ms;
outline: 0.5px solid transparent;
outline-offset: -1px;
}
.hover-3d-content::before {
content: '';
pointer-events: none;
position: absolute;
inset: 0;
z-index: 1;
opacity: 0;
filter: blur(0.75rem);
background-image: radial-gradient(circle at 50%, rgba(255, 255, 255, 0.18) 10%, transparent 50%);
translate: var(--shine);
transition: translate ease-out 400ms, opacity ease-out 400ms;
}
.hover-3d:hover {
--ease-out: var(--ease-hover);
}
.hover-3d:hover > .hover-3d-content {
scale: 1.05;
outline-color: rgba(255, 255, 255, 0.07);
}
.hover-3d:hover > .hover-3d-content::before {
opacity: 1;
}
.hover-3d:has(> :nth-child(2):hover) { --transform: -1, 1; --shine: 0% 0%; --shadow: -0.5rem -0.5rem; }
.hover-3d:has(> :nth-child(3):hover) { --transform: -1, 0; --shine: 100% 0%; --shadow: 0rem -0.5rem; }
.hover-3d:has(> :nth-child(4):hover) { --transform: -1, -1; --shine: 200% 0%; --shadow: 0.5rem -0.5rem; }
.hover-3d:has(> :nth-child(5):hover) { --transform: 0, 1; --shine: 0% 100%; --shadow: -0.5rem 0rem; }
.hover-3d:has(> :nth-child(6):hover) { --transform: 0, -1; --shine: 200% 100%; --shadow: 0.5rem 0rem; }
.hover-3d:has(> :nth-child(7):hover) { --transform: 1, 1; --shine: 0% 200%; --shadow: -0.5rem 0.5rem; }
.hover-3d:has(> :nth-child(8):hover) { --transform: 1, 0; --shine: 100% 200%; --shadow: 0rem 0.5rem; }
.hover-3d:has(> :nth-child(9):hover) { --transform: 1, -1; --shine: 200% 200%; --shadow: 0.5rem 0.5rem; }
</style>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import { getAuthMode } from '$lib/core/auth'
import { loadImageObjectUrl, resolveImageUrl } from '$lib/core/image'
interface Props {
src: string | null | undefined
alt?: string
class?: string
loading?: 'lazy' | 'eager'
decoding?: 'sync' | 'async' | 'auto'
draggable?: boolean
}
let {
src,
alt = '',
class: className = '',
loading = 'lazy',
decoding = 'async',
draggable = false,
}: Props = $props()
let objectUrl = $state<string | null>(null)
let failed = $state(false)
const resolvedSrc = $derived(objectUrl ?? resolveImageUrl(src) ?? '')
$effect(() => {
const source = src
failed = false
if (!source || getAuthMode() === 'NONE') {
if (objectUrl?.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl)
}
objectUrl = null
return
}
let active = true
const controller = new AbortController()
const previousUrl = objectUrl
void loadImageObjectUrl(source, controller.signal)
.then((nextUrl) => {
if (!active) {
if (nextUrl.startsWith('blob:')) URL.revokeObjectURL(nextUrl)
return
}
if (previousUrl?.startsWith('blob:') && previousUrl !== nextUrl) {
URL.revokeObjectURL(previousUrl)
}
objectUrl = nextUrl
})
.catch(() => {
if (!active) return
objectUrl = null
failed = true
})
return () => {
active = false
controller.abort()
if (objectUrl?.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl)
}
}
})
</script>
{#if resolvedSrc && !failed}
<img src={resolvedSrc} {alt} class={className} {loading} {decoding} {draggable} onerror={() => { failed = true }} />
{:else}
<div class={`placeholder ${className}`.trim()} aria-label={alt || 'Thumbnail unavailable'} role="img">
<span>no cover</span>
</div>
{/if}
<style>
.placeholder {
display: flex;
align-items: center;
justify-content: center;
background:
linear-gradient(160deg, color-mix(in srgb, var(--accent-muted) 60%, transparent), transparent 55%),
linear-gradient(180deg, var(--bg-raised), var(--bg-overlay));
color: var(--text-faint);
}
.placeholder span {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
</style>

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