Compare commits

...

4 Commits

Author SHA1 Message Date
Youwes09 e9929747d2 Chore: Fix Zoom & Attempt Theming 2026-05-25 12:31:23 -05:00
Youwes09 cbdf9e8be1 Fix: Stub Capacitor, Fix Tauri-Build, Fix Static-Build 2026-05-25 10:21:12 -05:00
Youwes09 d9a9427e3b Chore: Port over Settings (Barely Works) 2026-05-24 20:31:46 -05:00
Youwes09 ae5d9748c7 Chore: Port over Home & Fix Suwayomi-Server Detection on Web 2026-05-24 12:09:29 -05:00
114 changed files with 12310 additions and 2101 deletions
+12 -2
View File
@@ -13,7 +13,7 @@
"build:node": "MOKU_TARGET=node vite build",
"build:tauri": "MOKU_TARGET=static vite build",
"build:android": "MOKU_TARGET=static vite build",
"tauri:dev": "tauri dev",
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
"tauri:build": "tauri build"
},
"devDependencies": {
@@ -21,15 +21,25 @@
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2.0.0",
"@tauri-apps/cli": "^2.1.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"@capacitor/browser": "^8.0.3",
"@capacitor/core": "^8.3.4",
"@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-http": "^2.5.9",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3",
"capacitor-native-biometric": "^4.2.2",
"phosphor-svelte": "^3.1.0"
}
}
+163 -58
View File
@@ -8,12 +8,42 @@ importers:
.:
dependencies:
'@capacitor/browser':
specifier: ^8.0.3
version: 8.0.3(@capacitor/core@8.3.4)
'@capacitor/core':
specifier: ^8.3.4
version: 8.3.4
'@capacitor/filesystem':
specifier: ^8.1.2
version: 8.1.2(@capacitor/core@8.3.4)
'@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-http':
specifier: ^2.5.9
version: 2.5.9
'@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-shell':
specifier: ^2.3.5
version: 2.3.5
'@tauri-apps/plugin-store':
specifier: ^2.4.3
version: 2.4.3
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)
@@ -31,8 +61,8 @@ importers:
specifier: ^7.0.0
version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10)
'@tauri-apps/cli':
specifier: ^2.0.0
version: 2.11.2
specifier: ^2.1.0
version: 2.1.0
svelte:
specifier: ^5.55.2
version: 5.55.5(@typescript-eslint/types@8.57.1)
@@ -48,6 +78,25 @@ importers:
packages:
'@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/core@8.3.4':
resolution: {integrity: sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==}
'@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==}
@@ -401,85 +450,96 @@ packages:
'@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
'@tauri-apps/cli-darwin-arm64@2.11.2':
resolution: {integrity: sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==}
'@tauri-apps/cli-darwin-arm64@2.1.0':
resolution: {integrity: sha512-ESc6J6CE8hl1yKH2vJ+ALF+thq4Be+DM1mvmTyUCQObvezNCNhzfS6abIUd3ou4x5RGH51ouiANeT3wekU6dCw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.11.2':
resolution: {integrity: sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==}
'@tauri-apps/cli-darwin-x64@2.1.0':
resolution: {integrity: sha512-TasHS442DFs8cSH2eUQzuDBXUST4ECjCd0yyP+zZzvAruiB0Bg+c8A+I/EnqCvBQ2G2yvWLYG8q/LI7c87A5UA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.2':
resolution: {integrity: sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==}
'@tauri-apps/cli-linux-arm-gnueabihf@2.1.0':
resolution: {integrity: sha512-aP7ZBGNL4ny07Cbb6kKpUOSrmhcIK2KhjviTzYlh+pPhAptxnC78xQGD3zKQkTi2WliJLPmBYbOHWWQa57lQ9w==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.11.2':
resolution: {integrity: sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==}
'@tauri-apps/cli-linux-arm64-gnu@2.1.0':
resolution: {integrity: sha512-ZTdgD5gLeMCzndMT2f358EkoYkZ5T+Qy6zPzU+l5vv5M7dHVN9ZmblNAYYXmoOuw7y+BY4X/rZvHV9pcGrcanQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.11.2':
resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==}
'@tauri-apps/cli-linux-arm64-musl@2.1.0':
resolution: {integrity: sha512-NzwqjUCilhnhJzusz3d/0i0F1GFrwCQbkwR6yAHUxItESbsGYkZRJk0yMEWkg3PzFnyK4cWTlQJMEU52TjhEzA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.11.2':
resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
'@tauri-apps/cli-linux-x64-gnu@2.1.0':
resolution: {integrity: sha512-TyiIpMEtZxNOQmuFyfJwaaYbg3movSthpBJLIdPlKxSAB2BW0VWLY3/ZfIxm/G2YGHyREkjJvimzYE0i37PnMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.11.2':
resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==}
'@tauri-apps/cli-linux-x64-musl@2.1.0':
resolution: {integrity: sha512-/dQd0TlaxBdJACrR72DhynWftzHDaX32eBtS5WBrNJ+nnNb+znM3gON6nJ9tSE9jgDa6n1v2BkI/oIDtypfUXw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.11.2':
resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==}
'@tauri-apps/cli-win32-arm64-msvc@2.1.0':
resolution: {integrity: sha512-NdQJO7SmdYqOcE+JPU7bwg7+odfZMWO6g8xF9SXYCMdUzvM2Gv/AQfikNXz5yS7ralRhNFuW32i5dcHlxh4pDg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.11.2':
resolution: {integrity: sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==}
'@tauri-apps/cli-win32-ia32-msvc@2.1.0':
resolution: {integrity: sha512-f5h8gKT/cB8s1ticFRUpNmHqkmaLutT62oFDB7N//2YTXnxst7EpMIn1w+QimxTvTk2gcx6EcW6bEk/y2hZGzg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.11.2':
resolution: {integrity: sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==}
'@tauri-apps/cli-win32-x64-msvc@2.1.0':
resolution: {integrity: sha512-P/+LrdSSb5Xbho1LRP4haBjFHdyPdjWvGgeopL96OVtrFpYnfC+RctB45z2V2XxqFk3HweDDxk266btjttfjGw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.11.2':
resolution: {integrity: sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==}
'@tauri-apps/cli@2.1.0':
resolution: {integrity: sha512-K2VhcKqBhAeS5pNOVdnR/xQRU6jwpgmkSL2ejHXcl0m+kaTggT0WRDQnFtPq6NljA7aE03cvwsbCAoFG7vtkJw==}
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-http@2.5.9':
resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==}
'@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-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@tauri-apps/plugin-store@2.4.3':
resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -512,6 +572,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'}
@@ -841,6 +904,25 @@ packages:
snapshots:
'@capacitor/browser@8.0.3(@capacitor/core@8.3.4)':
dependencies:
'@capacitor/core': 8.3.4
'@capacitor/core@3.9.0':
dependencies:
tslib: 2.8.1
'@capacitor/core@8.3.4':
dependencies:
tslib: 2.8.1
'@capacitor/filesystem@8.1.2(@capacitor/core@8.3.4)':
dependencies:
'@capacitor/core': 8.3.4
'@capacitor/synapse': 1.0.4
'@capacitor/synapse@1.0.4': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -1098,57 +1180,77 @@ snapshots:
'@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.11.2':
'@tauri-apps/cli-darwin-arm64@2.1.0':
optional: true
'@tauri-apps/cli-darwin-x64@2.11.2':
'@tauri-apps/cli-darwin-x64@2.1.0':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.2':
'@tauri-apps/cli-linux-arm-gnueabihf@2.1.0':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.11.2':
'@tauri-apps/cli-linux-arm64-gnu@2.1.0':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.11.2':
'@tauri-apps/cli-linux-arm64-musl@2.1.0':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.11.2':
'@tauri-apps/cli-linux-x64-gnu@2.1.0':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
'@tauri-apps/cli-linux-x64-musl@2.1.0':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.11.2':
'@tauri-apps/cli-win32-arm64-msvc@2.1.0':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.11.2':
'@tauri-apps/cli-win32-ia32-msvc@2.1.0':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.11.2':
'@tauri-apps/cli-win32-x64-msvc@2.1.0':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.11.2':
optional: true
'@tauri-apps/cli@2.11.2':
'@tauri-apps/cli@2.1.0':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.11.2
'@tauri-apps/cli-darwin-x64': 2.11.2
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.2
'@tauri-apps/cli-linux-arm64-gnu': 2.11.2
'@tauri-apps/cli-linux-arm64-musl': 2.11.2
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.2
'@tauri-apps/cli-linux-x64-gnu': 2.11.2
'@tauri-apps/cli-linux-x64-musl': 2.11.2
'@tauri-apps/cli-win32-arm64-msvc': 2.11.2
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
'@tauri-apps/cli-darwin-arm64': 2.1.0
'@tauri-apps/cli-darwin-x64': 2.1.0
'@tauri-apps/cli-linux-arm-gnueabihf': 2.1.0
'@tauri-apps/cli-linux-arm64-gnu': 2.1.0
'@tauri-apps/cli-linux-arm64-musl': 2.1.0
'@tauri-apps/cli-linux-x64-gnu': 2.1.0
'@tauri-apps/cli-linux-x64-musl': 2.1.0
'@tauri-apps/cli-win32-arm64-msvc': 2.1.0
'@tauri-apps/cli-win32-ia32-msvc': 2.1.0
'@tauri-apps/cli-win32-x64-msvc': 2.1.0
'@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-http@2.5.9':
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-shell@2.3.5':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-store@2.4.3':
dependencies:
'@tauri-apps/api': 2.11.0
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -1171,6 +1273,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
@@ -1434,8 +1540,7 @@ snapshots:
totalist@3.0.1: {}
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
typescript@6.0.3: {}
+1
View File
@@ -4,6 +4,7 @@
"version": "0.9.4",
"identifier": "io.github.MokuProject.Moku",
"build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist",
"beforeBuildCommand": "pnpm build"
},
+1 -2
View File
@@ -1,6 +1,5 @@
{
"build": {
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev"
},
"app": {
@@ -13,4 +12,4 @@
"bundle": {
"externalBin": []
}
}
}
+4 -141
View File
@@ -1,3 +1,6 @@
@import '$lib/components/settings/Settings.css';
@import '$lib/styles/themes.css';
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
@@ -88,146 +91,6 @@
--z-settings: 150;
}
[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;
}
[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;
@@ -337,4 +200,4 @@ body {
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm);
}
}
+22 -33
View File
@@ -3,8 +3,8 @@ import { initPlatformService } from '$lib/platform-service'
import { appState } from '$lib/state/app.svelte'
import { configureAuth, probeServer } from '$lib/core/auth'
const SAVED_URL_KEY = 'moku_server_url'
const SAVED_AUTH_KEY = 'moku_auth_config'
const KEY_URL = 'moku_server_url'
const KEY_AUTH = 'moku_auth_config'
interface SavedAuth {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
@@ -12,24 +12,13 @@ interface SavedAuth {
pass?: string
}
function isTauri(): boolean {
return '__TAURI_INTERNALS__' in window
}
function isTauri(): boolean { return '__TAURI_INTERNALS__' in window }
function isCapacitor(): boolean { return 'Capacitor' in window }
function isCapacitor(): boolean {
return 'Capacitor' in window
}
function loadSavedServerUrl(): string {
return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567'
}
function loadSavedAuth(): SavedAuth {
try {
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' }
} catch {
return { mode: 'NONE' }
}
function detectPlatform(): 'tauri' | 'capacitor' | 'web' {
if (isTauri()) return 'tauri'
if (isCapacitor()) return 'capacitor'
return 'web'
}
async function resolvePlatformAdapter() {
@@ -60,30 +49,30 @@ async function boot() {
initRequestManager(serverAdapter)
initPlatformService(platformAdapter)
appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web'
appState.platform = detectPlatform()
appState.version = await platformAdapter.getVersion()
const savedUrl = loadSavedServerUrl()
const savedAuth = loadSavedAuth()
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567'
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH)
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' }
appState.serverUrl = savedUrl
appState.authMode = savedAuth.mode
if (isTauri() && platformAdapter.isSupported('server-management')) {
await platformAdapter.launchServer({ url: savedUrl }).catch(() => {})
}
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
await serverAdapter.connect({ baseUrl: savedUrl })
await serverAdapter.connect({
baseUrl: savedUrl,
credentials:
savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass
? { username: savedAuth.user, password: savedAuth.pass }
: undefined,
})
const probe = await probeServer()
if (probe === 'auth_required') {
appState.status = 'auth'
return
}
if (probe === 'unreachable') {
if (probe === 'auth_required') { appState.status = 'auth'; return }
if (probe === 'unreachable') {
appState.error = `Could not reach server at ${savedUrl}`
appState.status = 'error'
return
@@ -1,35 +1,10 @@
<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
}
}
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
function handleBypass() {
appState.authenticated = false
appState.status = 'ready'
bypassBoot(appState.authMode, boot.loginUser)
}
</script>
@@ -43,8 +18,8 @@
</span>
<p class="host">{appState.serverUrl || 'localhost:4567'}</p>
{#if loginError}
<p class="error">{loginError}</p>
{#if boot.loginError}
<p class="error">{boot.loginError}</p>
{/if}
<div class="fields">
@@ -52,28 +27,28 @@
class="input"
type="text"
placeholder="Username"
bind:value={loginUser}
disabled={loginBusy}
bind:value={boot.loginUser}
disabled={boot.loginBusy}
autocomplete="username"
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
/>
<input
class="input"
type="password"
placeholder="Password"
bind:value={loginPass}
disabled={loginBusy}
bind:value={boot.loginPass}
disabled={boot.loginBusy}
autocomplete="current-password"
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
/>
</div>
<button
class="btn"
onclick={handleLogin}
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}
onclick={submitLogin}
disabled={boot.loginBusy || !boot.loginUser.trim() || !boot.loginPass.trim()}
>
{loginBusy ? 'Signing in…' : 'Sign in'}
{boot.loginBusy ? 'Signing in…' : 'Sign in'}
</button>
<button class="btn btn--ghost" onclick={handleBypass}>Skip</button>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { app } from '$lib/state/app.svelte'
import {
House, Books, MagnifyingGlass, ClockCounterClockwise,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
@@ -51,7 +52,7 @@
</nav>
<div class="bottom">
<button class="settings-btn" onclick={() => goto('/settings')} title="Settings">
<button class="settings-btn" onclick={() => app.setSettingsOpen(true)} title="Settings">
<GearSix size={18} weight="light" />
</button>
</div>
@@ -1,7 +1,7 @@
<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'
import { mountCardCanvas, ringGeometry, animateRingProgress } from '$lib/components/chrome/splashCanvas'
interface Props {
mode?: 'loading' | 'idle'
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { detectOs } from '$lib/ui/chrome/titlebarOs'
import type { OsKind } from '$lib/ui/chrome/titlebarOs'
import { detectOs } from '$lib/components/chrome/titlebarOs'
import type { OsKind } from '$lib/components/chrome/titlebarOs'
let { onClose }: { onClose: () => void } = $props()
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from 'phosphor-svelte'
import { timeAgo } from '$lib/components/home/homeHelpers'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
let {
entries,
libraryManga,
onresume,
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[]
libraryManga: Manga[]
onresume: (entry: HistoryEntry) => void
onviewhistory: () => void
onopenlibrary: () => void
} = $props()
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? ''
}
</script>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if entries.length > 0}
<button class="see-all" onclick={onviewhistory}>
Full History <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}>
<img src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ''}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="placeholder">
{#each Array(5) as _, i}
<div class="row row-sk">
<div class="sk-thumb"></div>
<div class="row-info">
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
</div>
<div class="sk sk-time"></div>
</div>
{/each}
<div class="placeholder-overlay">
<button class="placeholder-cta" onclick={onopenlibrary}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
}
.section-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.see-all {
display: flex; align-items: center; gap: 4px;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0;
transition: color var(--t-base);
}
.see-all:hover { color: var(--accent-fg); }
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 7px var(--sp-2); border-radius: var(--radius-md);
border: 1px solid transparent; background: none;
text-align: left; cursor: pointer; width: 100%;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
.row-thumb {
width: 33px; height: 48px; border-radius: var(--radius-sm);
object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim);
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-sub {
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-time {
font-family: var(--font-ui); font-size: var(--text-sm);
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.row-sk { cursor: default; pointer-events: none; }
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-title { height: 11px; margin-bottom: 5px; }
.sk-sub { height: 9px; }
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
.placeholder { position: relative; }
.placeholder-overlay {
position: absolute; left: 0; right: 0; top: 0; bottom: -1px;
display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4);
pointer-events: none;
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%);
}
.placeholder-cta {
pointer-events: all;
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 16px; border-radius: var(--radius-full);
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.13);
color: rgba(255,255,255,0.62); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
@@ -0,0 +1,207 @@
<script lang="ts">
let {
dailyReadCounts,
}: {
dailyReadCounts: Record<string, number>
} = $props()
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
if (count === 0) return 0
if (count === 1) return 1
if (count <= 3) return 2
if (count <= 6) return 3
return 4
}
let tip: { text: string; x: number; y: number } | null = $state(null)
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const label = cell.count === 0
? `No chapters — ${fmtDate(cell.dateStr)}`
: `${cell.count} chapter${cell.count !== 1 ? 's' : ''}${fmtDate(cell.dateStr)}`
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 }
}
function hideTip() { tip = null }
function fmtDate(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function localDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
let wrapEl: HTMLElement
let cellSize = $state(12)
let numWeeks = $state(26)
const GAP = 3
const DAY_GUTTER = 28
const LEGEND_H = 20
const MONTH_H = 14
const ROWS = 7
$effect(() => {
if (!wrapEl) return
const obs = new ResizeObserver(() => {
const h = wrapEl.clientHeight
const w = wrapEl.clientWidth
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS))
cellSize = cs
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)))
})
obs.observe(wrapEl)
return () => obs.disconnect()
})
const visibleWeeks = $derived((() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = localDateStr(today)
const endDow = today.getDay()
const weekEnd = new Date(today)
weekEnd.setDate(weekEnd.getDate() + (6 - endDow))
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = []
for (let wi = numWeeks - 1; wi >= 0; wi--) {
const week: typeof weeks[0] = []
for (let di = 0; di < 7; di++) {
const d = new Date(weekEnd)
d.setDate(d.getDate() - wi * 7 - (6 - di))
const dateStr = localDateStr(d)
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today })
}
weeks.push(week)
}
return weeks
})())
const monthLabels = $derived((() => {
const labels: { label: string; colIndex: number }[] = []
let lastMonth = -1
visibleWeeks.forEach((week, ci) => {
const first = week[0]
if (!first) return
const m = new Date(first.dateStr + 'T00:00:00').getMonth()
if (m !== lastMonth) {
labels.push({ label: new Date(first.dateStr + 'T00:00:00').toLocaleDateString('en-US', { month: 'short' }), colIndex: ci })
lastMonth = m
}
})
return labels
})())
const DAY_LABELS = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat']
</script>
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
<div class="month-row">
<div class="day-gutter"></div>
<div class="month-cells">
{#each visibleWeeks as _week, ci}
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
<div class="month-label">{lbl?.label ?? ''}</div>
{/each}
</div>
</div>
<div class="grid-row">
<div class="day-labels">
{#each DAY_LABELS as d}
<span class="day-label">{d}</span>
{/each}
</div>
<div class="cell-grid">
{#each visibleWeeks as week}
<div class="week-col">
{#each week as cell}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<button
class="cell intensity-{intensity(cell.count)}"
class:cell-today={cell.isToday}
class:cell-future={cell.isFuture}
onmouseover={(e) => showTip(e, cell)}
onmouseleave={hideTip}
aria-label="{cell.count} chapters on {cell.dateStr}"
></button>
{/each}
</div>
{/each}
</div>
</div>
<div class="legend">
<span class="legend-label">Less</span>
{#each [0, 1, 2, 3, 4] as lvl}
<div class="legend-cell intensity-{lvl}"></div>
{/each}
<span class="legend-label">More</span>
</div>
</div>
{#if tip}
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
{/if}
<style>
.heatmap-wrap {
display: flex; flex-direction: column; justify-content: center;
gap: 4px; width: 100%; height: 100%;
min-width: 0; min-height: 0; overflow: hidden; box-sizing: border-box;
}
.month-row { display: flex; gap: 4px; flex-shrink: 0; }
.day-gutter { width: 28px; flex-shrink: 0; }
.month-cells {
display: grid; grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px; overflow: hidden;
}
.month-label {
font-family: var(--font-ui); font-size: 9px; color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding-left: 1px; white-space: nowrap; overflow: hidden;
}
.grid-row { display: flex; gap: 4px; align-items: flex-start; flex-shrink: 0; }
.day-labels { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; width: 28px; }
.day-label {
font-family: var(--font-ui); font-size: 8px; color: var(--text-faint);
letter-spacing: var(--tracking-wide); height: var(--cell); line-height: var(--cell); text-align: right;
}
.cell-grid {
display: grid; grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px; overflow: visible; padding: 4px; margin: -4px;
}
.week-col { display: flex; flex-direction: column; gap: 3px; }
.cell {
width: var(--cell); height: var(--cell); border-radius: 3px;
border: none; padding: 0; cursor: pointer;
transition: filter var(--t-fast), transform var(--t-fast);
}
.cell:hover:not(.cell-future) { filter: brightness(1.5); transform: scale(1.2); z-index: 1; position: relative; }
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
.legend { display: flex; align-items: center; gap: 3px; justify-content: flex-end; flex-shrink: 0; padding-top: 2px; }
.legend-cell { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.legend-label { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.heatmap-tip {
position: fixed; transform: translate(-50%, -100%);
background: var(--bg-overlay); border: 1px solid var(--border-base);
border-radius: var(--radius-sm); padding: 4px 8px;
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary);
letter-spacing: var(--tracking-wide); white-space: nowrap; pointer-events: none;
z-index: 9999; box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
</style>
@@ -0,0 +1,129 @@
<script lang="ts">
import { MagnifyingGlass, X as XIcon } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
let {
slotIndex,
libraryManga,
loading,
onpin,
onclose,
}: {
slotIndex: 1 | 2 | 3
libraryManga: Manga[]
loading: boolean
onpin: (m: Manga) => void
onclose: () => void
} = $props()
let search = $state('')
function focusEl(node: HTMLElement) { node.focus() }
const results = $derived(
search.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20)
)
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose() }}
onkeydown={(e) => { if (e.key === 'Escape') onclose() }}
>
<div class="modal">
<div class="modal-header">
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
</div>
<div class="list">
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if results.length === 0}
<p class="empty-msg">No results</p>
{:else}
{#each results as m (m.id)}
<button class="list-row" onclick={() => onpin(m)}>
<img src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<div class="row-info">
<span class="row-title">{m.title}</span>
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.62);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.modal {
width: min(460px, calc(100vw - 48px)); max-height: 68vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.modal-close {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.search-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
.search-input::placeholder { color: var(--text-faint); }
.list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.list::-webkit-scrollbar { display: none; }
.empty-msg {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center;
}
.list-row {
display: flex; align-items: center; gap: var(--sp-3); width: 100%;
padding: 8px var(--sp-3); border-radius: var(--radius-md);
border: none; background: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.list-row:hover { background: var(--bg-raised); }
.row-thumb {
height: 50px; width: 35px; aspect-ratio: 1/1.42;
border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0;
border: 1px solid var(--border-dim); background: var(--bg-raised); display: block;
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+452
View File
@@ -0,0 +1,452 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte'
import { goto } from '$app/navigation'
import { timeAgo } from '$lib/components/home/homeHelpers'
import type { Manga } from '$lib/types'
import type { Chapter } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
interface HeroSlot {
kind: 'continue' | 'pinned' | 'empty'
entry?: HistoryEntry
manga?: Manga
slotIndex: number
}
let {
resolvedSlots,
activeIdx = $bindable(),
heroThumb,
heroTitle,
heroManga,
heroEntry,
heroMangaId,
heroChapters,
heroNewChapter,
loadingHeroChapters,
resuming,
onresume,
onopenchapter,
oncyclenext,
oncycleprev,
ongotoslot,
onopenpicker,
onunpin,
onviewall,
}: {
resolvedSlots: HeroSlot[]
activeIdx: number
heroThumb: string
heroTitle: string
heroManga: Manga | null | undefined
heroEntry: HistoryEntry | null
heroMangaId: number | null
heroChapters: Chapter[]
heroNewChapter: Chapter | null
loadingHeroChapters: boolean
resuming: boolean
onresume: () => void
onopenchapter: (ch: Chapter) => void
oncyclenext: () => void
oncycleprev: () => void
ongotoslot: (i: number) => void
onopenpicker: (i: 1 | 2 | 3) => void
onunpin: (i: 1 | 2 | 3) => void
onviewall: () => void
} = $props()
const activeSlot = $derived(resolvedSlots[activeIdx])
const TOTAL_SLOTS = 4
</script>
<div class="hero-stage">
{#key heroThumb}
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
{/key}
<div class="hero-scrim"></div>
<button
class="hero-cover-col"
onclick={onresume}
disabled={resuming || activeSlot?.kind === 'empty'}
aria-label={heroTitle ? `Resume ${heroTitle}` : 'No manga selected'}
>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === 'continue'}
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
{/if}
{:else}
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
{/if}
</button>
<div class="hero-details">
{#if activeSlot?.kind === 'empty'}
<p class="hero-empty-title">Nothing here yet</p>
<p class="hero-empty-sub">
{activeSlot.slotIndex === 0
? 'Read a manga to see it here'
: 'Pin a manga or keep reading to fill this slot'}
</p>
{#if activeSlot.slotIndex !== 0}
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
<PushPin size={11} weight="fill" /> Pin manga
</button>
{/if}
{:else}
<div class="hero-tags">
{#if activeSlot?.kind === 'continue'}
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
{:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#if heroNewChapter && !heroNewChapter.isRead}
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button
class="hero-tag hero-tag-genre"
onclick={() => goto(`/browse?genre=${encodeURIComponent(g)}`)}
>{g}</button>
{/each}
</div>
<h2 class="hero-title">{heroTitle}</h2>
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
</p>
{/if}
{#if heroManga?.description}
<p class="hero-desc">{heroManga.description}</p>
{/if}
<div class="hero-actions">
{#if activeSlot?.kind === 'continue'}
<button class="hero-cta" onclick={onresume} disabled={resuming}>
<Play size={11} weight="fill" />{resuming ? 'Loading…' : 'Resume'}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => goto(`/series/${heroManga!.id}`)}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
{#if activeSlot?.slotIndex !== 0}
{#if activeSlot?.kind === 'pinned'}
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => onopenpicker(activeSlot!.slotIndex as 1 | 2 | 3)}>
<PushPin size={10} weight="light" /> Pin
</button>
{/if}
{/if}
</div>
{/if}
<div class="hero-nav-row">
<button class="hero-nav-btn" onclick={oncycleprev} aria-label="Previous">
<ArrowLeft size={12} weight="bold" />
</button>
<div class="hero-dots">
{#each resolvedSlots as slot, i}
<button
class="hero-dot"
class:active={activeIdx === i}
class:pinned={slot.kind === 'pinned'}
onclick={() => ongotoslot(i)}
aria-label="Slot {i + 1}"
></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={oncyclenext} aria-label="Next">
<ArrowRight size={12} weight="bold" />
</button>
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
</div>
</div>
<div class="hero-chapters">
<div class="hero-chapters-header">
<ListBullets size={11} weight="bold" /><span>Up Next</span>
</div>
{#if activeSlot?.kind === 'empty'}
<p class="hero-chapters-empty">No chapters to show</p>
{:else if loadingHeroChapters}
{#each Array(4) as _}
<div class="chapter-row-sk">
<div class="sk sk-num"></div>
<div class="sk-info">
<div class="sk sk-name"></div>
<div class="sk sk-meta"></div>
</div>
</div>
{/each}
{:else if heroChapters.length === 0}
<p class="hero-chapters-empty">No chapters available</p>
{:else}
{#each heroChapters as ch (ch.id)}
{@const isCurrent = heroEntry?.chapterId === ch.id}
<button
class="chapter-row"
class:chapter-row-current={isCurrent}
class:chapter-row-read={ch.isRead && !isCurrent}
onclick={() => onopenchapter(ch)}
>
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
<div class="ch-info">
<span class="ch-name">{ch.name}</span>
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
{:else if ch.isRead}
<span class="ch-meta ch-read">Read</span>
{:else if ch.uploadDate}
<span class="ch-meta">
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
{/if}
</div>
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={onviewall}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
<style>
.hero-stage {
position: relative;
display: flex;
align-items: stretch;
height: 374px;
overflow: hidden;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
}
.hero-backdrop {
position: absolute;
inset: -14px;
background-size: cover;
background-position: center 25%;
filter: blur(22px) saturate(2.4) brightness(0.4);
transform: scale(1.07);
pointer-events: none;
z-index: 0;
animation: backdropIn 0.5s ease both;
}
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-cover-col {
position: relative;
z-index: 2;
flex-shrink: 0;
width: 256px;
height: 374px;
overflow: hidden;
cursor: pointer;
background: var(--bg-raised);
padding: 0;
border: none;
border-right: 1px solid rgba(255,255,255,0.07);
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
.hero-cover-empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: var(--bg-overlay); color: var(--text-faint);
}
.cover-resume-hint {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #fff; background: rgba(0,0,0,0.38);
opacity: 0; transition: opacity 0.18s ease; pointer-events: none;
}
.hero-details {
position: relative; z-index: 2;
flex: 1; min-width: 0;
padding: var(--sp-5) var(--sp-5) var(--sp-4);
display: flex; flex-direction: column; gap: var(--sp-2);
overflow: hidden;
border-right: 1px solid rgba(255,255,255,0.05);
}
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 9px;
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
border: 1px solid rgba(255,255,255,0.13);
}
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
.hero-tag-new { background: rgba(74,222,128,0.15); color: #86efac; border-color: rgba(74,222,128,0.25); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
.hero-title {
font-size: var(--text-xl); font-weight: var(--weight-semibold);
color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
text-shadow: 0 2px 12px rgba(0,0,0,0.55); letter-spacing: -0.01em;
}
.hero-author {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.hero-progress {
display: flex; align-items: center; gap: 5px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.55); letter-spacing: var(--tracking-wide);
}
.hero-prog-page { color: rgba(255,255,255,0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.3); }
.hero-desc {
font-size: var(--text-xs); color: rgba(255,255,255,0.38); line-height: 1.6;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0;
}
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.48); flex-shrink: 0; }
.hero-empty-sub {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
}
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
.hero-cta {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 18px; border-radius: var(--radius-md);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
cursor: pointer; transition: filter var(--t-base); white-space: nowrap;
}
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
.hero-cta:disabled { opacity: 0.5; cursor: default; }
.hero-cta-ghost {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 14px; border-radius: var(--radius-md);
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.48); cursor: pointer;
transition: background var(--t-base), color var(--t-base); white-space: nowrap;
}
.hero-cta-ghost:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.82); }
.hero-nav-row {
display: flex; align-items: center; gap: var(--sp-2);
flex-shrink: 0; margin-top: auto; padding-top: var(--sp-3);
border-top: 1px solid rgba(255,255,255,0.07);
}
.hero-nav-btn {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.55); cursor: pointer; flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
}
.hero-nav-btn:hover { background: rgba(255,255,255,0.18); color: #fff; }
.hero-dots { display: flex; gap: 5px; align-items: center; }
.hero-dot {
width: 5px; height: 5px; border-radius: 50%;
background: rgba(255,255,255,0.2); border: none; cursor: pointer; padding: 0;
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
}
.hero-dot:hover { background: rgba(255,255,255,0.48); }
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
.hero-dot.pinned { background: rgba(168,132,232,0.5); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter {
font-family: var(--font-ui); font-size: 10px;
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); margin-left: auto;
}
.hero-chapters {
position: relative; z-index: 2;
width: clamp(180px, 30%, 232px); flex-shrink: 0;
display: flex; flex-direction: column;
padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden;
}
.hero-chapters-header {
display: flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wider); text-transform: uppercase;
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
.hero-chapters-empty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.22); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0;
}
.chapter-row {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 7px var(--sp-2); border-radius: var(--radius-sm);
background: none; border: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.chapter-row:hover { background: rgba(255,255,255,0.07); }
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
.ch-num {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.32); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px;
}
.chapter-row-current .ch-num { color: var(--accent-fg); }
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-row-read .ch-name { color: rgba(255,255,255,0.32); }
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); }
.ch-read { color: rgba(255,255,255,0.18); }
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
.sk-name { height: 11px; width: 85%; }
.sk-meta { height: 9px; width: 50%; }
.ch-view-all {
display: flex; align-items: center; gap: 4px; margin-top: auto;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide);
background: none; border: none; cursor: pointer;
padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base);
}
.ch-view-all:hover { color: var(--accent-fg); }
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import { Sparkle } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
let {
libraryManga,
history,
onopenrecommended,
}: {
libraryManga: Manga[]
history: HistoryEntry[]
onopenrecommended: (m: Manga) => void
} = $props()
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><Sparkle size={10} weight="bold" /> Recommended</span>
</div>
<p class="stub">Recommendations coming soon</p>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); flex-shrink: 0; }
.col-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.stub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from 'phosphor-svelte'
import { formatReadTime } from '$lib/components/home/homeHelpers'
import type { ReadingStats } from '$lib/state/home.svelte'
let {
stats,
updateCount,
}: {
stats: ReadingStats
updateCount: number
} = $props()
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
</div>
<div class="grid">
<div class="card">
<div class="icon-wrap fire"><Fire size={15} weight="fill" /></div>
<div class="body">
<span class="val">{stats.currentStreakDays}</span>
<span class="label">Day streak</span>
</div>
</div>
<div class="card">
<div class="icon-wrap accent"><BookOpen size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalChaptersRead}</span>
<span class="label">Chapters read</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><Clock size={15} weight="light" /></div>
<div class="body">
<span class="val">{formatReadTime(stats.totalMinutesRead)}</span>
<span class="label">Read time</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><TrendUp size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalMangaRead}</span>
<span class="label">Series started</span>
</div>
</div>
<div class="card">
<div class="icon-wrap green"><Bell size={15} weight="light" /></div>
<div class="body">
<span class="val">{updateCount}</span>
<span class="label">New updates</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><CalendarBlank size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.longestStreakDays}d</span>
<span class="label">Best streak</span>
</div>
</div>
</div>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; }
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.col-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.card {
display: flex; align-items: center; gap: var(--sp-3);
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: var(--sp-3);
transition: border-color var(--t-fast);
}
.card:hover { border-color: var(--border-base); }
.icon-wrap {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: var(--radius-sm); flex-shrink: 0;
}
.fire { background: rgba(251,146,60,0.15); color: #fb923c; }
.accent { background: var(--accent-muted); color: var(--accent-fg); }
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
.green { background: rgba(34,197,94,0.12); color: #22c55e; }
.body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.val {
font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1;
}
.label {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
export function timeAgo(ts: number): string {
const diff = Date.now() - ts
const m = Math.floor(diff / 60000)
if (m < 1) return 'Just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 7) return `${d}d ago`
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function timeAgoRefresh(ts: number): string {
if (!ts) return ''
const diff = Date.now() - ts
const m = Math.floor(diff / 60000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function formatReadTime(mins: number): string {
if (mins < 1) return `${Math.round(mins * 60)}s`
if (mins < 60) return `${Math.round(mins)}m`
const h = Math.floor(mins / 60)
const r = Math.round(mins % 60)
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`
const d = Math.floor(h / 24)
const rh = h % 24
return rh === 0 ? `${d}d` : `${d}d ${rh}h`
}
export function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
;(e.currentTarget as HTMLElement).scrollLeft += e.deltaY
e.stopPropagation()
}
@@ -0,0 +1,170 @@
<script lang="ts">
import { Check, Funnel } from 'phosphor-svelte'
import type { MangaStatus } from '$lib/server-adapters/types'
interface Props {
status: MangaStatus | 'all'
unread: boolean
downloaded: boolean
bookmarked: boolean
hasActive: boolean
open: boolean
onToggle: () => void
onStatus: (s: MangaStatus | 'all') => void
onUnread: () => void
onDownloaded: () => void
onBookmarked: () => void
onClear: () => void
}
let {
status, unread, downloaded, bookmarked, hasActive, open,
onToggle, onStatus, onUnread, onDownloaded, onBookmarked, onClear,
}: Props = $props()
const STATUSES: [MangaStatus, string][] = [
['ONGOING', 'Ongoing'],
['COMPLETED', 'Completed'],
['ON_HIATUS', 'Hiatus'],
['CANCELLED', 'Cancelled'],
['PUBLISHING_FINISHED', 'Publishing finished'],
]
</script>
<div class="wrap">
<button
class="icon-btn"
class:active={hasActive}
title="Filter"
onclick={onToggle}
>
<Funnel size={15} weight={hasActive ? 'fill' : 'bold'} />
</button>
{#if open}
<div class="panel" role="menu">
<div class="panel-head">
<span class="panel-title">Filter</span>
{#if hasActive}
<button class="clear-btn" onclick={onClear}>Clear all</button>
{/if}
</div>
<div class="divider"></div>
<p class="section-label">Content</p>
{#each [
{ label: 'Unread', active: unread, handler: onUnread },
{ label: 'Downloaded', active: downloaded, handler: onDownloaded },
{ label: 'Bookmarked', active: bookmarked, handler: onBookmarked },
] as f}
<button
class="item"
class:item-active={f.active}
role="menuitem"
onclick={f.handler}
>
<span class="check" class:check-on={f.active}>
{#if f.active}<Check size={9} weight="bold" />{/if}
</span>
{f.label}
</button>
{/each}
<div class="divider"></div>
<p class="section-label">Status</p>
{#each STATUSES as [s, label]}
<button
class="item"
class:item-active={status === s}
role="menuitem"
onclick={() => onStatus(status === s ? 'all' : s)}
>
<span class="check" class:check-on={status === s}>
{#if status === s}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.wrap { position: relative; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-faint);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
min-width: 220px;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-1);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: fadeIn 0.1s ease both;
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px 4px;
}
.panel-title {
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
font-weight: var(--weight-medium, 500);
}
.clear-btn {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 0;
transition: color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); }
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); padding: 4px 8px 8px;
}
.item {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px 10px;
border-radius: var(--radius-sm); border: none;
background: transparent; color: var(--text-muted);
font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left;
transition: background var(--t-base), color var(--t-base);
}
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.item-active:hover { background: var(--accent-dim); }
.check {
width: 13px; height: 13px; border-radius: 2px;
border: 1px solid var(--border-strong);
background: transparent; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--bg-base);
transition: background var(--t-base), border-color var(--t-base);
}
.check-on { background: var(--accent); border-color: var(--accent); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,246 @@
<script lang="ts">
import { CheckSquare, Trash } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
interface Props {
items: Manga[]
loading: boolean
selectMode: boolean
selected: Set<number>
tab: string
onCardClick: (e: MouseEvent, m: Manga) => void
onSelectAll: () => void
onExitSelect: () => void
onBulkRemove: () => void
}
let {
items, loading, selectMode, selected, tab,
onCardClick, onSelectAll, onExitSelect, onBulkRemove,
}: Props = $props()
const THUMB_BASE = 'http://127.0.0.1:4567'
function coverUrl(m: Manga) {
const url = m.thumbnailUrl ?? ''
return url.startsWith('http') ? url : `${THUMB_BASE}${url}`
}
</script>
{#if selectMode}
<div class="select-bar">
<span class="sel-count">{selected.size} selected</span>
<button class="sel-text-btn" onclick={onSelectAll}>Select all</button>
<div class="sel-right">
<button
class="sel-action-btn sel-danger"
disabled={selected.size === 0}
onclick={onBulkRemove}
>
<Trash size={13} weight="bold" />
Remove
</button>
</div>
</div>
{/if}
<div
class="content"
role="presentation"
onclick={(e) => {
if (selectMode && !(e.target as HTMLElement).closest('.card')) onExitSelect()
}}
>
{#if loading}
<div class="grid">
{#each Array(12) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if items.length === 0}
<div class="empty">
{tab === 'downloaded'
? 'No downloaded manga.'
: 'No manga saved to library — browse sources to add some.'}
</div>
{:else}
<div class="grid">
{#each items as m (m.id)}
{@const isSelected = selected.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
<button
class="card"
class:card-selected={isSelected}
class:select-mode={selectMode}
onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => {
e.preventDefault()
onCardClick(e, m)
}}
>
<div class="cover-wrap" class:completed={isCompleted}>
<img
class="cover"
src={coverUrl(m)}
alt={m.title}
draggable="false"
loading="lazy"
/>
<div class="overlay">
<div class="badges">
{#if isCompleted}
<span class="badge badge-done">✓ Done</span>
{:else if m.unreadCount}
<span class="badge badge-unread">{m.unreadCount} new</span>
{/if}
{#if m.downloadCount}
<span class="badge badge-dl">{m.downloadCount}</span>
{/if}
</div>
</div>
{#if selectMode}
<div class="select-overlay" aria-hidden="true">
<div class="select-check" class:checked={isSelected}>
{#if isSelected}
<CheckSquare size={20} weight="fill" />
{:else}
<div class="check-empty"></div>
{/if}
</div>
</div>
{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{/if}
</div>
<style>
.content {
flex: 1; overflow-y: auto;
padding: var(--sp-5) var(--sp-6) var(--sp-6);
-webkit-overflow-scrolling: touch;
}
.select-bar {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-6);
background: var(--bg-raised); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; z-index: 10; position: relative;
animation: fadeIn 0.1s ease both;
}
.sel-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.sel-count {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap;
}
.sel-text-btn {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none;
cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm);
transition: color var(--t-base);
}
.sel-text-btn:hover { color: var(--text-primary); }
.sel-action-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-xs);
padding: 5px 10px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-muted); cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-danger:hover:not(:disabled) {
color: var(--color-error, #e05c5c);
border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent);
background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: var(--sp-4);
}
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:not(.select-mode):hover .cover-wrap {
transform: translateY(-3px);
border-color: var(--border-strong);
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
}
.card:not(.select-mode):hover .title { color: var(--text-primary); }
.card.select-mode { cursor: default; }
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
.card.card-selected .title { color: var(--accent-fg); }
.cover-wrap {
position: relative; aspect-ratio: 2/3; overflow: hidden;
border-radius: var(--radius-md); background: var(--bg-raised);
border: 1px solid var(--border-dim); will-change: transform;
transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1);
}
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.cover { width: 100%; height: 100%; object-fit: cover; display: block; }
.overlay {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
padding: 32px 6px 10px;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%);
opacity: 0; pointer-events: none;
transition: opacity 0.18s ease;
}
.card:not(.select-mode):hover .overlay { opacity: 1; }
.badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.badge {
font-family: var(--font-ui); font-size: 9.5px; font-weight: 700;
letter-spacing: 0.04em; line-height: 1; padding: 3px 7px;
border-radius: 20px; white-space: nowrap;
}
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.select-overlay {
position: absolute; inset: 0; z-index: 3;
background: rgba(0,0,0,0.18);
display: flex; align-items: flex-start; justify-content: flex-end;
padding: 6px; pointer-events: none;
}
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
.select-check.checked { color: var(--accent-fg); opacity: 1; }
.check-empty {
width: 20px; height: 20px; border-radius: 4px;
border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3);
}
.title {
margin-top: var(--sp-2); font-size: var(--text-sm);
color: var(--text-secondary); line-height: var(--leading-snug);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; height: 2lh;
transition: color var(--t-base);
}
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
.skeleton { background: var(--bg-raised); animation: pulse 1.4s ease infinite; }
.empty {
display: flex; align-items: center; justify-content: center;
height: 60%; color: var(--text-muted); font-size: var(--text-sm);
text-align: center;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
</style>
@@ -0,0 +1,263 @@
<script lang="ts">
import {
MagnifyingGlass, Books, DownloadSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise,
} from 'phosphor-svelte'
import LibraryFilters from './LibraryFilters.svelte'
import type { LibrarySortOption, LibraryTab } from '$lib/state/library.svelte'
import type { MangaStatus } from '$lib/server-adapters/types'
interface Props {
tab: LibraryTab
savedCount: number
dlCount: number
sort: LibrarySortOption
sortDesc: boolean
status: MangaStatus | 'all'
unread: boolean
downloaded: boolean
bookmarked: boolean
hasActiveFilters: boolean
refreshing: boolean
query: string
onTab: (t: LibraryTab) => void
onQuery: (q: string) => void
onSort: (s: LibrarySortOption) => void
onSortDesc: () => void
onStatus: (s: MangaStatus | 'all') => void
onUnread: () => void
onDownloaded: () => void
onBookmarked: () => void
onFilterClear: () => void
onRefresh: () => void
}
let {
tab, savedCount, dlCount, sort, sortDesc,
status, unread, downloaded, bookmarked, hasActiveFilters, refreshing, query,
onTab, onQuery, onSort, onSortDesc,
onStatus, onUnread, onDownloaded, onBookmarked, onFilterClear, onRefresh,
}: Props = $props()
let sortOpen = $state(false)
let filterOpen = $state(false)
const SORT_LABELS: Record<LibrarySortOption, string> = {
alphabetical: 'AZ',
unread: 'Unread chapters',
lastRead: 'Recently read',
dateAdded: 'Date added',
}
function onDocDown(e: MouseEvent) {
const t = e.target as HTMLElement
if (sortOpen && !t.closest('.sort-wrap')) sortOpen = false
if (filterOpen && !t.closest('.filter-wrap')) filterOpen = false
}
$effect(() => {
document.addEventListener('mousedown', onDocDown, true)
return () => document.removeEventListener('mousedown', onDocDown, true)
})
</script>
<div class="toolbar">
<span class="heading">Library</span>
<div class="tabs">
<button class="tab" class:active={tab === 'saved'} onclick={() => onTab('saved')}>
<Books size={11} weight="bold" />
Saved
<span class="count">{savedCount}</span>
</button>
<button class="tab" class:active={tab === 'downloaded'} onclick={() => onTab('downloaded')}>
<DownloadSimple size={11} weight="bold" />
Downloaded
<span class="count">{dlCount}</span>
</button>
</div>
<div class="right">
<div class="search-wrap">
<MagnifyingGlass size={13} class="search-icon" weight="light" />
<input
class="search"
placeholder="Search"
value={query}
oninput={(e) => onQuery((e.target as HTMLInputElement).value)}
/>
</div>
<button
class="icon-btn"
class:spinning={refreshing}
title={refreshing ? 'Checking for updates…' : 'Check for updates'}
onclick={onRefresh}
disabled={refreshing}
>
<ArrowsClockwise size={15} weight="bold" />
</button>
<div class="sort-wrap">
<button
class="icon-btn"
class:active={sort !== 'alphabetical' || sortDesc}
title="Sort"
onclick={() => { sortOpen = !sortOpen; filterOpen = false }}
>
<SortAscending size={15} weight="bold" />
</button>
{#if sortOpen}
<div class="panel sort-panel" role="menu">
<div class="panel-head">
<span class="panel-title">Sort</span>
</div>
<div class="divider"></div>
<p class="section-label">Order by</p>
{#each Object.entries(SORT_LABELS) as [s, label]}
<button
class="item"
class:item-active={sort === s}
role="menuitem"
onclick={() => { onSort(s as LibrarySortOption); sortOpen = false }}
>
{label}
{#if sort === s}
{#if sortDesc}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
{/if}
</button>
{/each}
<button class="item dir-toggle" role="menuitem" onclick={onSortDesc}>
{sortDesc ? 'Descending' : 'Ascending'}
{#if sortDesc}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
</button>
</div>
{/if}
</div>
<div class="filter-wrap">
<LibraryFilters
{status} {unread} {downloaded} {bookmarked}
hasActive={hasActiveFilters}
open={filterOpen}
onToggle={() => { filterOpen = !filterOpen; sortOpen = false }}
{onStatus} {onUnread} {onDownloaded} {onBookmarked}
onClear={onFilterClear}
/>
</div>
</div>
</div>
<style>
.toolbar {
position: relative; z-index: 100;
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; min-width: 0;
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase; flex-shrink: 0;
}
.tabs {
display: flex; align-items: center; gap: 2px;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 2px;
}
.tab {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 4px 10px; border-radius: var(--radius-sm);
border: 1px solid transparent; color: var(--text-faint);
white-space: nowrap;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
cursor: pointer;
}
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.count { font-size: var(--text-2xs); opacity: 0.6; }
.right {
display: flex; align-items: center; gap: var(--sp-2);
margin-left: auto; flex-shrink: 0;
}
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
.search {
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 5px 10px 5px 28px;
color: var(--text-primary); font-size: var(--text-sm); width: 180px;
outline: none; transition: border-color var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.5; cursor: default; }
.icon-btn.spinning :global(svg) { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.sort-wrap, .filter-wrap { position: relative; }
.panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
min-width: 220px; background: var(--bg-raised);
border: 1px solid var(--border-base); border-radius: var(--radius-lg);
padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: fadeIn 0.1s ease both;
}
.panel-head { display: flex; align-items: center; padding: 6px 10px 4px; }
.panel-title {
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
font-weight: var(--weight-medium, 500);
}
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); padding: 4px 8px 8px;
}
.item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px 10px; border-radius: var(--radius-sm);
border: none; background: transparent; color: var(--text-muted);
font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left; gap: var(--sp-2);
transition: background var(--t-base), color var(--t-base);
}
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.item-active:hover { background: var(--accent-dim); }
.dir-toggle {
justify-content: flex-start; color: var(--text-secondary);
border-top: 1px solid var(--border-dim);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
margin-top: 2px; padding-top: 9px;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
<script lang="ts">
import { tick } from 'svelte'
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from 'phosphor-svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
import GeneralSettings from './sections/GeneralSettings.svelte'
import AppearanceSettings from './sections/AppearanceSettings.svelte'
import ReaderSettings from './sections/ReaderSettings.svelte'
import LibrarySettings from './sections/LibrarySettings.svelte'
import AutomationSettings from './sections/AutomationSettings.svelte'
import PerformanceSettings from './sections/PerformanceSettings.svelte'
import KeybindsSettings from './sections/KeybindsSettings.svelte'
import StorageSettings from './sections/StorageSettings.svelte'
import FoldersSettings from './sections/FoldersSettings.svelte'
import TrackingSettings from './sections/TrackingSettings.svelte'
import SecuritySettings from './sections/SecuritySettings.svelte'
import ContentSettings from './sections/ContentSettings.svelte'
import AboutSettings from './sections/AboutSettings.svelte'
import DevtoolsSettings from './sections/DevToolsSettings.svelte'
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
let { onclose, onOpenThemeEditor }: Props = $props()
type Tab = 'general'|'appearance'|'reader'|'library'|'automation'|'performance'|'keybinds'|'storage'|'folders'|'tracking'|'security'|'content'|'about'|'devtools'
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: 'general', label: 'General', icon: Gear },
{ id: 'appearance', label: 'Appearance', icon: PaintBrush },
{ id: 'reader', label: 'Reader', icon: Book },
{ id: 'library', label: 'Library', icon: Image },
{ id: 'automation', label: 'Automation', icon: Robot },
{ id: 'performance', label: 'Performance', icon: Sliders },
{ id: 'keybinds', label: 'Keybinds', icon: Keyboard },
{ id: 'storage', label: 'Storage', icon: HardDrives },
{ id: 'folders', label: 'Folders', icon: FolderSimple },
{ id: 'tracking', label: 'Tracking', icon: ListChecks },
{ id: 'security', label: 'Security', icon: Lock },
{ id: 'content', label: 'Content', icon: ShieldCheck },
{ id: 'about', label: 'About', icon: Info },
{ id: 'devtools', label: 'Dev Tools', icon: Wrench },
]
const anims = $derived(settingsState.settings.qolAnimations ?? true)
let tab: Tab = $state('general')
let prevTabIndex = $state(0)
let tabSlideDir = $state<'up'|'down'>('down')
let tabIconKey = $state(0)
let contentBodyEl: HTMLDivElement
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
function setTab(id: Tab) {
if (anims) {
const next = TABS.findIndex(t => t.id === id)
tabSlideDir = next > prevTabIndex ? 'down' : 'up'
prevTabIndex = next
tabIconKey++
}
tab = id
}
function close() { onclose?.() }
let listeningKey: keyof Keybinds | null = $state(null)
$effect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !listeningKey) { e.stopPropagation(); close() } }
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
})
$effect(() => {
if (!listeningKey) return
const capture = (e: KeyboardEvent) => {
e.preventDefault(); e.stopPropagation()
const bind = eventToKeybind(e)
if (!bind) return
updateSettings({ keybinds: { ...settingsState.settings.keybinds, [listeningKey!]: bind } })
listeningKey = null
}
window.addEventListener('keydown', capture, true)
return () => window.removeEventListener('keydown', capture, true)
})
let selectOpen: string | null = $state(null)
let closingSelect: string | null = $state(null)
const CLOSE_ANIM_MS = 120
function closeSelect() {
if (!selectOpen) return
closingSelect = selectOpen
selectOpen = null
setTimeout(() => { closingSelect = null }, CLOSE_ANIM_MS)
}
function toggleSelect(id: string) {
if (selectOpen === id) { closeSelect() }
else { closingSelect = null; selectOpen = id }
}
$effect(() => {
const handler = (e: MouseEvent) => {
if (!selectOpen) return
const t = e.target as HTMLElement
if (t.closest('.s-select') || t.closest('.s-select-menu')) return
closeSelect()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
})
</script>
<div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close() }}
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
<div class="s-modal" role="dialog" aria-label="Settings">
<div class="s-sidebar">
<p class="s-sidebar-title">Settings</p>
<nav>
{#each TABS as t}
<button class="s-nav-item" class:active={tab === t.id} class:anims onclick={() => setTab(t.id)}>
<span class="s-nav-icon"
class:slide-down={anims && tab === t.id && tabSlideDir === 'down'}
class:slide-up={anims && tab === t.id && tabSlideDir === 'up'}>
{#key anims && tab === t.id ? tabIconKey : 0}
<t.icon size={14} weight={tab === t.id ? 'regular' : 'light'} />
{/key}
</span>
<span>{t.label}</span>
</button>
{/each}
</nav>
</div>
<div class="s-content">
<div class="s-content-header">
<div class="s-content-header-left">
<span class="s-header-icon"
class:slide-down={anims && tabSlideDir === 'down'}
class:slide-up={anims && tabSlideDir === 'up'}>
{#key tabIconKey}
{#each TABS as t}
{#if t.id === tab}
<t.icon size={13} weight="light" />
{/if}
{/each}
{/key}
</span>
<p class="s-content-title">{TABS.find(t => t.id === tab)?.label}</p>
</div>
<button class="s-close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="s-content-body" bind:this={contentBodyEl}>
{#if tab === 'general'}
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'appearance'}
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
{:else if tab === 'reader'}
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'library'}
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'automation'}
<AutomationSettings />
{:else if tab === 'performance'}
<PerformanceSettings />
{:else if tab === 'keybinds'}
<KeybindsSettings bind:listeningKey />
{:else if tab === 'storage'}
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} />
{:else if tab === 'folders'}
<FoldersSettings />
{:else if tab === 'tracking'}
<TrackingSettings />
{:else if tab === 'security'}
<SecuritySettings {selectOpen} {toggleSelect} />
{:else if tab === 'content'}
<ContentSettings />
{:else if tab === 'about'}
<AboutSettings />
{:else if tab === 'devtools'}
<DevtoolsSettings />
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,508 @@
<script lang="ts">
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
import { settingsState, updateSettings } from '$lib/state/settings.svelte';
import type { CustomTheme, ThemeTokens } from "$lib/types/settings";
import { DEFAULT_THEME_TOKENS } from "$lib/types/settings";
interface Props {
editingId?: string | null;
onClose: () => void;
}
let { editingId = $bindable(null), onClose }: Props = $props();
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{ label: "Backgrounds", tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"] },
{ label: "Borders", tokens: ["border-dim", "border-base", "border-strong", "border-focus"] },
{ label: "Text", tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"] },
{ label: "Accent", tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"] },
{ label: "Semantic", tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"] },
];
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
"bg-void": "Void (deepest bg)",
"bg-base": "Base",
"bg-surface": "Surface",
"bg-raised": "Raised",
"bg-overlay": "Overlay",
"bg-subtle": "Subtle",
"border-dim": "Dim border",
"border-base": "Base border",
"border-strong": "Strong border",
"border-focus": "Focus ring",
"text-primary": "Primary text",
"text-secondary": "Secondary text",
"text-muted": "Muted text",
"text-faint": "Faint text",
"text-disabled": "Disabled text",
"accent": "Accent",
"accent-dim": "Accent dim",
"accent-muted": "Accent muted",
"accent-fg": "Accent foreground",
"accent-bright": "Accent bright",
"color-error": "Error",
"color-error-bg": "Error background",
"color-success": "Success",
"color-info": "Info",
"color-info-bg": "Info background",
};
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = settingsState.settings.customThemes.find(t => t.id === editingId);
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
}
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
}
const initial = loadInitial();
let themeName: string = $state(initial.name);
let tokens: ThemeTokens = $state(initial.tokens);
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
function saveCustomTheme(theme: CustomTheme) {
const existing = settingsState.settings.customThemes.findIndex(t => t.id === theme.id);
const next = [...settingsState.settings.customThemes];
if (existing >= 0) next[existing] = theme;
else next.push(theme);
updateSettings({ customThemes: next });
}
function deleteCustomTheme(id: string) {
updateSettings({ customThemes: settingsState.settings.customThemes.filter(t => t.id !== id) });
}
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
saveCustomTheme({ id, name, tokens: { ...tokens } });
updateSettings({ theme: id });
editingId = id;
saveStatus = "saved";
setTimeout(() => (saveStatus = "idle"), 1800);
}
function handleDelete() {
if (!editingId) { onClose(); return; }
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
deleteCustomTheme(editingId);
onClose();
}
async function handleExport() {
const data: CustomTheme = {
id: editingId ?? "custom:export",
name: themeName.trim() || "Untitled Theme",
tokens: { ...tokens },
};
const filename = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
const json = JSON.stringify(data, null, 2);
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: filename,
types: [{ description: "Theme JSON", accept: { "application/json": [".json"] } }],
});
const writable = await handle.createWritable();
await writable.write(json);
await writable.close();
} catch (e: any) {
if (e?.name === "AbortError") return;
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
}
function handleImport() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.onchange = async () => {
const file = inp.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
if (typeof data.name === "string") themeName = data.name;
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
importError = null;
} catch (e: any) {
importError = e.message ?? "Could not parse theme file";
setTimeout(() => (importError = null), 3000);
}
};
inp.click();
}
function resetToDefaults() { tokens = { ...DEFAULT_THEME_TOKENS }; }
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
</script>
<svelte:window onkeydown={onKey} />
<div class="backdrop" role="button" tabindex="-1" aria-label="Close theme editor" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div class="shell" role="dialog" aria-label="Theme editor" tabindex="0" style={toCssVars(tokens)} onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<header class="header">
<div class="header-left">
<button class="icon-btn" onclick={onClose} title="Close editor">
<ArrowLeft size={14} weight="bold" />
</button>
<input bind:value={themeName} class="name-input" placeholder="Theme name" maxlength={40} spellcheck={false} />
</div>
<div class="header-actions">
{#if importError}
<span class="import-err">{importError}</span>
{/if}
<button class="action-btn" onclick={handleImport} title="Import from JSON">
<UploadSimple size={13} /><span>Import</span>
</button>
<button class="action-btn" onclick={handleExport} title="Export as JSON">
<DownloadSimple size={13} /><span>Export</span>
</button>
<button class="action-btn ghost" onclick={resetToDefaults} title="Reset all to dark defaults">Reset</button>
{#if editingId}
<button class="action-btn danger" onclick={handleDelete} title="Delete theme">
<Trash size={13} />
</button>
{/if}
<button class="save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
<FloppyDisk size={13} /><span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
</button>
<button class="icon-btn" onclick={onClose} title="Close">
<X size={14} weight="bold" />
</button>
</div>
</header>
<div class="body">
<aside class="preview-pane">
<div class="pane-label">Live Preview</div>
<div class="preview-ui" style={toCssVars(tokens)}>
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots"><span></span><span></span><span></span></div>
<div class="prv-win-title">Moku</div>
</div>
<div class="prv-content">
<div class="prv-row">
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
</div>
<div class="prv-grid">
{#each Array(6) as _, i}
<div class="prv-card" class:active-card={i === 0}>
<div class="prv-cover"></div>
<div class="prv-card-line"></div>
</div>
{/each}
</div>
<div class="prv-reader"><div class="prv-page"></div></div>
<div class="prv-toast">
<div class="prv-toast-dot"></div>
<div class="prv-toast-lines">
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
</div>
</div>
</div>
</div>
</div>
<div class="swatches" style={toCssVars(tokens)}>
{#each ["bg-base","bg-surface","accent","accent-fg","text-primary","text-muted","color-error"] as v}
<div class="swatch" style="background:var(--{v})" title={v}></div>
{/each}
</div>
</aside>
<div class="editor-pane">
{#each TOKEN_GROUPS as group}
<div class="group">
<div class="group-label">{group.label}</div>
<div class="token-list">
{#each group.tokens as token}
<div class="token-row">
<label class="color-swatch" style="background:{tokens[token]}" title="Pick colour">
<input
type="color"
class="color-picker"
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0, 7)}
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
/>
</label>
<span class="token-name">{TOKEN_LABELS[token]}</span>
<span class="token-key">{token}</span>
<input
type="text"
class="hex-input"
value={tokens[token]}
spellcheck={false}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
}}
onblur={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) (e.target as HTMLInputElement).value = tokens[token];
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: 200;
display: flex; align-items: center; justify-content: center;
animation: backdropIn 0.14s ease both;
}
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
.shell {
width: calc(100% - var(--sp-12)); max-width: 1100px;
height: calc(100% - var(--sp-12)); max-height: 760px;
display: flex; flex-direction: column;
background: var(--bg-base);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
animation: shellIn 0.2s cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes shellIn {
from { transform: translateY(10px) scale(0.99); opacity: 0 }
to { transform: translateY(0) scale(1); opacity: 1 }
}
.header {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); padding: 0 var(--sp-4); height: 46px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-surface);
flex-shrink: 0;
}
.header-left { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-md);
color: var(--text-muted);
transition: color var(--t-base), background var(--t-base);
flex-shrink: 0;
}
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.name-input {
flex: 1; min-width: 0;
background: none; border: none; outline: none;
font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary);
border-bottom: 1px solid transparent;
padding: 3px 0;
transition: border-color var(--t-base);
}
.name-input:focus { border-color: var(--border-focus); }
.name-input::placeholder { color: var(--text-faint); }
.import-err {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--color-error); flex-shrink: 0;
}
.action-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.action-btn.ghost { border-color: transparent; }
.action-btn.ghost:hover { border-color: var(--border-dim); }
.action-btn.danger { color: var(--color-error); border-color: transparent; }
.action-btn.danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
.save-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim);
background: var(--accent-muted); color: var(--accent-fg);
cursor: pointer; flex-shrink: 0;
transition: filter var(--t-base), background var(--t-base);
}
.save-btn:hover { filter: brightness(1.12); }
.save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
.body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
.preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
background: var(--bg-void);
display: flex; flex-direction: column;
padding: var(--sp-4); gap: var(--sp-3);
}
.pane-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); flex-shrink: 0;
}
.preview-ui {
flex: 1; min-height: 0;
border-radius: var(--radius-lg); overflow: hidden;
border: 1px solid var(--border-base);
display: flex; background: var(--bg-void);
}
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
align-items: center; padding: var(--sp-3) 0; gap: var(--sp-2);
}
.prv-sb-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--text-faint); opacity: 0.4;
transition: background var(--t-base), opacity var(--t-base);
}
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.prv-titlebar {
height: 26px; flex-shrink: 0;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
display: flex; align-items: center; padding: 0 var(--sp-2); gap: var(--sp-2);
}
.prv-win-dots { display: flex; gap: var(--sp-1); }
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.prv-win-title { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); color: var(--text-faint); }
.prv-content {
flex: 1; overflow: hidden;
padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2);
background: var(--bg-base);
}
.prv-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.prv-bar { height: 3px; border-radius: 2px; }
.prv-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: var(--sp-1); flex-shrink: 0; }
.prv-card {
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-raised); overflow: hidden;
transition: border-color var(--t-base);
}
.prv-card.active-card { border-color: var(--accent); }
.prv-cover { height: 34px; background: var(--bg-overlay); }
.prv-card-line { height: 3px; margin: 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
.prv-reader {
flex: 1; min-height: 0;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
}
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
.prv-toast {
flex-shrink: 0;
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2);
border-radius: var(--radius-md);
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
}
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
.swatches { display: flex; gap: var(--sp-1); flex-wrap: wrap; flex-shrink: 0; }
.swatch { width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; }
.editor-pane {
flex: 1; overflow-y: auto;
padding: var(--sp-4) var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-6);
}
.group { display: flex; flex-direction: column; gap: var(--sp-1); }
.group-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint);
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid var(--border-dim);
}
.token-list { display: flex; flex-direction: column; gap: 1px; }
.token-row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 5px var(--sp-2); border-radius: var(--radius-md);
transition: background var(--t-base);
}
.token-row:hover { background: var(--bg-raised); }
.color-swatch {
width: 36px; height: 18px; border-radius: var(--radius-md);
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
cursor: pointer; position: relative; overflow: hidden; display: block;
}
.color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
.color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0; cursor: pointer; padding: 0; border: none;
}
.token-name { flex: 1; font-size: var(--text-xs); color: var(--text-secondary); }
.token-key {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); flex-shrink: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px;
}
.hex-input {
width: 82px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 3px var(--sp-2);
outline: none;
transition: border-color var(--t-base), color var(--t-base);
}
.hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
</style>
@@ -0,0 +1,297 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { getVersion } from '@tauri-apps/api/app'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import { autoBackupAppData } from '$lib/core/backup'
import { requestManager } from '$lib/request-manager'
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string }
type UpdatePhase = 'idle' | 'downloading' | 'launching' | 'ready' | 'error'
const IS_WINDOWS = navigator.userAgent.includes('Windows')
interface AboutServer { name: string; version: string; buildType: string; buildTime: number; github: string; discord: string }
interface AboutWebUI { channel: string; tag: string; updateTimestamp: number }
let appVersion = $state('…')
let releases = $state<ReleaseInfo[]>([])
let releasesLoading = $state(false)
let releasesError = $state<string | null>(null)
let expandedTag = $state<string | null>(null)
let updatePhase = $state<UpdatePhase>('idle')
let updateError = $state<string | null>(null)
let dlBytes = $state(0)
let dlTotal = $state<number | null>(null)
let targetTag = $state<string | null>(null)
let releasesLoaded = false
let serverInfo = $state<AboutServer | null>(null)
let webuiInfo = $state<AboutWebUI | null>(null)
$effect(() => {
getVersion().then(v => appVersion = v).catch(() => appVersion = 'unknown')
if (!releasesLoaded) { releasesLoaded = true; loadReleases() }
})
$effect(() => { loadServerInfo() })
$effect(() => {
let unlisten: (() => void) | undefined
listen<{ downloaded: number; total: number | null }>('update-progress', e => {
dlBytes = e.payload.downloaded; dlTotal = e.payload.total ?? null
}).then(fn => { unlisten = fn })
return () => unlisten?.()
})
$effect(() => {
let unlisten: (() => void) | undefined
listen('update-launching', () => { updatePhase = 'launching' }).then(fn => { unlisten = fn })
return () => unlisten?.()
})
async function loadReleases() {
releasesLoading = true; releasesError = null
try {
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Request timed out after 10s')), 10_000))
const all = await Promise.race([invoke<ReleaseInfo[]>('list_releases'), timeout])
releases = all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
} catch (e: any) {
releasesError = e instanceof Error ? e.message : String(e)
} finally { releasesLoading = false }
}
async function loadServerInfo() {
try {
const [s, w] = await Promise.all([
requestManager.meta.getAboutServer(),
requestManager.meta.getAboutWebUI(),
])
serverInfo = s
webuiInfo = w
} catch {}
}
function stripV(v: string) { return v.replace(/^v/, '') }
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion }
function parseSemver(v: string) { return stripV(v).split('.').map(Number) }
function compareSemver(a: string, b: string) {
const pa = parseSemver(a), pb = parseSemver(b)
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
return 0
}
const onLatestVersion = $derived((() => {
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === '…') return false
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name))
return compareSemver(appVersion, sorted[0].tag_name) >= 0
})())
function fmtDate(iso: string) {
if (!iso) return ''
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function fmtBuildTime(unix: number | string) {
if (!unix) return ''
return new Date(Number(unix) * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function fmtBytes(bytes: number) {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
}
function fmtProgress() {
return dlTotal
? `${fmtBytes(dlBytes)} / ${fmtBytes(dlTotal)} (${Math.round((dlBytes / dlTotal) * 100)}%)`
: fmtBytes(dlBytes)
}
async function installUpdate(release: ReleaseInfo) {
if (updatePhase === 'downloading') return
targetTag = release.tag_name; updatePhase = 'downloading'; updateError = null; dlBytes = 0; dlTotal = null
try {
if (IS_WINDOWS) {
await autoBackupAppData()
try { await invoke('kill_server') } catch {}
await invoke('download_and_install_update', { tag: release.tag_name })
updatePhase = 'ready'
} else {
await openUrl(release.html_url)
updatePhase = 'idle'; targetTag = null
}
} catch (e: any) {
updateError = e instanceof Error ? e.message : String(e)
updatePhase = 'error'
}
}
async function restartNow() { await invoke('restart_app') }
function cancelUpdate() { updatePhase = 'idle'; updateError = null; targetTag = null; dlBytes = 0; dlTotal = null }
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Moku</p>
<div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-1)">
<span class="s-label">A manga reader frontend for Suwayomi / Tachidesk.</span>
<span class="s-desc">Built with Tauri + Svelte.</span>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Version</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Installed</span><span class="s-desc">v{appVersion}</span></div>
<button class="s-btn" onclick={() => { releasesError = null; loadReleases() }} disabled={releasesLoading}>
{releasesLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{#if onLatestVersion}
<div class="s-row">
<span class="s-desc" style="color:var(--accent-fg)">✓ You're on the latest version.</span>
</div>
{/if}
{#if updatePhase === 'downloading' && IS_WINDOWS}
<div class="s-update-progress">
<div class="s-update-bar">
<div class="s-update-fill" style="width:{dlTotal ? Math.round((dlBytes / dlTotal) * 100) : 0}%"></div>
</div>
<div class="s-update-labels">
<span>Downloading {targetTag ?? 'update'}</span>
<span>{fmtProgress()}</span>
</div>
</div>
{/if}
{#if updatePhase === 'launching'}
<div class="s-update-ready">
<span class="s-update-ready-label">Launching installer for {targetTag}</span>
</div>
{/if}
{#if updatePhase === 'ready'}
<div class="s-update-ready">
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
<button class="s-btn s-btn-accent" onclick={restartNow}>Restart now</button>
<button class="s-btn-icon" onclick={cancelUpdate} title="Dismiss"></button>
</div>
{/if}
{#if updatePhase === 'error'}
<div class="s-row">
<span class="s-desc" style="color:var(--color-error)">{updateError}</span>
<button class="s-btn" onclick={cancelUpdate}>Dismiss</button>
</div>
{/if}
</div>
</div>
{#if serverInfo}
<div class="s-section">
<p class="s-section-title">Server</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Version</span>
<span class="s-desc">
{serverInfo.version}
{#if serverInfo.buildType}
<span class="s-release-badge">{serverInfo.buildType}</span>
{/if}
</span>
</div>
</div>
{#if serverInfo.buildTime}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Built</span>
<span class="s-desc">{fmtBuildTime(serverInfo.buildTime)}</span>
</div>
</div>
{/if}
{#if webuiInfo?.channel}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Channel</span>
<span class="s-desc">{webuiInfo.channel}</span>
</div>
</div>
{/if}
</div>
</div>
{/if}
<div class="s-section">
<p class="s-section-title">Releases</p>
<div class="s-section-body">
{#if releasesError}
<p class="s-empty" style="color:var(--color-error)">{releasesError}</p>
{:else if releasesLoading}
<p class="s-empty">Fetching releases…</p>
{:else if releases.length === 0}
<p class="s-empty">No releases found.</p>
{:else}
<div class="s-release-scroll">
{#each releases as release}
{@const isCurrent = isCurrentVersion(release.tag_name)}
{@const isExpanded = expandedTag === release.tag_name}
{@const isTarget = targetTag === release.tag_name}
{@const isInstalling = isTarget && updatePhase === 'downloading'}
<div class="s-release-row" class:current={isCurrent}>
<div class="s-release-header">
<div class="s-release-meta">
<span class="s-release-tag">{release.tag_name}</span>
{#if isCurrent}<span class="s-release-badge">installed</span>{/if}
{#if release.published_at}<span class="s-release-date">{fmtDate(release.published_at)}</span>{/if}
</div>
<div class="s-btn-row">
{#if release.body.trim()}
<button class="s-btn" onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
{isExpanded ? 'Hide' : 'Changelog'}
</button>
{/if}
{#if !isCurrent}
{#if IS_WINDOWS}
<button class="s-btn" class:s-btn-accent={!isInstalling}
disabled={updatePhase === 'downloading'} onclick={() => installUpdate(release)}>
{isInstalling ? 'Downloading…' : 'Install'}
</button>
{:else}
<button class="s-btn" onclick={() => installUpdate(release)}>Open on GitHub</button>
{/if}
{/if}
</div>
</div>
{#if isExpanded && release.body.trim()}
<div class="s-release-body">
<pre class="s-release-body pre">{release.body.trim()}</pre>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Links</p>
<div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
<a href="https://github.com/moku-project/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
{#if serverInfo?.github && serverInfo.github !== 'https://github.com/moku-project/Moku'}
<a href={serverInfo.github} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi GitHub →</a>
{/if}
{#if serverInfo?.discord && serverInfo.discord !== 'https://discord.gg/Jq3pwuNqPp'}
<a href={serverInfo.discord} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi Discord →</a>
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,161 @@
<script lang="ts">
import { Pencil, Trash, Plus } from 'phosphor-svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
interface Props {
selectOpen: string | null
closingSelect: string | null
toggleSelect: (id: string) => void
anims: boolean
onOpenThemeEditor?: (id?: string | null) => void
}
let { selectOpen, closingSelect, toggleSelect, anims, onOpenThemeEditor }: Props = $props()
const THEMES = [
{ id: 'original', label: 'Original', description: 'Default near-black', swatches: ['#101010','#151515','#a8c4a8','#f0efec'] },
{ id: 'dark', label: 'Dark', description: 'Darker base, sharper text', swatches: ['#080808','#111111','#bcd8bc','#ffffff'] },
{ id: 'light', label: 'Light', description: 'Warm off-white', swatches: ['#f4f2ee','#faf8f4','#2a5a2a','#1a1916'] },
{ id: 'midnight', label: 'Midnight', description: 'Deep blue-black tint', swatches: ['#0c1020','#101428','#a8b4e8','#eeeef8'] },
{ id: 'warm', label: 'Warm', description: 'Amber and sepia tones', swatches: ['#16130c','#1c1810','#e0b860','#f5f0e0'] },
]
const allThemeOptions = $derived([
...THEMES.map(t => ({ id: t.id, label: t.label })),
...(settingsState.settings.customThemes ?? []).map(t => ({ id: t.id, label: t.name })),
])
let triggerDark = $state<HTMLButtonElement>(null!)
let triggerLight = $state<HTMLButtonElement>(null!)
function deleteCustomTheme(id: string) {
updateSettings({ customThemes: (settingsState.settings.customThemes ?? []).filter(t => t.id !== id) })
if (settingsState.settings.theme === id) updateSettings({ theme: 'dark' })
}
</script>
<div class="s-panel">
<div class="s-section">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Match system theme</span>
<span class="s-desc">Automatically switch theme when your OS switches between light and dark</span>
</div>
<button class="s-toggle" class:on={settingsState.settings.systemThemeSync}
onclick={() => updateSettings({ systemThemeSync: !settingsState.settings.systemThemeSync })}
role="switch" aria-label="Match system theme" aria-checked={settingsState.settings.systemThemeSync}>
<span class="s-toggle-thumb"></span>
</button>
</div>
{#if settingsState.settings.systemThemeSync}
<div class="s-sync-pair">
<div class="s-sync-item">
<span class="s-sync-label">Dark theme</span>
<div class="s-select">
<button bind:this={triggerDark} class="s-select-btn" onclick={() => toggleSelect('sync-dark')}>
<span>{allThemeOptions.find(o => o.id === (settingsState.settings.systemThemeDark ?? 'dark'))?.label ?? 'Dark'}</span>
<svg class="s-select-caret" class:open={selectOpen === 'sync-dark'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'sync-dark' || closingSelect === 'sync-dark'}
<div class="s-select-menu" class:anims class:closing={closingSelect === 'sync-dark'}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (settingsState.settings.systemThemeDark ?? 'dark')}
onclick={() => { updateSettings({ systemThemeDark: opt.id }); toggleSelect('sync-dark') }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-sync-item">
<span class="s-sync-label">Light theme</span>
<div class="s-select">
<button bind:this={triggerLight} class="s-select-btn" onclick={() => toggleSelect('sync-light')}>
<span>{allThemeOptions.find(o => o.id === (settingsState.settings.systemThemeLight ?? 'light'))?.label ?? 'Light'}</span>
<svg class="s-select-caret" class:open={selectOpen === 'sync-light'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'sync-light' || closingSelect === 'sync-light'}
<div class="s-select-menu" class:anims class:closing={closingSelect === 'sync-light'}>
{#each allThemeOptions as opt}
<button class="s-select-option" class:active={opt.id === (settingsState.settings.systemThemeLight ?? 'light')}
onclick={() => { updateSettings({ systemThemeLight: opt.id }); toggleSelect('sync-light') }}>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section">
<p class="s-section-title">Theme</p>
<div class="s-theme-grid">
{#each THEMES as theme}
{@const active = (settingsState.settings.theme ?? 'dark') === theme.id}
<div class="s-theme-card" class:active>
<button class="s-theme-card" style="border:none;padding:0;width:100%;display:block" onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
<div class="s-theme-preview">
<div class="s-theme-preview-bg" style="background:{theme.swatches[0]}">
<div class="s-theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
<div class="s-theme-preview-content">
<div class="s-theme-preview-accent" style="background:{theme.swatches[2]}"></div>
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}55"></div>
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
</div>
</div>
</div>
<div class="s-theme-info">
<span class="s-theme-name">{theme.label}</span>
<span class="s-theme-desc">{theme.description}</span>
</div>
{#if active}<span class="s-theme-check"></span>{/if}
</button>
</div>
{/each}
{#each settingsState.settings.customThemes ?? [] as custom}
{@const active = settingsState.settings.theme === custom.id}
<div class="s-theme-card" class:active>
<div class="s-theme-actions">
<button class="s-theme-action-btn edit" onclick={() => onOpenThemeEditor?.(custom.id)} title="Edit theme"><Pencil size={10} /></button>
<button class="s-theme-action-btn delete"
onclick={() => { if (confirm(`Delete theme "${custom.name}"?`)) deleteCustomTheme(custom.id) }}
title="Delete theme"><Trash size={10} /></button>
</div>
<button style="border:none;padding:0;width:100%;display:block;background:none;cursor:pointer"
onclick={() => updateSettings({ theme: custom.id })} title="Apply {custom.name}">
<div class="s-theme-preview">
<div class="s-theme-preview-bg" style="background:{custom.tokens['bg-base']}">
<div class="s-theme-preview-sidebar" style="background:{custom.tokens['bg-surface']}"></div>
<div class="s-theme-preview-content">
<div class="s-theme-preview-accent" style="background:{custom.tokens['accent']}"></div>
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}55"></div>
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}33;width:60%"></div>
</div>
</div>
</div>
<div class="s-theme-info">
<span class="s-theme-name">{custom.name}</span>
<span class="s-theme-desc" style="color:var(--accent-fg)">Custom</span>
</div>
</button>
{#if active}<span class="s-theme-check"></span>{/if}
</div>
{/each}
<button class="s-theme-card s-theme-new" onclick={() => onOpenThemeEditor?.(null)} title="Create a custom theme">
<Plus size={18} weight="light" />
<div class="s-theme-info">
<span class="s-theme-name">New Theme</span>
<span class="s-theme-desc">Create custom</span>
</div>
</button>
</div>
</div>
</div>
@@ -0,0 +1,143 @@
<script lang="ts">
import { ArrowCounterClockwise, LockSimple, Warning } from 'phosphor-svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import type { MangaPrefs } from '$lib/types/settings'
const DOWNLOAD_AHEAD_OPTIONS = [{ value: 0, label: 'Off' },{ value: 2, label: '2' },{ value: 5, label: '5' },{ value: 10, label: '10' }]
const MAX_KEEP_OPTIONS = [{ value: 0, label: 'Off' },{ value: 5, label: '5' },{ value: 10, label: '10' },{ value: 25, label: '25' }]
const DELETE_DELAY_OPTIONS = [{ value: 0, label: 'Now' },{ value: 24, label: '1 day' },{ value: 168, label: '1 week' }]
const REFRESH_INTERVAL_OPTIONS = [{ value: 'daily', label: 'Daily' },{ value: 'weekly', label: 'Weekly' },{ value: 'manual', label: 'Manual' }]
type GlobalDefaults = Omit<MangaPrefs, 'refreshInterval'> & { refreshInterval: 'daily' | 'weekly' | 'manual' }
const fallback: GlobalDefaults = {
autoDownload: false, downloadAhead: 0, maxKeepChapters: 0, deleteOnRead: false,
deleteDelayHours: 0, pauseUpdates: false, refreshInterval: 'weekly',
preferredScanlator: '', scanlatorFilter: [], scanlatorBlacklist: [],
scanlatorForce: false, autoDownloadScanlators: [],
}
function getGlobal<K extends keyof GlobalDefaults>(key: K): GlobalDefaults[K] {
return (settingsState.settings.automationDefaults as GlobalDefaults | undefined)?.[key] ?? fallback[key]
}
function setGlobal<K extends keyof GlobalDefaults>(key: K, value: GlobalDefaults[K]) {
updateSettings({ automationDefaults: { ...(settingsState.settings.automationDefaults ?? fallback), [key]: value } })
}
const enforceGlobal = $derived(settingsState.settings.automationEnforceGlobal ?? false)
const customCount = $derived(
Object.values(settingsState.settings.mangaPrefs ?? {}).filter(p => p && Object.keys(p).length > 0).length
)
let confirmReset = $state(false)
function resetAllCustoms() {
if (!confirmReset) { confirmReset = true; return }
updateSettings({ mangaPrefs: {} })
confirmReset = false
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Behaviour</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable automation</span><span class="s-desc">Allow per-series and global automation rules to run</span></div>
<button role="switch" aria-checked={settingsState.settings.automationEnabled ?? false} aria-label="Enable automation" class="s-toggle" class:on={settingsState.settings.automationEnabled ?? false} onclick={() => updateSettings({ automationEnabled: !(settingsState.settings.automationEnabled ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enforce global defaults</span><span class="s-desc">Ignore per-series overrides — all series use the global settings below</span></div>
<button role="switch" aria-checked={enforceGlobal} aria-label="Enforce global defaults" class="s-toggle" class:on={enforceGlobal} onclick={() => updateSettings({ automationEnforceGlobal: !enforceGlobal })}><span class="s-toggle-thumb"></span></button>
</label>
{#if enforceGlobal}
<div class="s-banner s-banner-info enforce-banner"><LockSimple size={12} weight="fill" /><span>Per-series overrides are paused.</span></div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Global Defaults</p>
<div class="s-section-body">
<p class="sub-head">Downloads</p>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Auto-download new chapters</span><span class="s-desc">Queue new chapters when a series refreshes</span></div>
<button role="switch" aria-checked={getGlobal('autoDownload')} aria-label="Auto-download new chapters" class="s-toggle" class:on={getGlobal('autoDownload')} onclick={() => setGlobal('autoDownload', !getGlobal('autoDownload'))}><span class="s-toggle-thumb"></span></button>
</div>
<div class="s-row chip-row">
<div class="s-row-info"><span class="s-label">Download ahead</span><span class="s-desc">Pre-fetch chapters while reading</span></div>
<div class="chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('downloadAhead') === opt.value} onclick={() => setGlobal('downloadAhead', opt.value)}>{opt.label}</button>
{/each}
</div>
</div>
<div class="s-row chip-row">
<div class="s-row-info"><span class="s-label">Max chapters to keep</span><span class="s-desc">Delete oldest downloads when limit is exceeded</span></div>
<div class="chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('maxKeepChapters') === opt.value} onclick={() => setGlobal('maxKeepChapters', opt.value)}>{opt.label}</button>
{/each}
</div>
</div>
<p class="sub-head sub-head-rule">On Read</p>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Delete after reading</span><span class="s-desc">Remove download when a chapter is marked read</span></div>
<button role="switch" aria-checked={getGlobal('deleteOnRead')} aria-label="Delete after reading" class="s-toggle" class:on={getGlobal('deleteOnRead')} onclick={() => setGlobal('deleteOnRead', !getGlobal('deleteOnRead'))}><span class="s-toggle-thumb"></span></button>
</div>
{#if getGlobal('deleteOnRead')}
<div class="s-row chip-row sub-row">
<span class="s-label">Delete delay</span>
<div class="chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('deleteDelayHours') === opt.value} onclick={() => setGlobal('deleteDelayHours', opt.value)}>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<p class="sub-head sub-head-rule">Updates</p>
<div class="s-row chip-row">
<div class="s-row-info"><span class="s-label">Default refresh interval</span><span class="s-desc">How often series check for new chapters by default</span></div>
<div class="chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button class="s-preset" class:active={getGlobal('refreshInterval') === opt.value} onclick={() => setGlobal('refreshInterval', opt.value as GlobalDefaults['refreshInterval'])}>{opt.label}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Custom Overrides</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Series with custom rules</span><span class="s-desc">Per-series settings set via the series automation panel</span></div>
<span class="s-pill" class:on={customCount > 0}>{customCount}</span>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reset all custom rules</span><span class="s-desc">Revert every series to the global defaults above</span></div>
{#if confirmReset}
<div class="s-btn-row">
<button class="s-btn s-btn-danger" onclick={resetAllCustoms}><Warning size={11} weight="fill" /> Confirm reset</button>
<button class="s-btn" onclick={() => confirmReset = false}>Cancel</button>
</div>
{:else}
<button class="s-btn" disabled={customCount === 0} onclick={resetAllCustoms}><ArrowCounterClockwise size={11} /> Reset</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.enforce-banner { display: flex; align-items: center; gap: var(--sp-2); }
.sub-head { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-widest); text-transform: uppercase; color: var(--text-faint); margin: 0; padding: var(--sp-2) var(--sp-4) 0; }
.sub-head-rule { border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); margin-top: var(--sp-1); }
.chip-row { align-items: flex-start; padding-top: 8px; padding-bottom: 8px; }
.chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.sub-row { padding-left: calc(var(--sp-4) + var(--sp-2)); border-left: 2px solid var(--border-dim); }
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { requestManager } from '$lib/request-manager'
import { platformService } from '$lib/platform-service'
import type { ContentLevel } from '$lib/types/settings'
import type { Source } from '$lib/types'
let contentSources = $state<Source[]>([])
let contentSourcesLoading = $state(false)
let sourceSearch = $state('')
$effect(() => {
if (settingsState.settings.sourceOverridesEnabled && contentSources.length === 0 && !contentSourcesLoading)
loadContentSources()
})
async function loadContentSources() {
contentSourcesLoading = true
try {
const d = await requestManager.extensions.getSources()
contentSources = d.filter(s => s.id !== '0')
} catch (e) { console.error(e) }
finally { contentSourcesLoading = false }
}
function toggleSourceAllowed(ids: string[]) {
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
const allAllowed = ids.every(id => allowed.includes(id))
if (allAllowed) {
updateSettings({ nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)) })
} else {
updateSettings({
nsfwAllowedSourceIds: [...allowed.filter(x => !ids.includes(x)), ...ids],
nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)),
})
}
}
function toggleSourceBlocked(ids: string[]) {
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
const allBlocked = ids.every(id => blocked.includes(id))
if (allBlocked) {
updateSettings({ nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)) })
} else {
updateSettings({
nsfwBlockedSourceIds: [...blocked.filter(x => !ids.includes(x)), ...ids],
nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)),
})
}
}
interface ContentSourceGroup { name: string; iconUrl: string; isNsfw: boolean; sources: Source[] }
const contentSourcesFiltered = $derived.by(() => {
const q = sourceSearch.trim().toLowerCase()
const filtered = q
? contentSources.filter(s => s.displayName.toLowerCase().includes(q) || s.lang.toLowerCase().includes(q))
: contentSources
const map = new Map<string, ContentSourceGroup>()
for (const s of filtered) {
if (!map.has(s.name)) map.set(s.name, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] })
map.get(s.name)!.sources.push(s)
}
return Array.from(map.values())
})
const LEVELS: { value: ContentLevel; label: string; desc: string }[] = [
{ value: 'strict', label: 'Strict', desc: 'Hides all adult, sexual, and graphic violent content' },
{ value: 'moderate', label: 'Moderate', desc: 'Allows violence and gore, filters sexual content' },
{ value: 'unrestricted', label: 'Unrestricted', desc: 'No content filtering applied' },
]
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Content Level</p>
<div class="s-section-body">
<div class="s-row" style="border-bottom: none; padding-bottom: 0;">
<span class="s-desc">Controls what content is visible across library, search, and discover.</span>
</div>
<div class="s-level-group">
{#each LEVELS as lvl}
{@const active = settingsState.settings.contentLevel === lvl.value}
<button class="s-level-btn" class:active onclick={() => updateSettings({ contentLevel: lvl.value })}>
<span class="s-level-dot" class:active></span>
<div class="s-level-text">
<span class="s-level-label">{lvl.label}</span>
<span class="s-level-desc">{lvl.desc}</span>
</div>
</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Source Overrides</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Per-source overrides</span>
<span class="s-desc">Allow a source through even if flagged NSFW, or always block it. Allowed sources still respect the active content level.</span>
</div>
<button
role="switch"
aria-checked={settingsState.settings.sourceOverridesEnabled}
aria-label="Enable source overrides"
class="s-toggle"
class:on={settingsState.settings.sourceOverridesEnabled}
onclick={() => updateSettings({ sourceOverridesEnabled: !settingsState.settings.sourceOverridesEnabled })}
><span class="s-toggle-thumb"></span></button>
</label>
{#if settingsState.settings.sourceOverridesEnabled}
<div class="s-search-wrap">
<input class="s-input full" placeholder="Filter sources…" bind:value={sourceSearch} />
</div>
{#if contentSourcesLoading}
<p class="s-empty">Loading sources…</p>
{:else if contentSources.length === 0}
<p class="s-empty">No sources found — check your server connection.</p>
{:else}
<div class="s-source-list">
{#each contentSourcesFiltered as group (group.name)}
{@const ids = group.sources.map(s => s.id)}
{@const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []}
{@const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []}
{@const isAllowed = ids.every(id => allowed.includes(id))}
{@const isBlocked = ids.every(id => blocked.includes(id))}
<div class="s-source-row" class:allowed={isAllowed} class:blocked={isBlocked}>
<img src={platformService.thumbUrl(group.iconUrl)} alt="" class="s-source-icon" loading="lazy" decoding="async" />
<div class="s-source-info">
<span class="s-source-name">{group.name}</span>
<span class="s-source-meta">
{group.sources[0].isNsfw ? 'NSFW · ' : ''}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()}
</span>
</div>
<div class="s-source-actions">
<button class="s-source-action-btn" class:allow={isAllowed} onclick={() => toggleSourceAllowed(ids)}>Allow</button>
<button class="s-source-action-btn" class:block={isBlocked} onclick={() => toggleSourceBlocked(ids)}>Block</button>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
<style>
.s-level-group {
display: flex;
flex-direction: column;
padding: var(--sp-2) var(--sp-4) var(--sp-3);
gap: var(--sp-1);
}
.s-level-btn {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 10px var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-surface);
cursor: pointer;
text-align: left;
transition: border-color var(--t-base), background var(--t-base);
width: 100%;
}
.s-level-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.s-level-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); }
.s-level-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
background: none;
flex-shrink: 0;
transition: border-color var(--t-base), background var(--t-base);
}
.s-level-dot.active { border-color: var(--accent); background: var(--accent); }
.s-level-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.s-level-label {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.3;
}
.s-level-btn.active .s-level-label { color: var(--accent-fg); }
.s-level-desc {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
}
.s-level-btn.active .s-level-desc { color: var(--accent-fg); opacity: 0.7; }
</style>
@@ -0,0 +1,241 @@
<script lang="ts">
import ThreeDCard from '$lib/components/shared/manga/ThreeDCard.svelte'
import { appState } from '$lib/state/app.svelte'
import { toast } from '$lib/state/notifications.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { cache } from '$lib/core/cache/queryCache'
import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from '$lib/core/auth'
import { invoke } from '@tauri-apps/api/core'
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null }
let perfSnapshot = $state<PerfSnapshot | null>(null)
let splashTriggered = $state(false)
let expOpen = $state(false)
let appVersion = $state('…')
let helloAvailable = $state<boolean | null>(null)
let helloBusy = $state(false)
let authStatus = $state<UiAuthDebugStatus | null>(null)
let authRefreshBusy = $state(false)
$effect(() => {
import('@tauri-apps/api/app').then(m => m.getVersion()).then(v => appVersion = v).catch(() => {})
refreshPerfMetrics()
refreshAuthStatus()
invoke<boolean>('windows_hello_available').then(v => helloAvailable = v).catch(() => helloAvailable = false)
const timer = setInterval(() => refreshAuthStatus(), 1000)
return () => clearInterval(timer)
})
function refreshAuthStatus() { authStatus = getUiAuthDebugStatus() }
function fmtCountdown(ms: number | null): string {
if (ms === null) return '—'
if (ms <= 0) return 'expired'
const total = Math.floor(ms / 1000)
const month = 30 * 24 * 60 * 60
const day = 24 * 60 * 60
const hour = 60 * 60
const minute = 60
const months = Math.floor(total / month)
const days = Math.floor((total % month) / day)
const hours = Math.floor(total / 3600)
const remainingHours = Math.floor((total % day) / hour)
const mins = Math.floor((total % hour) / minute)
const secs = total % 60
if (months > 0) return days > 0 ? `${months}mo ${days}d` : `${months}mo`
if (days > 0) return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`
if (hours > 0) return `${hours}h ${mins}m ${secs}s`
if (mins > 0) return `${mins}m ${secs}s`
return `${secs}s`
}
function fmtTime(ts: number | null): string {
if (ts === null) return '—'
return new Date(ts).toLocaleString([], { dateStyle: 'medium', timeStyle: 'medium' })
}
async function forceTokenRefresh() {
authRefreshBusy = true
try {
const token = await refreshUiAccessToken(true)
toast({
kind: token ? 'success' : 'info',
title: 'UI auth refresh',
body: token ? 'Refresh succeeded' : 'No refreshed token available',
})
} catch (e: any) {
toast({ kind: 'error', title: 'UI auth refresh', body: String(e?.message ?? e) })
} finally {
authRefreshBusy = false
refreshAuthStatus()
}
}
function refreshPerfMetrics() {
let entries = 0, oldest: number | null = null, newest: number | null = null
const foundKeys: string[] = []
const checkKey = (k: string) => {
const age = cache.ageOf(k)
if (age !== undefined) {
entries++
foundKeys.push(k)
const ts = Date.now() - age
if (oldest === null || ts < oldest) oldest = ts
if (newest === null || ts > newest) newest = ts
}
}
['library', 'sources', 'popular'].forEach(checkKey)
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest }
}
function fmtAge(ts: number | null) {
if (ts === null) return '—'
const secs = Math.floor((Date.now() - ts) / 1000)
if (secs < 60) return `${secs}s ago`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ago`
return `${Math.floor(mins / 60)}h ago`
}
function triggerSplash() {
splashTriggered = true
setTimeout(() => splashTriggered = false, 200)
;(window as any).__mokuShowSplash?.()
}
async function testWindowsHello() {
helloBusy = true
try {
await invoke('windows_hello_authenticate', { reason: 'Moku devtools test' })
toast({ kind: 'success', title: 'Windows Hello', body: 'Verified successfully' })
} catch (e: any) {
toast({ kind: 'error', title: 'Windows Hello', body: String(e) })
} finally { helloBusy = false }
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Toasts</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
<div class="s-dev-pill-group">
{#each (['success', 'error', 'info', 'download'] as const) as kind (kind)}
{@const label = kind === 'success' ? 'S' : kind === 'error' ? 'E' : kind === 'info' ? 'I' : 'D'}
{@const title = kind === 'success' ? 'Library updated' : kind === 'error' ? 'Could not reach server' : kind === 'info' ? 'Already up to date' : 'Download complete'}
{@const body = kind === 'success' ? '3 new chapters across 2 series' : kind === 'error' ? 'Connection refused on port 4567' : kind === 'info' ? 'No new chapters found' : 'Berserk · Ch. 372 ready to read'}
<button class="s-dev-pill {kind}" onclick={() => toast({ kind, title, body })}>{label}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Previews</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Idle splash</span><span class="s-desc">Dismiss with any click or key</span></div>
<button class="s-btn" class:s-btn-accent={splashTriggered} onclick={triggerSplash}>Show</button>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Biometrics</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Windows Hello</span>
<span class="s-desc">Available: {helloAvailable === null ? '…' : helloAvailable ? 'yes' : 'no'}</span>
</div>
<button class="s-btn" disabled={!helloAvailable || helloBusy} onclick={testWindowsHello}>
{helloBusy ? '…' : 'Test'}
</button>
</div>
</div>
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => expOpen = !expOpen} aria-expanded={expOpen}>
<span class="s-label">Experimental</span>
<svg class="s-collapsible-caret" class:open={expOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if expOpen}
<div class="s-collapsible-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
<span class="s-desc">3D tilt cards — hover to preview</span>
<div style="display:flex;gap:var(--sp-3)">
{#each [{ title: 'Berserk', sub: 'Ch. 372', hue: '265' }, { title: 'Vinland Saga', sub: 'Ch. 208', hue: '200' }, { title: 'Dungeon Meshi', sub: 'Ch. 97', hue: '140' }] as card (card.title)}
<ThreeDCard>
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
<span style="font-size:10px;color:var(--text-faint)">{card.sub}</span>
</div>
</ThreeDCard>
{/each}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section">
<p class="s-section-title">Runtime</p>
<div class="s-section-body">
<div class="s-dev-grid">
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history.length} entries</span>
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
</div>
<div class="s-row">
<div class="s-row-info">
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<span class="s-desc">{perfSnapshot.cacheKeys.join(', ')}</span>
<span class="s-desc">Oldest: {fmtAge(perfSnapshot.oldestEntryMs)} · Newest: {fmtAge(perfSnapshot.newestEntryMs)}</span>
{/if}
</div>
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh cache stats"></button>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Auth (UI Login)</p>
<div class="s-section-body">
<div class="s-dev-grid">
<span class="s-dev-key">Mode</span> <span class="s-dev-val">{authStatus?.mode ?? '—'}</span>
<span class="s-dev-key">Session</span> <span class="s-dev-val">{authStatus?.hasSession ? 'present' : 'none'}</span>
<span class="s-dev-key">Refresh token</span> <span class="s-dev-val">{authStatus?.hasRefreshToken ? 'present' : 'none'}</span>
<span class="s-dev-key">Access expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.accessExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.refreshExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh window</span> <span class="s-dev-val">{authStatus?.shouldRefreshSoon ? 'open' : 'not yet'}</span>
<span class="s-dev-key">Refresh in-flight</span> <span class="s-dev-val">{authStatus?.refreshInFlight ? 'yes' : 'no'}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-desc">Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)}</span>
<span class="s-desc">Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)}</span>
<span class="s-desc">Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry</span>
</div>
<div class="s-btn-row">
<button class="s-btn" onclick={refreshAuthStatus}>Refresh</button>
<button class="s-btn s-btn-accent" onclick={forceTokenRefresh}
disabled={authRefreshBusy || authStatus?.mode !== 'UI_LOGIN' || !authStatus?.hasRefreshToken}>
{authRefreshBusy ? 'Refreshing…' : 'Force refresh'}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,368 @@
<script lang="ts">
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from 'phosphor-svelte'
import { getAdapter } from '$lib/request-manager'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import type { Category } from '$lib/types'
let categories = $state<Category[]>([])
let catsLoading = $state(false)
let catsError = $state<string | null>(null)
let newFolderName = $state('')
let editingId = $state<number | null>(null)
let editingName = $state('')
let dragStrId = $state<string | null>(null)
let dragOverStrId = $state<string | null>(null)
let dropPosition = $state<'above' | 'below' | null>(null)
const completedCat = $derived(categories.find(c => c.name === 'Completed' && c.id !== 0) ?? null)
const completedId = $derived(completedCat ? String(completedCat.id) : null)
const sortedCatIds = $derived(categories.filter(c => c.id !== 0).map(c => String(c.id)))
const orderedAllIds = $derived.by(() => {
const order = settingsState.settings.libraryPinnedTabOrder ?? []
const allIds = ['library', 'downloaded', ...sortedCatIds]
const known = new Set(allIds)
return [...new Set([...order.filter(id => known.has(id)), ...allIds])]
})
function isHidden(id: string) {
return (settingsState.settings.hiddenLibraryTabs ?? []).includes(id)
}
function toggleHidden(id: string) {
const current = settingsState.settings.hiddenLibraryTabs ?? []
updateSettings({ hiddenLibraryTabs: current.includes(id) ? current.filter(x => x !== id) : [...current, id] })
}
async function loadCategories() {
catsLoading = true; catsError = null
try {
const fresh = await getAdapter().getCategories()
const zeroCat = categories.filter(c => c.id === 0)
const merged = fresh.filter((c: Category) => c.id !== 0).map((f: Category) => {
const existing = categories.find(c => c.id === f.id)
return existing ? { ...existing, ...f } : f
})
categories = [...zeroCat, ...merged]
} catch (e: any) {
catsError = e?.message ?? 'Failed to load folders'
} finally { catsLoading = false }
}
async function createFolder() {
const name = newFolderName.trim()
if (!name) return
try {
const cat = await getAdapter().createCategory({ name })
categories = [...categories, cat]
newFolderName = ''
} catch (e: any) { catsError = e?.message ?? 'Failed to create folder' }
}
function startEdit(id: number, name: string) { editingId = id; editingName = name }
async function commitEdit() {
if (editingId !== null && editingName.trim()) {
try {
await getAdapter().updateCategory({ id: editingId, name: editingName.trim() })
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)
} catch (e: any) { catsError = e?.message ?? 'Failed to rename' }
}
editingId = null; editingName = ''
}
async function deleteFolder(id: number) {
try {
await getAdapter().deleteCategory({ id })
categories = categories.filter(c => c.id !== id)
} catch (e: any) { catsError = e?.message ?? 'Failed to delete folder' }
}
async function toggleCategoryFlag(id: number, flag: 'includeInUpdate' | 'includeInDownload') {
const cat = categories.find(c => c.id === id)
if (!cat) return
const next = !cat[flag]
categories = categories.map(c => c.id === id ? { ...c, [flag]: next } : c)
try {
await getAdapter().updateCategories({ ids: [id], patch: { [flag]: next ? 'INCLUDE' : 'EXCLUDE' } })
} catch (e: any) {
categories = categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)
catsError = e?.message ?? 'Failed to update folder'
}
}
function applyReorder(fromStrId: string, toStrId: string) {
const catIds = categories.filter(c => c.id !== 0).map(c => String(c.id))
const allIds = ['library', 'downloaded', ...catIds]
const current = settingsState.settings.libraryPinnedTabOrder ?? []
const base = [...new Set([...current.filter(id => allIds.includes(id)), ...allIds])]
const fromIdx = base.indexOf(fromStrId)
const toIdx = base.indexOf(toStrId)
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return
base.splice(fromIdx, 1)
base.splice(toIdx, 0, fromStrId)
updateSettings({ libraryPinnedTabOrder: base })
const fromNumId = Number(fromStrId)
if (!isNaN(fromNumId) && fromStrId !== 'library' && fromStrId !== 'downloaded') {
const zeroCat = categories.filter(c => c.id === 0)
const sortable = categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order)
const sFromIdx = sortable.findIndex(c => c.id === fromNumId)
const sToIdx = sortable.findIndex(c => String(c.id) === toStrId)
if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
const reordered = [...sortable]
const [moved] = reordered.splice(sFromIdx, 1)
reordered.splice(sToIdx, 0, moved)
categories = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
getAdapter().updateCategoryOrder({ id: fromNumId, position: sToIdx + 1 })
.then((updated: Category[]) => {
categories = [
...zeroCat,
...updated.sort((a: Category, b: Category) => a.order - b.order).map((fresh: Category) => {
const existing = categories.find(c => c.id === fresh.id)
return existing ? { ...existing, ...fresh } : fresh
}),
]
})
.catch(async (e: any) => {
catsError = e?.message ?? 'Failed to reorder'
await loadCategories()
})
}
}
}
function onDragStart(e: DragEvent, id: string) {
dragStrId = id
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id) }
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
if (dragStrId === id) return
dragOverStrId = id
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
dropPosition = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
}
function onDrop(e: DragEvent, id: string) {
e.preventDefault()
if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id)
dragStrId = null; dragOverStrId = null; dropPosition = null
}
function onDragEnd() { dragStrId = null; dragOverStrId = null; dropPosition = null }
function focusInput(node: HTMLElement) { node.focus() }
$effect(() => {
if (!categories.length && !catsLoading) loadCategories()
})
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Manage Folders</p>
<div class="s-section-body">
<div class="s-row">
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
</div>
{#if catsError}
<div class="s-banner s-banner-error">{catsError}</div>
{/if}
{#if catsLoading}
<p class="s-empty">Loading folders…</p>
{:else}
<div class="s-folder-list" class:is-dragging={dragStrId !== null}>
{#each orderedAllIds as id}
{@const isBuiltin = id === 'library' || id === 'downloaded'}
{@const isCompleted = id === completedId}
{@const cat = isBuiltin ? null : (categories.find(c => String(c.id) === id) ?? null)}
{@const hidden = isHidden(id)}
{#if isBuiltin || cat}
<div
class="s-folder-row"
class:dragging={dragStrId === id}
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === 'above'}
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === 'below'}
draggable="true"
ondragstart={(e) => onDragStart(e, id)}
ondragover={(e) => onDragOver(e, id)}
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null } }}
ondrop={(e) => onDrop(e, id)}
ondragend={onDragEnd}
>
{#if isCompleted}
<span class="s-folder-icon">
<CheckSquare size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if isBuiltin}
<span class="s-folder-icon">
{#if id === 'library'}<BookmarkSimple size={14} weight="light" />{:else}<DownloadSimple size={14} weight="light" />{/if}
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{id === 'library' ? 'Saved' : 'Downloaded'}</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if cat}
{#if editingId === cat.id}
<input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') { editingId = null } }}
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<div class="s-folder-identity" draggable="true"
ondragstart={(e) => onDragStart(e, id)}
ondragend={onDragEnd}>
<span class="s-folder-icon">
<FolderSimple size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</span>
</div>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<div class="s-folder-actions">
<button class="s-btn-icon"
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
onclick={() => updateSettings({ defaultLibraryCategoryId: (settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
title={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? 'Remove as default folder' : 'Set as default folder'}>
<Star size={13} weight={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? 'fill' : 'light'} />
</button>
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show in library' : 'Hide from library'}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon"
class:active={cat.includeInUpdate !== false}
class:inactive={cat.includeInUpdate === false}
onclick={() => toggleCategoryFlag(cat.id, 'includeInUpdate')}
title={cat.includeInUpdate !== false ? 'Included in updates — click to exclude' : 'Excluded from updates — click to include'}>
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon"
class:active={cat.includeInDownload !== false}
class:inactive={cat.includeInDownload === false}
onclick={() => toggleCategoryFlag(cat.id, 'includeInDownload')}
title={cat.includeInDownload !== false ? 'Included in auto-downloads — click to exclude' : 'Excluded from auto-downloads — click to include'}>
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? 'bold' : 'light'} />
</button>
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
<Trash size={12} weight="light" />
</button>
</div>
{/if}
{/if}
</div>
{/if}
{/each}
</div>
{#if categories.filter(c => c.id !== 0 && c.name !== 'Completed').length === 0}
<p class="s-empty">No custom folders yet. Create one below.</p>
{/if}
{/if}
<div class="s-folder-create">
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
onkeydown={(e) => e.key === 'Enter' && createFolder()} />
<button class="s-btn s-btn-accent" onclick={createFolder} disabled={!newFolderName.trim()}>
<Plus size={13} weight="bold" /> Create
</button>
</div>
</div>
</div>
</div>
<style>
.s-folder-list { display: contents; }
.s-folder-list.is-dragging,
.s-folder-list.is-dragging * { user-select: none; -webkit-user-select: none; }
.s-folder-row { transition: opacity 0.15s, background 0.1s; position: relative; }
.s-folder-row.dragging { opacity: 0.35; }
.s-folder-row.drop-above::before,
.s-folder-row.drop-below::after {
content: '';
position: absolute;
left: 8px; right: 8px;
height: 2px;
background: var(--color-success, #4ade80);
border-radius: 2px;
pointer-events: none;
z-index: 10;
}
.s-folder-row.drop-above::before { top: -1px; }
.s-folder-row.drop-below::after { bottom: -1px; }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-icon {
display: grid;
flex-shrink: 0;
}
.s-folder-icon > :global(*) { grid-area: 1 / 1; transition: opacity 0.12s; }
.s-folder-icon > :global(*:last-child) { opacity: 0; }
.s-folder-row:hover .s-folder-icon > :global(*:first-child) { opacity: 0; }
.s-folder-row:hover .s-folder-icon > :global(*:last-child) { opacity: 1; }
.s-folder-name {
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
.s-folder-name:hover { text-decoration: underline; text-underline-offset: 3px; }
.s-folder-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; flex-shrink: 0; }
.s-folder-badge {
font-size: 10px;
letter-spacing: 0.04em;
color: var(--text-faint);
background: var(--bg-subtle);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
margin-left: 6px;
}
.s-btn-icon.active { color: var(--accent, #6c8ef5); }
.s-btn-icon.inactive { color: var(--color-error, #f87171); opacity: 0.75; }
.s-btn-icon.inactive:hover { opacity: 1; }
.s-btn-icon.muted { color: var(--text-faint); opacity: 0.5; }
.s-btn-icon-lock { opacity: 0.25; cursor: not-allowed; }
.s-btn-icon-lock:hover { opacity: 0.25; color: inherit; }
</style>
@@ -0,0 +1,204 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { platformService } from '$lib/platform-service'
interface Props {
selectOpen: string | null
closingSelect: string | null
toggleSelect: (id: string) => void
anims: boolean
}
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props()
let triggerIdleTimeout = $state<HTMLButtonElement>(null!)
let serverAdvancedOpen = $state(false)
async function pickServerBinary() {
const path = await platformService.pickFolder()
if (path) updateSettings({ serverBinary: path })
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Interface Scale</p>
<div class="s-section-body">
<div class="s-slider-row">
<input type="range" min={50} max={200} step={5}
value={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
class="s-slider" />
<input type="number" min={50} max={200} step={1} class="s-slider-val"
value={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }) }}
onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = '50' } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = '200' } }}
/>
<span class="s-slider-unit">%</span>
<button class="s-btn-icon" onclick={() => updateSettings({ uiZoom: 1.0 })} disabled={(settingsState.settings.uiZoom ?? 1.0) === 1.0} title="Reset to 100%"></button>
</div>
<div class="s-presets">
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="s-preset" class:active={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Server</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Server URL</span>
<span class="s-desc">Base URL of your Suwayomi instance</span>
</div>
<div class="srv-url-group">
<input class="s-input" value={settingsState.settings.serverUrl ?? 'http://localhost:4567'}
oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })}
placeholder="http://localhost:4567" spellcheck="false" />
<button class="srv-adv-btn" class:open={serverAdvancedOpen}
onclick={() => serverAdvancedOpen = !serverAdvancedOpen}
title="Server launch options" aria-expanded={serverAdvancedOpen}>
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={settingsState.settings.autoStartServer} aria-label="Auto-start server"
class="s-toggle" class:on={settingsState.settings.autoStartServer}
onclick={() => updateSettings({ autoStartServer: !settingsState.settings.autoStartServer })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Suwayomi Web UI</span><span class="s-desc">Enable the built-in Suwayomi web interface alongside Moku</span></div>
<button role="switch" aria-checked={settingsState.settings.suwayomiWebUI ?? false} aria-label="Suwayomi Web UI"
class="s-toggle" class:on={settingsState.settings.suwayomiWebUI ?? false}
onclick={() => updateSettings({ suwayomiWebUI: !(settingsState.settings.suwayomiWebUI ?? false) })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
{#if serverAdvancedOpen}
<div class="srv-adv-panel">
<div class="srv-adv-row">
<div class="s-row-info">
<span class="s-label">Server binary</span>
<span class="s-desc">Path to server executable — leave blank to use bundled</span>
</div>
<div class="srv-file-group">
<input class="s-input srv-path-input" value={settingsState.settings.serverBinary ?? ''}
oninput={(e) => updateSettings({ serverBinary: e.currentTarget.value })}
placeholder="auto-detect" spellcheck="false" />
<button class="srv-file-btn" onclick={pickServerBinary} title="Browse">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1.5 4.5h11v7a1 1 0 01-1 1h-9a1 1 0 01-1-1v-7z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M1.5 4.5l1.8-2.5h3.4l1.3 2.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Window</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
<div class="s-seg">
{#each [['ask','Ask'],['tray','Tray'],['quit','Quit']] as [v, l]}
<button class="s-seg-btn" class:active={(settingsState.settings.closeAction ?? 'ask') === v} onclick={() => updateSettings({ closeAction: v as 'ask' | 'tray' | 'quit' })}>{l}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Inactivity</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Idle screen timeout</span><span class="s-desc">Show the Moku idle splash after this much inactivity</span></div>
<div class="s-select">
<button bind:this={triggerIdleTimeout} class="s-select-btn" onclick={() => toggleSelect('idle-timeout')}>
<span>{{ '0':'Never','1':'1 minute','2':'2 minutes','5':'5 minutes','10':'10 minutes','15':'15 minutes','30':'30 minutes' }[String(settingsState.settings.idleTimeoutMin ?? 5)] ?? `${settingsState.settings.idleTimeoutMin} min`}</span>
<svg class="s-select-caret" class:open={selectOpen === 'idle-timeout'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'}
<div class="s-select-menu" class:anims class:closing={closingSelect === 'idle-timeout'}>
{#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]}
<button class="s-select-option" class:active={String(settingsState.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect('idle-timeout') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Integrations</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Discord Rich Presence</span><span class="s-desc">Show what you're reading in your Discord status</span></div>
<button role="switch" aria-checked={settingsState.settings.discordRpc} aria-label="Discord Rich Presence" class="s-toggle" class:on={settingsState.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !settingsState.settings.discordRpc })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Animations</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">QOL Animations</span><span class="s-desc">Hover lifts, active-tab transitions, and icon micro-animations</span></div>
<button role="switch" aria-checked={settingsState.settings.qolAnimations ?? true} aria-label="QOL Animations" class="s-toggle" class:on={settingsState.settings.qolAnimations ?? true} onclick={() => updateSettings({ qolAnimations: !(settingsState.settings.qolAnimations ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Language</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Preferred source language</span>
<span class="s-desc">Used to pre-select languages in Search and deduplicate sources</span>
</div>
<input class="s-input" style="width:72px;text-align:center;text-transform:uppercase"
value={settingsState.settings.preferredExtensionLang ?? ''}
oninput={(e) => updateSettings({ preferredExtensionLang: e.currentTarget.value.trim().toLowerCase() })}
placeholder="en" spellcheck="false" />
</div>
</div>
</div>
</div>
<style>
.s-seg { display: flex; border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; }
.s-seg-btn { flex: 1; padding: var(--sp-1) var(--sp-3); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); background: transparent; cursor: pointer; transition: background var(--t-base), color var(--t-base); border: none; }
.s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); }
.s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); }
.s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); }
.srv-url-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.srv-adv-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-faint); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.srv-adv-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
.srv-adv-btn.open { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
.srv-adv-btn svg { transition: transform var(--t-base); }
.srv-adv-btn.open svg { transform: rotate(180deg); }
.srv-adv-panel { border-top: 1px solid var(--border-dim); background: var(--bg-base); }
.srv-adv-row { display: flex; align-items: center; justify-content: space-between; padding: 10px var(--sp-4); gap: var(--sp-4); }
.srv-file-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.srv-path-input { width: 160px; }
.srv-file-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-faint); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.srv-file-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
</style>
@@ -0,0 +1,55 @@
<script lang="ts">
export { eventToKeybind, matchesKeybind } from './keybindEngine'
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
export type { Keybinds } from './defaultBinds'
let listeningKey: keyof Keybinds | null = $state(null)
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key
}
function onKeyCapture(e: KeyboardEvent) {
if (!listeningKey) return
e.preventDefault(); e.stopPropagation()
const bind = eventToKeybind(e)
if (!bind) return
updateSettings({ keybinds: { ...settingsState.settings.keybinds, [listeningKey]: bind } })
listeningKey = null
}
$effect(() => {
if (listeningKey) {
window.addEventListener('keydown', onKeyCapture, true)
return () => window.removeEventListener('keydown', onKeyCapture, true)
}
})
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">
Keyboard Shortcuts
<button class="s-btn" onclick={resetKeybinds}>Reset all</button>
</p>
<p class="s-kb-hint">Click a binding to rebind, then press the new key combination.</p>
<div class="s-section-body">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
{@const isListening = listeningKey === k}
{@const isDefault = settingsState.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
<div class="s-kb-row">
<span class="s-kb-label">{KEYBIND_LABELS[k]}</span>
<div class="s-kb-right">
<button class="s-kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
{isListening ? 'Press key…' : settingsState.settings.keybinds[k]}
</button>
<button class="s-btn-icon"
onclick={() => updateSettings({ keybinds: { ...settingsState.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })}
disabled={isDefault} title="Reset">↺</button>
</div>
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,92 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { homeState } from '$lib/state/home.svelte'
import type { Settings } from '$lib/types/settings'
interface Props {
selectOpen: string | null
closingSelect?: string | null
toggleSelect: (id: string) => void
anims: boolean
}
let { selectOpen, toggleSelect, anims }: Props = $props()
let triggerSortDir = $state<HTMLButtonElement>(null!)
function clearHistory() {
homeState.history = []
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Display</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Always show card stats</span><span class="s-desc">Show unread and download counts without needing to hover</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryStatsAlways ?? false} aria-label="Always show card stats" class="s-toggle" class:on={settingsState.settings.libraryStatsAlways ?? false} onclick={() => updateSettings({ libraryStatsAlways: !(settingsState.settings.libraryStatsAlways ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={settingsState.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !settingsState.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={settingsState.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(settingsState.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
{#if settingsState.settings.libraryShowAllInSaved ?? true}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Hide completed in Saved tab</span><span class="s-desc">Keep manga in the Completed folder out of the Saved view</span></div>
<button role="switch" aria-checked={settingsState.settings.libraryHideCompletedInSaved ?? false} aria-label="Hide completed manga in Saved tab" class="s-toggle" class:on={settingsState.settings.libraryHideCompletedInSaved ?? false} onclick={() => updateSettings({ libraryHideCompletedInSaved: !(settingsState.settings.libraryHideCompletedInSaved ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Chapters</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default sort direction</span><span class="s-desc">Initial chapter list order when opening a manga</span></div>
<div class="s-select">
<button bind:this={triggerSortDir} class="s-select-btn" onclick={() => toggleSelect('sort-dir')}>
<span>{{ 'desc':'Newest first','asc':'Oldest first' }[settingsState.settings.chapterSortDir]}</span>
<svg class="s-select-caret" class:open={selectOpen === 'sort-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'sort-dir'}
<div class="s-select-menu" class:anims>
{#each [['desc','Newest first'],['asc','Oldest first']] as [v, l]}
<button class="s-select-option" class:active={settingsState.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings['chapterSortDir'] }); toggleSelect('sort-dir') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Series</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-link on open</span><span class="s-desc">When opening a manga, automatically link it to similarly-titled entries</span></div>
<button role="switch" aria-checked={settingsState.settings.autoLinkOnOpen ?? false} aria-label="Auto-link on open" class="s-toggle" class:on={settingsState.settings.autoLinkOnOpen ?? false} onclick={() => updateSettings({ autoLinkOnOpen: !(settingsState.settings.autoLinkOnOpen ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Disable auto-complete</span><span class="s-desc">Don't move manga to the Completed folder when all chapters are read</span></div>
<button role="switch" aria-checked={settingsState.settings.disableAutoComplete} aria-label="Disable auto-complete" class="s-toggle" class:on={settingsState.settings.disableAutoComplete} onclick={() => updateSettings({ disableAutoComplete: !settingsState.settings.disableAutoComplete })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history.length} entries</span></div>
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={homeState.history.length === 0}>Clear</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,161 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { cache } from '$lib/core/cache/queryCache'
interface PerfSnapshot {
cacheEntries: number
cacheKeys: string[]
oldestEntryMs: number | null
newestEntryMs: number | null
}
let perfSnapshot = $state<PerfSnapshot | null>(null)
let clearing = $state(false)
let cleared = $state(false)
function refreshPerfMetrics() {
let entries = 0, oldest: number | null = null, newest: number | null = null
const foundKeys: string[] = []
const checkKey = (k: string) => {
const age = cache.ageOf(k)
if (age !== undefined) {
entries++
foundKeys.push(k)
const ts = Date.now() - age
if (oldest === null || ts < oldest) oldest = ts
if (newest === null || ts > newest) newest = ts
}
}
['library', 'sources', 'popular'].forEach(checkKey)
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest }
}
function fmtAge(ts: number | null): string {
if (ts === null) return '—'
const secs = Math.floor((Date.now() - ts) / 1000)
if (secs < 60) return `${secs}s ago`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ago`
return `${Math.floor(mins / 60)}h ago`
}
function handleClearCache() {
clearing = true
caches.keys()
.then(names => Promise.all(names.map(n => caches.delete(n))))
.catch(() => {})
.finally(() => {
clearing = false
cleared = true
setTimeout(() => cleared = false, 2500)
refreshPerfMetrics()
})
}
$effect(() => { refreshPerfMetrics() })
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Render Limit</p>
<div class="s-section-body">
<div class="s-slider-row">
<div class="s-row-info">
<span class="s-label">Items per page</span>
<span class="s-desc">Lower = faster on large libraries</span>
</div>
<div class="s-stepper">
<button class="s-step-btn"
onclick={() => updateSettings({ renderLimit: Math.max(12, (settingsState.settings.renderLimit ?? 48) - 12) })}
disabled={(settingsState.settings.renderLimit ?? 48) <= 12}></button>
<span class="s-step-val">{settingsState.settings.renderLimit ?? 48}</span>
<button class="s-step-btn"
onclick={() => updateSettings({ renderLimit: Math.min(200, (settingsState.settings.renderLimit ?? 48) + 12) })}
disabled={(settingsState.settings.renderLimit ?? 48) >= 200}>+</button>
</div>
</div>
<div class="s-presets">
{#each [12, 24, 48, 96, 200] as v}
<button class="s-preset" class:active={(settingsState.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Rendering</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">GPU acceleration</span><span class="s-desc">Uses the GPU for rendering; disable if you see visual glitches</span></div>
<button role="switch" aria-checked={settingsState.settings.gpuAcceleration} aria-label="GPU acceleration"
class="s-toggle" class:on={settingsState.settings.gpuAcceleration}
onclick={() => updateSettings({ gpuAcceleration: !settingsState.settings.gpuAcceleration })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Idle / Splash Screen</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Animated card background</span><span class="s-desc">Shows cover art cards floating in the background on the idle screen</span></div>
<button role="switch" aria-checked={settingsState.settings.splashCards ?? true} aria-label="Animated card background"
class="s-toggle" class:on={settingsState.settings.splashCards ?? true}
onclick={() => updateSettings({ splashCards: !(settingsState.settings.splashCards ?? true) })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Session Cache</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Cache entries</span>
<span class="s-desc">In-memory, cleared on restart</span>
</div>
<div class="s-btn-row">
<span class="s-step-val">{perfSnapshot?.cacheEntries ?? 0} entries</span>
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh"></button>
</div>
</div>
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Oldest entry</span></div>
<span class="s-step-val">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Newest entry</span></div>
<span class="s-step-val">{fmtAge(perfSnapshot.newestEntryMs)}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Cached keys</span>
<span class="s-desc">{perfSnapshot.cacheKeys.join(', ')}</span>
</div>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Cache</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Image cache</span><span class="s-desc">Webview page image cache</span></div>
<button class="s-btn s-btn-danger" onclick={handleClearCache} disabled={clearing}>
{cleared ? 'Cleared' : clearing ? 'Clearing…' : 'Clear'}
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,128 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import type { Settings, FitMode } from '$lib/types/settings'
interface Props {
selectOpen: string | null
closingSelect?: string | null
toggleSelect: (id: string) => void
anims: boolean
}
let { selectOpen, toggleSelect, anims }: Props = $props()
let triggerPageStyle = $state<HTMLButtonElement>(null!)
let triggerReadingDir = $state<HTMLButtonElement>(null!)
let triggerFitMode = $state<HTMLButtonElement>(null!)
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Page Layout</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default layout</span><span class="s-desc">How chapters open by default</span></div>
<div class="s-select">
<button bind:this={triggerPageStyle} class="s-select-btn" onclick={() => toggleSelect('page-style')}>
<span>{{ 'single':'Single page','longstrip':'Long strip' }[settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle]}</span>
<svg class="s-select-caret" class:open={selectOpen === 'page-style'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'page-style'}
<div class="s-select-menu" class:anims>
{#each [['single','Single page'],['longstrip','Long strip']] as [v, l]}
<button class="s-select-option" class:active={(settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings['pageStyle'] }); toggleSelect('page-style') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reading direction</span><span class="s-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
<div class="s-select">
<button bind:this={triggerReadingDir} class="s-select-btn" onclick={() => toggleSelect('reading-dir')}>
<span>{{ 'ltr':'Left to right','rtl':'Right to left' }[settingsState.settings.readingDirection]}</span>
<svg class="s-select-caret" class:open={selectOpen === 'reading-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'reading-dir'}
<div class="s-select-menu" class:anims>
{#each [['ltr','Left to right'],['rtl','Right to left']] as [v, l]}
<button class="s-select-option" class:active={settingsState.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings['readingDirection'] }); toggleSelect('reading-dir') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Page gap</span><span class="s-desc">Adds spacing between pages in single-page mode</span></div>
<button role="switch" aria-checked={settingsState.settings.pageGap} aria-label="Page gap" class="s-toggle" class:on={settingsState.settings.pageGap} onclick={() => updateSettings({ pageGap: !settingsState.settings.pageGap })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Overlay bars</span><span class="s-desc">Floats the nav and chapter bars over the page instead of pushing content</span></div>
<button role="switch" aria-checked={settingsState.settings.overlayBars ?? false} aria-label="Overlay bars" class="s-toggle" class:on={settingsState.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(settingsState.settings.overlayBars ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Tap to toggle bar</span><span class="s-desc">Double-tap the center of the reader to show or hide the bars</span></div>
<button role="switch" aria-checked={settingsState.settings.tapToToggleBar ?? false} aria-label="Tap to toggle bar" class="s-toggle" class:on={settingsState.settings.tapToToggleBar ?? false} onclick={() => updateSettings({ tapToToggleBar: !(settingsState.settings.tapToToggleBar ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Fit &amp; Zoom</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default fit mode</span><span class="s-desc">How pages are scaled to fill the reader on open</span></div>
<div class="s-select">
<button bind:this={triggerFitMode} class="s-select-btn" onclick={() => toggleSelect('fit-mode')}>
<span>{{ 'width':'Fit width','height':'Fit height','screen':'Fit screen','original':'Original (1:1)' }[settingsState.settings.fitMode ?? 'width']}</span>
<svg class="s-select-caret" class:open={selectOpen === 'fit-mode'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'fit-mode'}
<div class="s-select-menu" class:anims>
{#each [['width','Fit width'],['height','Fit height'],['screen','Fit screen'],['original','Original (1:1)']] as [v, l]}
<button class="s-select-option" class:active={(settingsState.settings.fitMode ?? 'width') === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); toggleSelect('fit-mode') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Optimize contrast</span><span class="s-desc">Sharpens dark lines on light pages; best for black-and-white manga</span></div>
<button role="switch" aria-checked={settingsState.settings.optimizeContrast} aria-label="Optimize contrast" class="s-toggle" class:on={settingsState.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !settingsState.settings.optimizeContrast })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Behaviour</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-mark read</span><span class="s-desc">Marks a chapter as read when you reach the last page</span></div>
<button role="switch" aria-checked={settingsState.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="s-toggle" class:on={settingsState.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !settingsState.settings.autoMarkRead })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-advance chapters</span><span class="s-desc">Automatically loads the next chapter when you pass the last page</span></div>
<button role="switch" aria-checked={settingsState.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="s-toggle" class:on={settingsState.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(settingsState.settings.autoNextChapter ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
{#if !(settingsState.settings.autoNextChapter ?? false)}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Mark read when skipping</span><span class="s-desc">Marks the current chapter read when you manually jump to the next</span></div>
<button role="switch" aria-checked={settingsState.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="s-toggle" class:on={settingsState.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(settingsState.settings.markReadOnNext ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
{/if}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-bookmark</span><span class="s-desc">Automatically saves your page position as you read</span></div>
<button role="switch" aria-checked={settingsState.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="s-toggle" class:on={settingsState.settings.autoBookmark ?? true} onclick={() => updateSettings({ autoBookmark: !(settingsState.settings.autoBookmark ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Pages to preload</span><span class="s-desc">How many pages ahead to fetch in the background while reading</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, settingsState.settings.preloadPages - 1) })} disabled={settingsState.settings.preloadPages <= 0}></button>
<span class="s-step-val">{settingsState.settings.preloadPages}</span>
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, settingsState.settings.preloadPages + 1) })} disabled={settingsState.settings.preloadPages >= 10}>+</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,340 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { requestManager } from '$lib/request-manager'
import { authSession, loginUI } from '$lib/core/auth'
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
let { selectOpen, toggleSelect }: Props = $props()
let showAuthPass = $state(false)
let showSocksPass = $state(false)
let secLoading = $state(false)
let secError = $state<string | null>(null)
let secSaved = $state<string | null>(null)
let secLoaded = $state(false)
let authMode = $state(settingsState.settings.serverAuthMode ?? 'NONE')
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
let authPassword = $state('')
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
let socksPort = $state(settingsState.settings.socksProxyPort ?? '1080')
let socksVersion = $state(settingsState.settings.socksProxyVersion ?? 5)
let socksUsername = $state(settingsState.settings.socksProxyUsername ?? '')
let socksPassword = $state(settingsState.settings.socksProxyPassword ?? '')
let flareEnabled = $state(settingsState.settings.flareSolverrEnabled ?? false)
let flareUrl = $state(settingsState.settings.flareSolverrUrl ?? 'http://localhost:8191')
let flareTimeout = $state(settingsState.settings.flareSolverrTimeout ?? 60)
let flareSession = $state(settingsState.settings.flareSolverrSessionName ?? 'moku')
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
return 'NONE'
}
function showSaved(key: string) {
secSaved = key; secError = null
setTimeout(() => { if (secSaved === key) secSaved = null }, 2000)
}
$effect(() => {
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() }
})
async function loadServerSecurity() {
try {
const s = await requestManager.extensions.getServerSecurity()
const serverMode = normalizeAuthMode(s.authMode)
if (serverMode !== 'UI_LOGIN') authSession.clearTokens()
authMode = serverMode
authUsername = s.authUsername || ''
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername })
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
socksUsername = s.socksProxyUsername
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
})
} catch {}
}
async function saveAuth() {
if (authMode === 'NONE') { await clearAuth(); return }
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
secLoading = true; secError = null
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try {
const newUser = authUsername.trim()
const newPass = authPassword.trim()
authSession.clearTokens()
if (authMode === 'UI_LOGIN') {
await loginUI(newUser, newPass)
updateSettings({ serverAuthMode: 'UI_LOGIN', serverAuthUser: newUser, serverAuthPass: '' })
} else {
updateSettings({ serverAuthMode: 'BASIC_AUTH', serverAuthUser: newUser, serverAuthPass: newPass })
}
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
authPassword = ''
showSaved('auth')
} catch (e: any) {
const msg = e?.message ?? 'Failed to save authentication settings'
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg)
if (!authMismatch) {
authSession.clearTokens()
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
}
secError = authMismatch
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.'
: msg
} finally { secLoading = false }
}
async function clearAuth() {
secLoading = true; secError = null
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try {
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
authMode = 'NONE'; authUsername = ''; authPassword = ''
authSession.clearTokens(); showSaved('auth')
} catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
secError = e?.message ?? 'Failed to disable authentication'
} finally { secLoading = false }
}
async function saveSocksProxy() {
secLoading = true; secError = null
try {
await requestManager.extensions.setSocksProxy({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
})
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
})
showSaved('socks')
} catch (e: any) {
secError = e?.message ?? 'Failed to save SOCKS proxy'
} finally { secLoading = false }
}
async function saveFlareSolverr() {
secLoading = true; secError = null
try {
await requestManager.extensions.setFlareSolverr({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
})
updateSettings({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
})
showSaved('flare')
} catch (e: any) {
secError = e?.message ?? 'Failed to save FlareSolverr'
} finally { secLoading = false }
}
function forceResetAuth() {
authSession.clearTokens()
authMode = 'NONE'
authUsername = ''
authPassword = ''
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
showSaved('auth')
}
const EyeOpen = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`
const EyeClose = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`
</script>
<div class="s-panel">
{#if secError}
<div class="s-banner s-banner-error">{secError}</div>
{/if}
<div class="s-section">
<p class="s-section-title">
Server Authentication
<span class="s-pill" class:on={settingsState.settings.serverAuthMode === 'BASIC_AUTH' || settingsState.settings.serverAuthMode === 'UI_LOGIN'}>
{settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Basic Auth' :
settingsState.settings.serverAuthMode === 'UI_LOGIN' ? 'UI Login' : 'Disabled'}
</span>
</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Moku authenticates with the server</span></div>
<div class="s-segment">
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
<button class="s-segment-btn" class:active={authMode === opt.value}
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
{/each}
</div>
</div>
{#if authMode !== 'NONE'}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span></div>
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
</div>
</div>
{/if}
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
<div class="s-row">
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
</div>
{/if}
<div class="s-row">
<div class="s-row-info">
<button class="s-ghost-btn" onclick={forceResetAuth} disabled={secLoading} title="Force reset local auth state">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset
</button>
</div>
<div class="s-btn-row">
{#if settingsState.settings.serverAuthMode !== 'NONE'}
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
{secLoading ? 'Saving…' : 'Disable'}
</button>
{/if}
<button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}>
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'}
</button>
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">SOCKS Proxy</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable SOCKS proxy</span><span class="s-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span></div>
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="s-toggle" class:on={socksEnabled}
onclick={() => { socksEnabled = !socksEnabled; saveSocksProxy() }}><span class="s-toggle-thumb"></span></button>
</label>
{#if socksEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Version</span></div>
<div class="s-select" id="socks-ver">
<button class="s-select-btn" onclick={() => toggleSelect('socks-ver')}>
<span>SOCKS{socksVersion}</span>
<svg class="s-select-caret" class:open={selectOpen === 'socks-ver'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === 'socks-ver'}
<div class="s-select-menu">
{#each [[4, 'SOCKS4'], [5, 'SOCKS5']] as [v, l]}
<button class="s-select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; toggleSelect('socks-ver') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Host</span></div>
<input class="s-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Port</span></div>
<input class="s-input" style="width:80px" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span><span class="s-desc">Optional</span></div>
<input class="s-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span><span class="s-desc">Optional</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showSocksPass ? 'text' : 'password'} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
<button class="s-eye-btn" onclick={() => showSocksPass = !showSocksPass} tabindex="-1" aria-label={showSocksPass ? 'Hide password' : 'Show password'}>{@html showSocksPass ? EyeClose : EyeOpen}</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveSocksProxy} disabled={secLoading}>
{secLoading ? 'Saving…' : secSaved === 'socks' ? 'Saved ✓' : 'Save'}
</button>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">FlareSolverr</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable FlareSolverr</span><span class="s-desc">Bypass Cloudflare challenges for sources that require it</span></div>
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="s-toggle" class:on={flareEnabled}
onclick={() => { flareEnabled = !flareEnabled; saveFlareSolverr() }}><span class="s-toggle-thumb"></span></button>
</label>
{#if flareEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">URL</span><span class="s-desc">FlareSolverr instance address</span></div>
<input class="s-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Timeout</span><span class="s-desc">Max wait per request, in seconds</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}></button>
<span class="s-step-val">{flareTimeout}s</span>
<button class="s-step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session name</span><span class="s-desc">Reuse browser session across requests</span></div>
<input class="s-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session TTL</span><span class="s-desc">Minutes before session is refreshed</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}></button>
<span class="s-step-val">{flareTtl}m</span>
<button class="s-step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Response fallback</span><span class="s-desc">Use FlareSolverr's response when the direct request fails</span></div>
<button role="switch" aria-checked={flareFallback} aria-label="Response fallback" class="s-toggle" class:on={flareFallback}
onclick={() => flareFallback = !flareFallback}><span class="s-toggle-thumb"></span></button>
</label>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveFlareSolverr} disabled={secLoading}>
{secLoading ? 'Saving…' : secSaved === 'flare' ? 'Saved ✓' : 'Save'}
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
</style>
@@ -0,0 +1,869 @@
<script lang="ts">
import { Trash, ClockCounterClockwise } from 'phosphor-svelte'
import { invoke } from '@tauri-apps/api/core'
import { untrack } from 'svelte'
import { appState } from '$lib/state/app.svelte'
import { toast } from '$lib/state/notifications.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { exportAppData, importAppData } from '$lib/core/backup'
import { loadBackups, persistBackups, persistSettings, persistLibrary } from '$lib/core/persistence/persist'
import type { BackupEntry } from '$lib/core/persistence/persist'
import { DEFAULT_SETTINGS } from '$lib/types/settings'
import { DEFAULT_READING_STATS } from '$lib/types/history'
import { clearBlobCache } from '$lib/core/cache/imageCache'
import { clearPageCache } from '$lib/request-manager'
import { cache as queryCache } from '$lib/core/cache/queryCache'
type ResetState = 'idle' | 'busy' | 'done' | 'error'
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean }
let resetItems = $state<ResetItem[]>([
{ key: 'all-cache', label: 'Clear all caches', desc: 'Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.', state: 'idle', error: null, confirm: false },
{ key: 'reading-history', label: 'Clear reading history', desc: 'Erases chapter history, read log, reading stats, and daily read counts.', state: 'idle', error: null, confirm: true },
{ key: 'moku-settings', label: 'Reset Moku settings', desc: 'Restores all app settings to their defaults. Does not affect library data.', state: 'idle', error: null, confirm: true },
{ key: 'suwayomi-data', label: 'Reset Suwayomi data', desc: 'Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.', state: 'idle', error: null, confirm: true },
])
let confirming = $state<string | null>(null)
function patchReset(key: string, update: Partial<ResetItem>) {
resetItems = resetItems.map(i => i.key === key ? { ...i, ...update } : i)
}
function showExitCountdown(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement('div')
backdrop.className = 's-backdrop'
backdrop.style.cssText = 'z-index:99999'
const modal = document.createElement('div')
modal.style.cssText = 'background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7);width:min(400px,calc(100vw - 40px));display:flex;flex-direction:column;overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both'
const header = document.createElement('div')
header.style.cssText = 'padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)'
const title = document.createElement('p')
title.style.cssText = 'margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em'
title.textContent = 'Reset complete'
header.appendChild(title)
const body = document.createElement('div')
body.style.cssText = 'padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)'
const sub = document.createElement('p')
sub.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)'
sub.textContent = 'Moku will close so you can relaunch with the reset applied.'
const counter = document.createElement('p')
counter.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)'
counter.textContent = 'Closing in 3…'
body.append(sub, counter)
const footer = document.createElement('div')
footer.style.cssText = 'padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end'
const btn = document.createElement('button')
btn.className = 's-btn s-btn-danger'
btn.textContent = 'Close now'
footer.appendChild(btn)
modal.append(header, body, footer)
backdrop.appendChild(modal)
document.body.appendChild(backdrop)
let secs = 3
const tick = setInterval(() => {
secs--
counter.textContent = secs > 0 ? `Closing in ${secs}…` : 'Closing…'
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve() }
}, 1000)
btn.addEventListener('click', () => { clearInterval(tick); backdrop.remove(); resolve() })
})
}
async function clearAllCaches(): Promise<void> {
clearBlobCache()
clearPageCache()
queryCache.clearAll()
await Promise.all([
invoke('clear_moku_cache'),
invoke('clear_suwayomi_cache'),
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
])
}
async function runReset(key: string) {
confirming = null
patchReset(key, { state: 'busy', error: null })
try {
switch (key) {
case 'all-cache':
await clearAllCaches()
break
case 'reading-history':
await persistLibrary({ history: [], bookmarks: [], markers: [], readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} })
break
case 'moku-settings':
localStorage.clear()
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 })
patchReset(key, { state: 'done' })
await showExitCountdown()
invoke('exit_app')
return
case 'suwayomi-data':
localStorage.clear()
await invoke('reset_suwayomi_data')
patchReset(key, { state: 'done' })
await showExitCountdown()
invoke('exit_app')
return
}
patchReset(key, { state: 'done' })
setTimeout(() => patchReset(key, { state: 'idle' }), 3000)
} catch (e: any) {
patchReset(key, { state: 'error', error: e?.message ?? String(e) })
}
}
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string }
const isExternalServer = $derived.by(() => {
const url = (settingsState.settings.serverUrl ?? 'http://localhost:4567').toLowerCase().trim()
try {
const host = new URL(url).hostname
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
} catch { return false }
})
let storageInfo = $state<StorageInfo | null>(null)
let storageLoading = $state(false)
let storageError = $state<string | null>(null)
let downloadsPathInput = $state(settingsState.settings.serverDownloadsPath ?? '')
let localSourcePathInput = $state(settingsState.settings.serverLocalSourcePath ?? '')
let pathsSaving = $state(false)
let pathsError = $state<string | null>(null)
let pathsFieldError = $state<{ dl?: string; loc?: string }>({})
let pathsSaved = $state(false)
let defaultDownloadsPath = $state('')
$effect(() => {
if (!isExternalServer) {
invoke<string>('get_default_downloads_path').then(p => { defaultDownloadsPath = p })
} else {
defaultDownloadsPath = ''
}
})
let confirmedDownloadsPath = $state(settingsState.settings.serverDownloadsPath ?? '')
let confirmedLocalSourcePath = $state(settingsState.settings.serverLocalSourcePath ?? '')
let migrateFrom = $state<string | null>(null)
let migrateTo = $state<string | null>(null)
let migrating = $state(false)
let migrateProgress = $state<{ done: number; total: number; current: string } | null>(null)
let migrateError = $state<string | null>(null)
let migrateUnlisten: (() => void) | null = null
let extraScanDirs = $state<string[]>([...(settingsState.settings.extraScanDirs ?? [])])
let newScanDir = $state('')
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([])
let advStorageOpen = $state(false)
let backupSectionOpen = $state(false)
let resetSectionOpen = $state(false)
async function fetchStorage() {
storageLoading = true; storageError = null
try {
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
`{ downloadsPath localSourcePath }`
)
const dl = pathData.downloadsPath ?? ''
const loc = pathData.localSourcePath ?? ''
downloadsPathInput = dl; localSourcePathInput = loc
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
if (isExternalServer) { multiStorageInfos = []; storageInfo = null; return }
const effectiveDl = dl || defaultDownloadsPath
const dirsToScan: { path: string; label: string }[] = []
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? 'Downloads' : 'Downloads (default)' })
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: 'Local source' })
for (const p of extraScanDirs) {
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p })
}
if (dirsToScan.length === 0) { multiStorageInfos = []; storageInfo = null; return }
const results = await Promise.allSettled(
dirsToScan.map(d => invoke<StorageInfo>('get_storage_info', { downloadsPath: d.path }).then(info => ({ ...info, label: d.label })))
)
multiStorageInfos = results
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === 'fulfilled')
.map(r => r.value)
storageInfo = multiStorageInfos[0] ?? null
} catch (e: any) {
storageError = e instanceof Error ? e.message : String(e)
} finally { storageLoading = false }
}
async function validatePath(path: string): Promise<string | null> {
if (!path.trim()) return null
if (isExternalServer) return null
try {
const exists = await invoke<boolean>('check_path_exists', { path: path.trim() })
return exists ? null : 'Directory does not exist'
} catch { return 'Could not check path' }
}
async function createDirectory(path: string): Promise<void> {
if (isExternalServer) throw new Error('Cannot create directories on an external server')
await invoke('create_directory', { path })
}
async function savePaths() {
const dl = downloadsPathInput.trim()
const loc = localSourcePathInput.trim()
pathsError = null; pathsFieldError = {}
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)])
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
pathsSaving = true
try {
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
if (!isExternalServer) {
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
const newDl = dl || defaultDownloadsPath
if (newDl && oldDl && newDl !== oldDl) {
const hadContent = await invoke<boolean>('check_path_exists', { path: oldDl })
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl }
}
}
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
pathsSaved = true; setTimeout(() => pathsSaved = false, 2000)
await fetchStorage()
} catch (e: any) {
pathsError = e?.message ?? 'Failed to save paths'
} finally { pathsSaving = false }
}
async function startMigration() {
if (!migrateFrom || !migrateTo) return
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: '' }
const { listen: tauriListen } = await import('@tauri-apps/api/event')
migrateUnlisten = await tauriListen<{ done: number; total: number; current: string }>(
'migrate_progress', e => { migrateProgress = e.payload }
)
try {
await invoke('migrate_downloads', { src: migrateFrom, dst: migrateTo })
migrateFrom = null; migrateTo = null; migrateProgress = null
await fetchStorage()
} catch (e: any) {
migrateError = e?.message ?? 'Migration failed'
} finally { migrating = false; migrateUnlisten?.(); migrateUnlisten = null }
}
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null }
async function browseDownloadsFolder() {
const picked = await invoke<string | null>('pick_downloads_folder')
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths() }
}
async function browseLocalSourceFolder() {
const picked = await invoke<string | null>('pick_downloads_folder')
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths() }
}
async function browseExtraScanDir() {
const picked = await invoke<string | null>('pick_downloads_folder')
if (picked) { newScanDir = picked; addExtraScanDir() }
}
function addExtraScanDir() {
const dir = newScanDir.trim()
if (!dir || extraScanDirs.includes(dir)) return
extraScanDirs = [...extraScanDirs, dir]
updateSettings({ extraScanDirs }); newScanDir = ''; fetchStorage()
}
function removeExtraScanDir(path: string) {
extraScanDirs = extraScanDirs.filter(d => d !== path)
updateSettings({ extraScanDirs }); fetchStorage()
}
function fmtBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
}
let backupLoading = $state(false)
let backupError = $state<string | null>(null)
let backupList = $state<(BackupEntry & { deleting?: boolean })[]>([])
async function loadBackupList() {
backupList = (await loadBackups()).map(b => ({ ...b }))
}
async function saveBackupList() {
await persistBackups(backupList.map(({ url, name }) => ({ url, name })))
}
async function createBackup() {
backupLoading = true; backupError = null
try {
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
const { url } = data.createBackup
const name = url.split('/').pop() ?? url
backupList = [{ url, name }, ...backupList]
await saveBackupList()
} catch (e: any) { backupError = e?.message ?? 'Failed to create backup' }
finally { backupLoading = false }
}
async function deleteBackup(url: string) {
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
try {
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
backupList = backupList.filter(b => b.url !== url)
await saveBackupList()
} catch (e: any) {
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b)
backupError = e?.message ?? 'Failed to delete backup'
}
}
async function downloadBackup(backup: BackupEntry) {
try {
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
const blob = await resp.blob()
if ('showSaveFilePicker' in window) {
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: backup.name,
types: [{ description: 'Backup file', accept: { 'application/octet-stream': ['.tachibk', '.proto.gz'] } }],
})
const writable = await handle.createWritable()
await writable.write(blob); await writable.close()
toast({ kind: 'success', message: 'Backup saved', detail: backup.name }); return
} catch (pickerErr: any) { if (pickerErr?.name === 'AbortError') return }
}
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl; a.download = backup.name
document.body.appendChild(a); a.click(); document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000)
toast({ kind: 'download', message: 'Backup downloaded', detail: backup.name })
} catch (e: any) { backupError = e?.message ?? 'Failed to download backup' }
}
let restoreLoading = $state(false)
let restoreError = $state<string | null>(null)
let restoreJobId = $state<string | null>(null)
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
let validateLoading = $state(false)
let validateError = $state<string | null>(null)
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
let restoreFile = $state<File | null>(null)
function stopRestorePoll() {
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null }
}
async function pollRestoreStatus(id: string) {
try {
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
{ id }
)
const status = data.restoreStatus
restoreStatus = status
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
} catch {}
}
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
const form = new FormData()
form.append('operations', JSON.stringify({ query, variables }))
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
form.append('0', file, file.name)
return form
}
function buildAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' }
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
return headers
}
function serverUrl(): string {
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
}
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch(`${serverUrl()}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
body: JSON.stringify({ query, variables }),
})
const json = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
return json.data as T
}
async function submitRestore() {
if (!restoreFile) return
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
stopRestorePoll()
try {
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
const json = await resp.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
const result = json.data.restoreBackup
restoreJobId = result.id; restoreStatus = result.status
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
finally { restoreLoading = false }
}
async function submitValidate() {
if (!restoreFile) return
validateLoading = true; validateError = null; validateResult = null
try {
const form = buildBackupFormData(
restoreFile,
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
{ backup: null }
)
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
const json = await resp.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
validateResult = json.data.validateBackup
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
finally { validateLoading = false }
}
let appDataExporting = $state(false)
let appDataImporting = $state(false)
let appDataError = $state<string | null>(null)
let appDataMsg = $state<string | null>(null)
let appDataBackupDir = $state<string | null>(null)
$effect(() => {
invoke<string>('get_auto_backup_dir').then(d => { appDataBackupDir = d }).catch(() => {})
})
async function handleExportAppData() {
appDataExporting = true; appDataError = null; appDataMsg = null
try {
await exportAppData()
appDataMsg = 'Backup saved.'
setTimeout(() => appDataMsg = null, 3000)
} catch (e: any) {
if (String(e).includes('Cancelled')) return
appDataError = e?.message ?? String(e)
} finally { appDataExporting = false }
}
async function handleImportAppData() {
appDataImporting = true; appDataError = null; appDataMsg = null
try {
await importAppData()
} catch (e: any) {
if (String(e).includes('Cancelled')) { appDataImporting = false; return }
appDataError = e?.message ?? String(e)
appDataImporting = false
}
}
$effect(() => { untrack(() => { loadBackupList(); fetchStorage() }) })
$effect(() => { return () => stopRestorePoll() })
</script>
<div class="s-panel">
{#if migrateFrom && !isExternalServer}
<div class="s-migrate-banner">
<div class="s-migrate-body">
<span class="s-migrate-title">Manga found at previous path — move to new location?</span>
<span class="s-migrate-paths">{migrateFrom}{migrateTo}</span>
{#if migrateProgress && migrateProgress.total > 0}
<div class="s-migrate-bar"><div class="s-migrate-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
<span class="s-migrate-paths">{migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total}</span>
{/if}
{#if migrateError}<span class="s-desc" style="color:var(--color-error)">{migrateError}</span>{/if}
</div>
<div class="s-migrate-actions">
<button class="s-btn s-btn-accent" onclick={startMigration} disabled={migrating}>
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : 'Starting…') : 'Move files'}
</button>
<button class="s-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
</div>
</div>
{/if}
<div class="s-section">
<p class="s-section-title">
Disk Usage
<button class="s-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? '…' : '↻'}</button>
</p>
<div class="s-section-body">
{#if storageLoading}
<p class="s-empty">Reading filesystem…</p>
{:else if storageError}
<p class="s-empty" style="color:var(--color-error)">{storageError}</p>
{:else if isExternalServer}
<p class="s-empty">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
{:else if multiStorageInfos.length > 0}
{#each multiStorageInfos as info}
{@const limitGb = settingsState.settings.storageLimitGb ?? null}
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
{@const available = info.manga_bytes + info.free_bytes}
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
<div class="s-storage-wrap">
<div class="s-storage-header">
<span class="s-storage-label">{info.label}</span>
<span class="s-storage-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
</div>
<div class="s-storage-bar">
<div class="s-storage-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
</div>
<div class="s-storage-footer">
<span>{info.path}</span>
<span>{fmtBytes(info.free_bytes)} free</span>
</div>
</div>
{/each}
{:else}
<p class="s-empty">No download path configured.</p>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Downloads Path</p>
<div class="s-section-body">
{#if isExternalServer}
<div class="s-row">
<span class="s-desc">Connected to an external server. The path below is read from the server — changes here will update the server's config directly.</span>
</div>
{/if}
<div class="s-row" style="gap:var(--sp-2)">
<input class="s-input full" class:error={!!pathsFieldError.dl}
bind:value={downloadsPathInput}
placeholder={isExternalServer ? 'Server default' : (defaultDownloadsPath || 'Default location')}
spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined } }} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
{/if}
</div>
<div class="s-row">
<div class="s-row-info">
{#if pathsFieldError.dl}
<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.dl}</span>
{/if}
{#if pathsError}
<span class="s-desc" style="color:var(--color-error)">{pathsError}</span>
{/if}
</div>
<div class="s-btn-row">
{#if pathsFieldError.dl && !isExternalServer}
<button class="s-btn" onclick={async () => {
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined } }
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? 'Failed' } }
}}>Create</button>
{/if}
{#if downloadsPathInput.trim() !== confirmedDownloadsPath}
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
{pathsSaved ? 'Saved ✓' : pathsSaving ? 'Saving…' : 'Save'}
</button>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Storage Limit</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Warn when limit is reached</span>
<span class="s-desc">{settingsState.settings.storageLimitGb === null ? 'No limit set' : `Warn above ${settingsState.settings.storageLimitGb} GB`}</span>
</div>
{#if settingsState.settings.storageLimitGb === null}
<button class="s-btn" onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
{:else}
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: Math.max(1, (settingsState.settings.storageLimitGb ?? 10) - 1) })} disabled={(settingsState.settings.storageLimitGb ?? 10) <= 1}></button>
<input type="number" min="1" step="1" class="s-slider-val" style="width:52px"
value={settingsState.settings.storageLimitGb}
oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }) }} />
<span class="s-slider-unit">GB</span>
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: (settingsState.settings.storageLimitGb ?? 10) + 1 })}>+</button>
<button class="s-btn-icon" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
</div>
{/if}
</div>
</div>
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => advStorageOpen = !advStorageOpen}>
<span class="s-label">Advanced</span>
<svg class="s-collapsible-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if advStorageOpen}
<div class="s-collapsible-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Local source path</span>
<span class="s-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
<div class="s-btn-row">
<input class="s-input mono" class:error={!!pathsFieldError.loc}
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined } }} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
{/if}
{#if pathsFieldError.loc && !isExternalServer}
<button class="s-btn" onclick={async () => {
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined } }
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? 'Failed' } }
}}>Create</button>
{/if}
</div>
{#if pathsFieldError.loc}<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
</div>
</div>
{#each extraScanDirs as dir}
<div class="s-row">
<div class="s-row-info">
<span class="s-label mono" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
<span class="s-desc">Extra scan directory</span>
</div>
<button class="s-btn s-btn-danger" onclick={() => removeExtraScanDir(dir)}>Remove</button>
</div>
{/each}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Additional scan path</span>
<span class="s-desc">Include an extra directory in disk usage readings</span>
</div>
<div class="s-btn-row">
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && addExtraScanDir()} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => backupSectionOpen = !backupSectionOpen}>
<span class="s-label">Backup</span>
<svg class="s-collapsible-caret" class:open={backupSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if backupSectionOpen}
<div class="s-collapsible-body">
<p class="s-subsection-title">Library backup</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Create backup</span>
<span class="s-desc">Snapshot your library, categories, and tracker links</span>
</div>
<button class="s-btn s-btn-accent" onclick={createBackup} disabled={backupLoading}>
{backupLoading ? 'Creating…' : 'Create backup'}
</button>
</div>
{#if backupError}
<div class="s-banner s-banner-error">{backupError}</div>
{/if}
{#if backupList.length === 0}
<p class="s-empty">No backups yet — create one above.</p>
{:else}
{#each backupList as backup}
<div class="s-folder-row">
<ClockCounterClockwise size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="s-folder-name" style="font-family:monospace;font-size:var(--text-xs)">{backup.name}</span>
<button class="s-btn-icon" onclick={() => downloadBackup(backup)} title="Download"></button>
<button class="s-btn-icon danger" onclick={() => deleteBackup(backup.url)} disabled={backup.deleting} title="Delete">
<Trash size={12} weight="light" />
</button>
</div>
{/each}
{/if}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Restore from file</span>
<span class="s-desc">{restoreFile ? restoreFile.name : 'Select a .tachibk file'}</span>
</div>
<label class="s-btn" style="cursor:pointer">
Browse
<input type="file" accept=".tachibk,.proto.gz" style="display:none"
onchange={(e) => {
const f = (e.currentTarget as HTMLInputElement).files?.[0] ?? null
restoreFile = f; restoreStatus = null; restoreError = null; validateResult = null; validateError = null
}} />
</label>
</div>
{#if restoreFile}
<div class="s-row">
<div class="s-row-info"></div>
<div class="s-btn-row">
<button class="s-btn" onclick={submitValidate} disabled={validateLoading || restoreLoading}>
{validateLoading ? 'Checking…' : 'Validate'}
</button>
<button class="s-btn s-btn-accent" onclick={submitRestore} disabled={restoreLoading || validateLoading}>
{restoreLoading ? 'Restoring…' : 'Restore'}
</button>
</div>
</div>
{/if}
{#if validateError}
<div class="s-banner s-banner-error">{validateError}</div>
{/if}
{#if validateResult}
{#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0}
<div class="s-row"><span class="s-desc" style="color:var(--color-success,#4caf50)">✓ All sources and trackers present</span></div>
{:else}
{#if validateResult.missingSources.length > 0}
<div class="s-row">
<div class="s-row-info">
<span class="s-label" style="color:var(--color-error)">Missing sources</span>
<span class="s-desc">{validateResult.missingSources.map(s => s.name).join(', ')}</span>
</div>
</div>
{/if}
{#if validateResult.missingTrackers.length > 0}
<div class="s-row">
<div class="s-row-info">
<span class="s-label" style="color:var(--color-error)">Missing trackers</span>
<span class="s-desc">{validateResult.missingTrackers.map(t => t.name).join(', ')}</span>
</div>
</div>
{/if}
{/if}
{/if}
{#if restoreError}
<div class="s-banner s-banner-error">{restoreError}</div>
{/if}
{#if restoreStatus}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">
{restoreStatus.state === 'SUCCESS' ? '✓ Restore complete' :
restoreStatus.state === 'FAILURE' ? '✗ Restore failed' : 'Restoring…'}
</span>
{#if restoreStatus.totalManga > 0}
<span class="s-desc">{restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga</span>
{/if}
</div>
{#if restoreStatus.state !== 'SUCCESS' && restoreStatus.state !== 'FAILURE' && restoreStatus.totalManga > 0}
<div class="s-storage-bar" style="width:160px;flex-shrink:0">
<div class="s-storage-fill" style="width:{Math.round((restoreStatus.mangaProgress / restoreStatus.totalManga) * 100)}%"></div>
</div>
{/if}
</div>
{/if}
<p class="s-subsection-title">App data backup</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Export settings</span>
<span class="s-desc">Save all Moku app settings to a .zip via a native save dialog.</span>
</div>
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
{appDataExporting ? 'Saving…' : 'Export'}
</button>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Import settings</span>
<span class="s-desc">Restore from a previously exported .zip file. Reloads the app immediately.</span>
</div>
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
{appDataImporting ? 'Importing…' : 'Import'}
</button>
</div>
{#if appDataError}
<div class="s-banner s-banner-error">{appDataError}</div>
{/if}
{#if appDataMsg}
<div class="s-row">
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
</div>
{/if}
{#if appDataBackupDir}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Auto-backup location</span>
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
</div>
<button class="s-btn" onclick={() => invoke('open_path', { path: appDataBackupDir })}>Open folder</button>
</div>
{/if}
</div>
{/if}
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => resetSectionOpen = !resetSectionOpen}>
<span class="s-label">Reset</span>
<svg class="s-collapsible-caret" class:open={resetSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if resetSectionOpen}
<div class="s-collapsible-body">
{#each resetItems as item}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">{item.label}</span>
<span class="s-desc">{item.desc}</span>
{#if item.error}<span class="s-desc" style="color:var(--color-error)">{item.error}</span>{/if}
</div>
<div class="s-btn-row">
{#if item.state === 'done'}
<span class="s-pill on">Done</span>
{:else if item.state === 'busy'}
<button class="s-btn" disabled>Working…</button>
{:else if confirming === item.key}
<span class="s-desc" style="color:var(--text-muted)">Sure?</span>
<button class="s-btn s-btn-danger" onclick={() => runReset(item.key)}>Confirm</button>
<button class="s-btn" onclick={() => confirming = null}>Cancel</button>
{:else}
<button
class="s-btn"
class:s-btn-danger={item.confirm}
onclick={() => item.confirm ? (confirming = item.key) : runReset(item.key)}
>Reset</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,274 @@
<script lang="ts">
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { toast } from "$lib/state/notifications.svelte";
import { getAdapter } from "$lib/request-manager";
import { syncBackFromTracker } from "$lib/state/tracking.svelte";
import type { Tracker, TrackRecord } from "$lib/types/index";
let trackers = $state<Tracker[]>([]);
let trackersLoading = $state(false);
let trackersError = $state<string | null>(null);
let oauthTrackerId = $state<number | null>(null);
let oauthCallbackInput = $state("");
let oauthSubmitting = $state(false);
let oauthError = $state<string | null>(null);
let credsTrackerId = $state<number | null>(null);
let credsUsername = $state("");
let credsPassword = $state("");
let credsSubmitting = $state(false);
let credsError = $state<string | null>(null);
let loggingOut = $state<number | null>(null);
let syncing = $state(false);
$effect(() => {
if (trackers.length === 0 && !trackersLoading) loadTrackers();
});
async function loadTrackers() {
trackersLoading = true; trackersError = null;
try {
trackers = await getAdapter().getTrackers();
} catch (e: any) {
trackersError = e?.message ?? "Failed to load trackers";
} finally { trackersLoading = false; }
}
async function startOAuth(tracker: Tracker) {
if (!tracker.authUrl) return;
oauthTrackerId = tracker.id; oauthCallbackInput = "";
window.open(tracker.authUrl, "_blank");
}
async function submitOAuth() {
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
oauthSubmitting = true;
try {
await getAdapter().loginTrackerOAuth(oauthTrackerId, oauthCallbackInput.trim());
await loadTrackers();
oauthTrackerId = null; oauthCallbackInput = "";
} catch (e: any) {
oauthError = e?.message ?? "Login failed";
} finally { oauthSubmitting = false; }
}
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; oauthError = null; }
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
async function submitCredentials() {
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
credsSubmitting = true;
try {
await getAdapter().loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword.trim());
await loadTrackers();
credsTrackerId = null; credsUsername = ""; credsPassword = "";
} catch (e: any) {
credsError = e?.message ?? "Login failed";
} finally { credsSubmitting = false; }
}
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; credsError = null; }
async function logoutTracker(trackerId: number) {
loggingOut = trackerId;
try {
await getAdapter().logoutTracker(trackerId);
await loadTrackers();
} catch (e: any) {
trackersError = e?.message ?? "Logout failed";
} finally { loggingOut = null; }
}
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
async function runSyncAll() {
syncing = true;
try {
const adapter = getAdapter();
const allTrackers = await adapter.getTrackersWithRecords();
const loggedIn = allTrackers.filter((t: any) => t.isLoggedIn);
const settings = settingsState.settings;
let totalMarked = 0;
for (const tracker of loggedIn) {
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
if (!record.manga?.id) continue;
const mangaId = record.manga.id;
const chapters = await adapter.getChapters(mangaId);
const prefs = settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker(
[record],
chapters,
{
threshold: settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
adapter.markChaptersRead.bind(adapter),
);
totalMarked += marked.length;
}
}
toast({ kind: "success", message: "Sync complete", detail: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
} catch (e: any) {
toast({ kind: "error", message: "Sync failed", detail: e?.message });
} finally { syncing = false; }
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Connected Trackers</p>
<div class="s-section-body">
{#if trackersError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}</div>
{/if}
{#if trackersLoading}
<p class="s-empty">Loading trackers…</p>
{:else}
{#each trackers as tracker}
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
<div class="s-tracker-identity">
<img src={tracker.icon} alt={tracker.name} class="s-tracker-logo" />
<div class="s-row-info">
<span class="s-label">{tracker.name}</span>
<div class="s-tracker-status-row">
<span class="s-pill" class:on={tracker.isLoggedIn && !tracker.isTokenExpired}>
{tracker.isLoggedIn ? "Connected" : "Not connected"}
</span>
{#if tracker.isLoggedIn && tracker.isTokenExpired}
<span class="s-pill s-pill-warn">Token expired — reconnect</span>
{/if}
</div>
</div>
</div>
<div class="s-tracker-action">
{#if tracker.isLoggedIn && tracker.isTokenExpired}
<button class="s-btn s-btn-accent" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
Reconnect
</button>
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button>
{:else if tracker.isLoggedIn}
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button>
{:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id}
<button class="s-btn" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
{tracker.authUrl ? "Connect via browser →" : "Connect"}
</button>
{/if}
</div>
{#if oauthTrackerId === tracker.id}
<div class="s-tracker-expand">
{#if oauthError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}</div>
{/if}
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
bind:value={oauthCallbackInput}
onkeydown={(e) => { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }}
use:focusEl />
<div class="s-oauth-btns">
<button class="s-btn s-btn-accent" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
{oauthSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="s-btn" onclick={cancelOAuth}>Cancel</button>
</div>
</div>
{/if}
{#if credsTrackerId === tracker.id}
<div class="s-tracker-expand">
{#if credsError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}</div>
{/if}
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} />
<div class="s-oauth-btns">
<button class="s-btn s-btn-accent" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
{credsSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="s-btn" onclick={cancelCredentials}>Cancel</button>
</div>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Sync back from tracker</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Enable sync back</span>
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
</div>
<button class="s-toggle" class:on={settings.trackerSyncBack}
onclick={() => updateSettings({ trackerSyncBack: !settings.trackerSyncBack })}
role="switch" aria-checked={settings.trackerSyncBack} aria-label="Enable sync back">
<span class="s-toggle-thumb"></span>
</button>
</div>
{#if settings.trackerSyncBack}
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Chapter number tolerance</span>
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
</div>
<button role="switch" aria-checked={settings.trackerSyncBackThreshold !== null} aria-label="Chapter number tolerance" class="s-toggle" class:on={settings.trackerSyncBackThreshold !== null}
onclick={() => updateSettings({ trackerSyncBackThreshold: settings.trackerSyncBackThreshold !== null ? null : 20 })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
{#if settings.trackerSyncBackThreshold !== null}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (120)</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.max(1, (settings.trackerSyncBackThreshold ?? 20) - 1) })}></button>
<span class="s-step-val">{settings.trackerSyncBackThreshold}</span>
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.min(20, (settings.trackerSyncBackThreshold ?? 20) + 1) })}>+</button>
</div>
</div>
{/if}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Respect scanlator filter</span>
<span class="s-desc">Only mark chapters matching the series' active scanlator filter</span>
</div>
<button class="s-toggle" class:on={settings.trackerRespectScanlatorFilter}
onclick={() => updateSettings({ trackerRespectScanlatorFilter: !settings.trackerRespectScanlatorFilter })}
role="switch" aria-checked={settings.trackerRespectScanlatorFilter} aria-label="Respect scanlator filter">
<span class="s-toggle-thumb"></span>
</button>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Sync now</span>
<span class="s-desc">Apply tracker progress to all linked manga in your library</span>
</div>
<button class="s-btn" onclick={runSyncAll} disabled={syncing}>
{syncing ? "Syncing…" : "Sync all"}
</button>
</div>
{/if}
</div>
</div>
<style>
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
.s-banner-dismissible:hover { opacity: 0.85; }
</style>
@@ -0,0 +1,839 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
} from "phosphor-svelte";
import Thumbnail from "$lib/components/manga/Thumbnail.svelte";
import { appState } from "$lib/state/app.svelte";
import { settings } from "$lib/state/settings.svelte";
import { requestManager } from "$lib/request-manager/index";
import { queryCache } from "$lib/core/cache/queryCache";
import { resolvedCover } from "$lib/core/cover/coverResolver";
import { autoLinkLibrary } from "$lib/core/cover/autoLink";
import { toast } from "$lib/state/notifications.svelte";
import { addBookmark } from "$lib/state/app.svelte";
import CoverPickerPanel from "$lib/components/series/CoverPickerPanel.svelte";
import SeriesLinkPanel from "$lib/components/series/SeriesLinkPanel.svelte";
import type { Manga, Chapter, Category } from "$lib/types/index";
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let allCategories: Category[] = $state([]);
let mangaCategories: Category[] = $state([]);
let catsLoading = $state(false);
let queueingAll = $state(false);
let fetchError: string | null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
let linkPickerOpen = $state(false);
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
let coverPickerOpen = $state(false);
let originNavPage = appState.navPage;
const linkedIds = $derived(
appState.previewManga ? (settings.mangaLinks?.[appState.previewManga.id] ?? []) : [],
);
const hasCoverOverride = $derived(
!!settings.mangaPrefs?.[appState.previewManga?.id ?? -1]?.coverUrl
);
const displayManga = $derived(manga ?? appState.previewManga);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.isRead).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? appState.previewManga?.inLibrary ?? false);
const scanlators = $derived(
[...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))],
);
const uploadDates = $derived(
chapters
.map((c) => (c.uploadDate ? new Date(c.uploadDate).getTime() : null))
.filter((d): d is number => d !== null && !isNaN(d)),
);
const statusLabel = $derived(
displayManga?.status
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
: null,
);
const assignedFolders = $derived(mangaCategories.filter((c) => c.id !== 0));
const continueChapter = $derived.by(() => {
if (!chapters.length) return null;
const asc = [...chapters];
const anyRead = asc.some((c) => c.isRead);
const bookmark = displayManga
? appState.bookmarks.find((b) => b.mangaId === displayManga!.id)
: null;
if (bookmark) {
const ch = asc.find((c) => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every((c) => c.isRead);
if (!(isLastChapter && allRead))
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
}
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { ch: asc[0], type: "reread" as const, resumePage: null };
});
const continueLabel = $derived.by(() => {
if (!continueChapter) return "";
const { ch, type, resumePage } = continueChapter;
if (type === "reread") return "Read again";
if (type === "start") return `Start · Ch.${ch.chapterNumber}`;
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
});
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort();
chapterAbort?.abort();
appState.previewManga = null;
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
async function openLinkPicker() {
linkPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
requestManager.getAllManga()
.then((d) => { allMangaForLink = d; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
async function openCoverPicker() {
coverPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
requestManager.getAllManga()
.then((d) => { allMangaForLink = d; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
$effect(() => {
const focal = appState.previewManga;
if (focal) {
originNavPage = appState.navPage;
load(focal.id);
loadCategories(focal.id);
if (settings.autoLinkOnOpen) {
if (allMangaForLink.length) {
autoLinkLibrary(focal, allMangaForLink)
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
} else {
loadingLinkList = true;
requestManager.getAllManga()
.then((nodes) => {
allMangaForLink = nodes;
return autoLinkLibrary(focal, nodes);
})
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
}
}
});
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
manga = appState.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
requestManager.fetchManga(id, dCtrl.signal)
.then((fullManga) => {
if (dCtrl.signal.aborted) return;
manga = fullManga; loadingDetail = false;
})
.catch((e) => {
if (e?.name === "AbortError") return;
manga = appState.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
requestManager.getChapters(id, cCtrl.signal)
.then(async (nodes) => {
if (cCtrl.signal.aborted) return;
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (sorted.length === 0) {
try {
const fetched = await requestManager.fetchChapters(id, cCtrl.signal);
if (!cCtrl.signal.aborted)
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}
if (!cCtrl.signal.aborted) {
chapters = sorted;
if (sorted.length > 0) checkAndMarkCompleted(id, sorted);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
}
async function toggleLibrary() {
if (!manga) return;
togglingLib = true;
const next = !manga.inLibrary;
await requestManager.updateManga(manga.id, { inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
queryCache.clear(`manga:${manga.id}`);
queryCache.clear("library");
togglingLib = false;
toast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
}
async function downloadAll() {
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
if (!ids.length) return;
queueingAll = true;
await requestManager.enqueueChaptersDownload(ids).catch(console.error);
toast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
queueingAll = false;
}
function openSeriesDetail() {
if (!displayManga) return;
appState.activeManga = displayManga;
appState.navPage = originNavPage;
close();
}
function loadCategories(mangaId: number) {
catsLoading = true;
requestManager.getCategories()
.then((cats) => {
allCategories = cats.filter((c: Category) => c.id !== 0);
mangaCategories = allCategories.filter((c: Category) => c.mangas?.nodes.some((m: Manga) => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
const mangaStatus = (manga ?? displayManga)?.status;
const isOngoing = mangaStatus === "ONGOING";
if (chaps.length && !isOngoing) {
const allRead = chaps.every((c) => c.isRead);
const completed = allCategories.find((c) => c.name === "Completed");
if (completed) {
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
}
}
}
async function toggleCategory(cat: Category) {
if (!appState.previewManga) return;
const mangaId = appState.previewManga.id;
const inCat = mangaCategories.some((c) => c.id === cat.id);
await requestManager.updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
if (!inCat && !inLibrary) {
await requestManager.updateManga(mangaId, { inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
queryCache.clear("library");
}
mangaCategories = inCat
? mangaCategories.filter((c) => c.id !== cat.id)
: [...mangaCategories, cat];
}
async function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !appState.previewManga) return;
try {
const cat = await requestManager.createCategory(name);
allCategories = [...allCategories, cat];
await requestManager.updateMangaCategories(appState.previewManga.id, [cat.id], []);
if (!inLibrary) {
await requestManager.updateManga(appState.previewManga.id, { inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
queryCache.clear("library");
}
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
newFolderName = ""; creatingFolder = false;
}
function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) {
folderOpen = false; creatingFolder = false; newFolderName = "";
}
}
$effect(() => {
if (folderOpen) {
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => document.removeEventListener("mousedown", handleFolderOutside);
}
});
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => {
window.removeEventListener("keydown", onKey);
detailAbort?.abort();
chapterAbort?.abort();
});
function focusAction(node: HTMLElement) { node.focus(); }
</script>
{#if appState.previewManga}
<div
class="backdrop"
role="button"
tabindex="-1"
aria-label="Close preview"
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
onkeydown={(e) => { if (e.key === "Escape") close(); }}
>
<div class="modal" role="dialog" aria-label="Manga preview">
<div class="cover-col">
<div class="cover-wrap">
<Thumbnail src={resolvedCover(appState.previewManga.id, appState.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
{#if loadingDetail}
<div class="cover-spinner">
<CircleNotch size={18} weight="light" class="anim-spin" />
</div>
{/if}
</div>
<div class="cover-actions">
<button
class="action-btn"
class:active={inLibrary}
onclick={toggleLibrary}
disabled={togglingLib || loadingDetail}
>
<span class="action-icon">
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
</span>
<span class="action-label">
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
</span>
</button>
<button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span>
</button>
<div class="folder-wrap" bind:this={folderRef}>
<button
class="action-btn"
class:active={assignedFolders.length > 0}
onclick={() => folderOpen = !folderOpen}
>
<span class="action-icon">
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
</span>
<span class="action-label">
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
</span>
</button>
{#if folderOpen}
<div class="folder-menu">
{#if catsLoading}
<p class="folder-empty">Loading…</p>
{:else if allCategories.length === 0 && !creatingFolder}
<p class="folder-empty">No folders yet</p>
{/if}
{#each allCategories as cat}
{@const isIn = mangaCategories.some((c) => c.id === cat.id)}
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
<Folder size={12} weight={isIn ? "fill" : "light"} />
{isIn ? "✓ " : ""}{cat.name}
</button>
{/each}
<div class="folder-divider"></div>
{#if creatingFolder}
<div class="folder-create-row">
<input
class="folder-input"
placeholder="Folder name…"
bind:value={newFolderName}
onkeydown={(e) => {
if (e.key === "Enter") handleFolderCreate();
if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; }
}}
use:focusAction
/>
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>
Add
</button>
</div>
{:else}
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<button
class="action-btn"
class:active={linkedIds.length > 0}
onclick={openLinkPicker}
>
<span class="action-icon">
<LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} />
</span>
<span class="action-label">
{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}
</span>
</button>
<button
class="action-btn"
class:active={hasCoverOverride}
onclick={openCoverPicker}
>
<span class="action-icon">
<Image size={13} weight={hasCoverOverride ? "fill" : "light"} />
</span>
<span class="action-label">Cover Image</span>
</button>
</div>
</div>
<div class="content">
<div class="content-header">
<div class="title-block">
<h2 class="title">{displayManga?.title}</h2>
{#if loadingDetail}
<div class="sk-byline"></div>
{:else if displayManga?.author || displayManga?.artist}
<p class="byline">
{[displayManga?.author, displayManga?.artist]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join(" · ")}
</p>
{/if}
</div>
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body">
{#if fetchError}
<div class="error-banner">{fetchError}</div>
{/if}
{#if loadingDetail}
<div class="sk-row">
<div class="sk-badge"></div>
<div class="sk-badge" style="width:72px"></div>
</div>
{:else}
<div class="badges">
{#if statusLabel}
<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>
{/if}
{#if displayManga?.source}
<span class="badge">{displayManga.source.displayName}</span>
{/if}
{#if inLibrary}
<span class="badge badge-accent">In Library</span>
{/if}
{#if !loadingChapters && unreadCount > 0}
<span class="badge badge-unread">{unreadCount} unread</span>
{/if}
{#if !loadingChapters && bookmarkCount > 0}
<span class="badge">{bookmarkCount} bookmarked</span>
{/if}
</div>
{/if}
<div class="chapter-box">
{#if loadingChapters}
<div class="chapter-loading">
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="chapter-loading-label">Loading chapters…</span>
</div>
{:else if totalCount > 0}
<div class="chapter-meta">
<span class="chapter-label">
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
{readCount > 0 ? ` · ${readCount} read` : ""}
{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}
{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
</span>
{#if unreadCount > 0}
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
{/if}
</div>
{#if readCount > 0}
<div class="progress-track">
<div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div>
</div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => {
const { ch, type, resumePage } = continueChapter!;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = appState.bookmarks.find((b) => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga!.id,
mangaTitle: displayManga!.title,
thumbnailUrl: displayManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
appState.openReader(ch, chapters, displayManga);
close();
}}>
<Play size={12} weight="fill" />{continueLabel}
</button>
{/if}
{:else if !loadingDetail}
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
{/if}
</div>
{#if loadingDetail}
<div class="sk-desc">
<div class="sk-line" style="width:100%"></div>
<div class="sk-line" style="width:88%"></div>
<div class="sk-line" style="width:70%"></div>
</div>
{:else if displayManga?.description}
<div class="desc-block">
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
{#if displayManga.description.length > 220}
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown
size={10}
weight="light"
style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease"
/>
</button>
{/if}
</div>
{/if}
{#if !loadingDetail && displayManga?.genre?.length}
<div class="genres">
{#each displayManga.genre as g}
<button
class="genre-tag"
onclick={() => { appState.genreFilter = g; appState.navPage = "search"; close(); }}
>{g}</button>
{/each}
</div>
{/if}
{#if !loadingDetail}
<div class="meta-table">
<div class="meta-grid">
<div class="meta-col">
<div class="meta-row">
<span class="meta-key">Status</span>
<span class="meta-val">{statusLabel ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Source</span>
<span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Link</span>
{#if displayManga?.realUrl}
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">
Open <ArrowSquareOut size={11} weight="light" />
</a>
{:else}
<span class="meta-val">N/A</span>
{/if}
</div>
</div>
<div class="meta-col">
<div class="meta-row">
<span class="meta-key">Author</span>
<span class="meta-val">{displayManga?.author ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Artist</span>
<span class="meta-val">
{displayManga?.artist && displayManga.artist !== displayManga.author
? displayManga.artist
: (displayManga?.author ?? "N/A")}
</span>
</div>
<div class="meta-row">
<span class="meta-key">Scanlator</span>
<span class="meta-val">
{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}
</span>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#if linkPickerOpen && appState.previewManga}
<SeriesLinkPanel
manga={displayManga ?? appState.previewManga}
allManga={allMangaForLink}
onClose={() => linkPickerOpen = false}
/>
{/if}
{#if coverPickerOpen && appState.previewManga}
<CoverPickerPanel
manga={displayManga ?? appState.previewManga}
allManga={allMangaForLink}
onClose={() => coverPickerOpen = false}
/>
{/if}
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(800px, calc(100vw - 48px));
height: min(560px, calc(100vh - 80px));
display: flex; flex-direction: row;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.16s ease both;
}
.cover-col {
width: 190px; flex-shrink: 0;
background: var(--bg-raised);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
padding: var(--sp-5) var(--sp-4) var(--sp-4);
gap: var(--sp-3); overflow: hidden;
}
.cover-wrap { position: relative; width: 100%; }
:global(.cover) {
width: 100%; aspect-ratio: 2/3;
object-fit: cover; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); display: block;
}
.cover-spinner {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.35); border-radius: var(--radius-md);
color: var(--text-faint);
}
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px var(--sp-3);
border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
cursor: pointer; text-align: left;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.folder-wrap { position: relative; width: 100%; }
.folder-menu {
position: absolute; bottom: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md);
padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10;
animation: scaleIn 0.1s ease both; transform-origin: bottom center;
}
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
.folder-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folder-item.folder-item-on { color: var(--accent-fg); }
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
.folder-input {
flex: 1; min-width: 0;
background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 4px 8px; color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); outline: none;
}
.folder-input:focus { border-color: var(--border-focus); }
.folder-ok {
font-family: var(--font-ui); font-size: var(--text-xs);
padding: 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
}
.folder-ok:disabled { opacity: 0.4; cursor: default; }
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folder-new {
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
background: none; border: none; cursor: pointer; text-align: left; width: 100%;
transition: color var(--t-fast);
}
.folder-new:hover { color: var(--accent-fg); }
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.content-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; flex-shrink: 0;
border-radius: var(--radius-sm); color: var(--text-faint);
background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body {
flex: 1; min-height: 0; overflow-y: auto;
padding: var(--sp-5) var(--sp-6);
display: flex; flex-direction: column; gap: var(--sp-4);
scrollbar-width: none;
}
.content-body::-webkit-scrollbar { display: none; }
.error-banner {
font-family: var(--font-ui); font-size: var(--text-xs);
color: #f59e0b; background: rgba(245,158,11,0.1);
border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm);
padding: 6px var(--sp-3);
}
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.badge {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
}
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.dl-all-btn {
display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
}
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.read-btn {
display: flex; align-items: center; gap: var(--sp-2); align-self: flex-start;
padding: 8px var(--sp-4); border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
cursor: pointer; transition: filter var(--t-base);
}
.read-btn:hover { filter: brightness(1.1); }
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.desc-toggle {
display: flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base);
}
.desc-toggle:hover { color: var(--accent-fg); }
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre-tag {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
.meta-col { display: flex; flex-direction: column; }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; }
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.8 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,195 @@
<script lang="ts">
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import Thumbnail from "$lib/components/manga/Thumbnail.svelte";
import { appState } from "$lib/state/app.svelte";
import { requestManager } from "$lib/request-manager/index";
import { toast } from "$lib/state/notifications.svelte";
import type { Manga, Category } from "$lib/types/index";
import ContextMenu, { type MenuEntry } from "$lib/components/common/ContextMenu.svelte";
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
let mangas: Manga[] = $state([]);
let loading = $state(true);
let page = $state(1);
let hasNextPage = $state(false);
let browseType: BrowseType = $state("POPULAR");
let search = $state("");
let searchInput = $state("");
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
async function fetchMangas(type: BrowseType, p: number, q: string) {
if (!appState.activeSource) return;
loading = true; mangas = [];
requestManager.fetchSourceManga(appState.activeSource.id, type, p, q || null)
.then((d) => { mangas = d.mangas; hasNextPage = d.hasNextPage; })
.catch(console.error)
.finally(() => loading = false);
}
$effect(() => { if (appState.activeSource) fetchMangas(browseType, page, search); });
function submitSearch() { search = searchInput.trim(); browseType = "SEARCH"; page = 1; }
function setMode(mode: BrowseType) {
if (mode === browseType) return;
browseType = mode; search = ""; searchInput = ""; page = 1;
}
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
requestManager.getCategories()
.then(d => { categories = d.filter((c: Category) => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
disabled: m.inLibrary,
onClick: () => requestManager.updateManga(m.id, { inLibrary: true })
.then(() => {
mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
toast({ kind: "success", title: "Added to library", body: m.title });
})
.catch((e) => {
toast({ kind: "error", title: "Failed to add to library", body: m.title });
console.error(e);
}),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some((x: Manga) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
onClick: () => requestManager.updateMangaCategories(m.id, [cat.id], []).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const cat = await requestManager.createCategory(name.trim()).catch(console.error);
if (cat) {
categories = [...categories, cat];
await requestManager.updateMangaCategories(m.id, [cat.id], []).catch(console.error);
}
},
},
];
}
</script>
{#if appState.activeSource}
<div class="root">
<div class="header">
<button class="back" onclick={() => appState.activeSource = null}>
<ArrowLeft size={13} weight="light" /><span>Sources</span>
</button>
<span class="source-name">{appState.activeSource.displayName}</span>
</div>
<div class="toolbar">
<div class="tabs">
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
{mode.charAt(0) + mode.slice(1).toLowerCase()}
</button>
{/each}
{#if search}<button class="tab active">Search</button>{/if}
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search source…" bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
</div>
</div>
{#if loading}
<div class="loading-grid">
{#each Array(18) as _}
<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>
{/each}
</div>
{:else if mangas.length === 0}
<div class="empty">No results.</div>
{:else}
<div class="grid">
{#each mangas as m (m.id)}
<button class="card" onclick={() => { appState.activeManga = m; appState.navPage = "library"; }}
oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{/if}
{#if !loading && (page > 1 || hasNextPage)}
<div class="pagination">
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<Prev size={13} weight="light" /> Prev
</button>
<span class="page-num">{page}</span>
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
Next <Next size={13} weight="light" />
</button>
</div>
{/if}
</div>
{/if}
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); flex-shrink: 0; }
.back:hover { color: var(--text-secondary); }
.source-name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.tabs { display: flex; gap: 2px; }
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 200px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 12px; background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.page-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-raised); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,130 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
}
let { children, class: cls = "" }: Props = $props();
</script>
<div class="hover-3d {cls}">
<div class="hover-3d-content">
{@render children()}
<div class="shine"></div>
<div class="edge-top"></div>
<div class="edge-left"></div>
</div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<style>
.hover-3d {
display: inline-grid;
perspective: 600px;
--tx: 0;
--ty: 0;
--shine-x: 50%;
--shine-y: 50%;
--shadow-x: 0px;
--shadow-y: 0px;
--ease-out: linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
--ease-hover: linear(0, 0.708 15.2%, 0.927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);
}
.hover-3d > :nth-child(n + 2) {
isolation: isolate;
z-index: 1;
scale: 1.2;
}
.hover-3d > :nth-child(2) { grid-area: 1/1/2/2; }
.hover-3d > :nth-child(3) { grid-area: 1/2/2/3; }
.hover-3d > :nth-child(4) { grid-area: 1/3/2/4; }
.hover-3d > :nth-child(5) { grid-area: 2/1/3/2; }
.hover-3d > :nth-child(6) { grid-area: 2/3/3/4; }
.hover-3d > :nth-child(7) { grid-area: 3/1/4/2; }
.hover-3d > :nth-child(8) { grid-area: 3/2/4/3; }
.hover-3d > :nth-child(9) { grid-area: 3/3/4/4; }
.hover-3d-content {
grid-area: 1/1/4/4;
overflow: hidden;
border-radius: inherit;
position: relative;
transform: rotate3d(var(--tx), var(--ty), 0, 12deg);
transform-style: preserve-3d;
transition:
transform var(--ease-out) 500ms,
scale var(--ease-out) 500ms,
box-shadow ease-out 450ms;
box-shadow:
calc(var(--shadow-x) * 0.6) calc(var(--shadow-y) * 0.6) 8px rgba(0,0,0,0.18),
calc(var(--shadow-x) * 1.0) calc(var(--shadow-y) * 1.0) 18px rgba(0,0,0,0.14),
calc(var(--shadow-x) * 1.6) calc(var(--shadow-y) * 1.6) 36px rgba(0,0,0,0.10),
0 2px 6px rgba(0,0,0,0.12);
}
.hover-3d:hover > .hover-3d-content {
--ease-out: var(--ease-hover);
scale: 1.055;
}
.shine {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 2;
opacity: 0;
border-radius: inherit;
background-image: radial-gradient(
ellipse 80% 60% at var(--shine-x) var(--shine-y),
rgba(255,255,255,0.22) 0%,
rgba(255,255,255,0.08) 30%,
transparent 65%
);
transition: opacity ease-out 350ms;
mix-blend-mode: screen;
}
.hover-3d:hover .shine { opacity: 1; }
.edge-top {
pointer-events: none;
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
z-index: 3;
background: linear-gradient(90deg, transparent 10%, rgba(255,255,255,0.18) 50%, transparent 90%);
opacity: 0;
transition: opacity ease-out 350ms;
}
.edge-left {
pointer-events: none;
position: absolute;
top: 0; left: 0; bottom: 0;
width: 1px;
z-index: 3;
background: linear-gradient(180deg, transparent 10%, rgba(255,255,255,0.12) 50%, transparent 90%);
opacity: 0;
transition: opacity ease-out 350ms;
}
.hover-3d:hover .edge-top,
.hover-3d:hover .edge-left { opacity: 1; }
.hover-3d:has(> :nth-child(2):hover) { --tx: -1; --ty: 1; --shine-x: 15%; --shine-y: 15%; --shadow-x: -8px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(3):hover) { --tx: -1; --ty: 0; --shine-x: 50%; --shine-y: 10%; --shadow-x: 0px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(4):hover) { --tx: -1; --ty: -1; --shine-x: 85%; --shine-y: 15%; --shadow-x: 8px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(5):hover) { --tx: 0; --ty: 1; --shine-x: 10%; --shine-y: 50%; --shadow-x: -8px; --shadow-y: 0px; }
.hover-3d:has(> :nth-child(6):hover) { --tx: 0; --ty: -1; --shine-x: 90%; --shine-y: 50%; --shadow-x: 8px; --shadow-y: 0px; }
.hover-3d:has(> :nth-child(7):hover) { --tx: 1; --ty: 1; --shine-x: 15%; --shine-y: 85%; --shadow-x: -8px; --shadow-y: 8px; }
.hover-3d:has(> :nth-child(8):hover) { --tx: 1; --ty: 0; --shine-x: 50%; --shine-y: 90%; --shadow-x: 0px; --shadow-y: 8px; }
.hover-3d:has(> :nth-child(9):hover) { --tx: 1; --ty: -1; --shine-x: 85%; --shine-y: 85%; --shadow-x: 8px; --shadow-y: 8px; }
</style>
@@ -0,0 +1,52 @@
<script lang="ts">
import { settings } from "$lib/state/settings.svelte";
import { getBlobUrl } from "$lib/core/cache/imageCache";
import { platformService } from "$lib/platform-service/index";
let {
src,
alt = "",
class: cls = "",
loading = "lazy",
decoding = "async",
priority = 0,
onerror = undefined,
...rest
}: {
src: string;
alt?: string;
class?: string;
loading?: string;
decoding?: string;
priority?: number;
onerror?: ((e: Event) => void) | undefined;
[key: string]: any;
} = $props();
const isAuth = $derived((settings.serverAuthMode ?? "NONE") !== "NONE");
let blobUrl = $state("");
let reqId = 0;
$effect(() => {
const _src = src;
const _priority = priority;
const _isAuth = isAuth;
if (!_isAuth || !_src) { blobUrl = ""; return; }
const id = ++reqId;
const bareUrl = _src.startsWith("http") ? _src : `${platformService.getServerUrl()}${_src}`;
getBlobUrl(bareUrl, _priority)
.then(u => { if (id === reqId) blobUrl = u; })
.catch(() => { if (id === reqId) blobUrl = ""; });
});
const resolved = $derived(
isAuth
? (blobUrl || undefined)
: (src ? platformService.plainThumbUrl(src) : undefined)
);
</script>
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
+2 -2
View File
@@ -1,5 +1,5 @@
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));
return item => predicates.every(p => p(item))
}
+9 -15
View File
@@ -1,11 +1,11 @@
export interface PaginationState {
visible: number;
visible: number
}
export interface PaginationResult<T> {
items: T[];
hasMore: boolean;
remaining: number;
items: T[]
hasMore: boolean
remaining: number
}
export function createPaginator<T>(pageSize: number) {
@@ -15,15 +15,9 @@ export function createPaginator<T>(pageSize: number) {
items: all.slice(0, visible),
hasMore: all.length > visible,
remaining: Math.max(0, all.length - visible),
};
}
},
nextVisible(current: number): number {
return current + pageSize;
},
reset(): number {
return pageSize;
},
};
}
nextVisible(current: number): number { return current + pageSize },
reset(): number { return pageSize },
}
}
+16 -19
View File
@@ -1,29 +1,26 @@
export interface AsyncQueue<T> {
enqueue(item: T): void;
drain(): void;
clear(): void;
size(): number;
enqueue(item: T): void
drain(): void
clear(): void
size(): number
}
export function createAsyncQueue<T>(
worker: (item: T) => Promise<void>,
concurrency = 1,
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
export function createAsyncQueue<T>(worker: (item: T) => Promise<void>, concurrency = 1): AsyncQueue<T> {
const queue: T[] = []
let active = 0
function next() {
while (active < concurrency && queue.length > 0) {
const item = queue.shift()!;
active++;
worker(item).finally(() => { active--; next(); });
const item = queue.shift()!
active++
worker(item).finally(() => { active--; next() })
}
}
return {
enqueue(item) { queue.push(item); next(); },
drain() { next(); },
clear() { queue.length = 0; },
size() { return queue.length; },
};
}
enqueue(item) { queue.push(item); next() },
drain() { next() },
clear() { queue.length = 0 },
size() { return queue.length },
}
}
+15 -24
View File
@@ -1,33 +1,24 @@
export interface SearchResult<T> {
item: T;
score: number;
item: T
score: number
}
export function searchItems<T>(
items: T[],
query: string,
getField: (item: T) => string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
export function searchItems<T>(items: T[], query: string, getField: (item: T) => string): T[] {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter(item => getField(item).toLowerCase().includes(q))
}
export function searchWithScore<T>(
items: T[],
query: string,
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
export function searchWithScore<T>(items: T[], query: string, getField: (item: T) => string): SearchResult<T>[] {
const q = query.trim().toLowerCase()
if (!q) return items.map(item => ({ item, score: 0 }))
return items
.map(item => {
const field = getField(item).toLowerCase();
if (!field.includes(q)) return null;
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
return { item, score };
const field = getField(item).toLowerCase()
if (!field.includes(q)) return null
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0
return { item, score }
})
.filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score);
}
.sort((a, b) => b.score - a.score)
}
+16 -17
View File
@@ -1,32 +1,31 @@
export type SortDir = "asc" | "desc";
export type SortDir = 'asc' | 'desc'
export interface SortField<T> {
key: string;
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
key: string
comparator: (a: T, b: T, context?: Record<string, unknown>) => number
}
export interface SortConfig<T> {
fields: SortField<T>[];
defaultField: string;
defaultDir: SortDir;
fields: SortField<T>[]
defaultField: string
defaultDir: SortDir
}
export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[]
}
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
const fieldMap = new Map(config.fields.map(f => [f.key, f]))
return {
sort(items, field, dir, context) {
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
if (!f) return [...items];
const d = dir ?? config.defaultDir;
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField)
if (!f) return [...items]
const d = dir ?? config.defaultDir
return [...items].sort((a, b) => {
const cmp = f.comparator(a, b, context);
return d === "asc" ? cmp : -cmp;
});
const cmp = f.comparator(a, b, context)
return d === 'asc' ? cmp : -cmp
})
},
};
}
}
}
+35
View File
@@ -0,0 +1,35 @@
const _inflight = new Map<string, Promise<unknown>>()
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let i = 0
async function worker() {
while (i < items.length) {
if (signal.aborted) return
const item = items[i++]
await fn(item).catch(() => {})
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
}
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 p = factory!().finally(() => _inflight.delete(key))
_inflight.set(key, p)
return p
}
@@ -0,0 +1,22 @@
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 },
}
}
+31
View File
@@ -0,0 +1,31 @@
export interface RetryOptions {
maxAttempts?: number
baseDelayMs?: number
maxDelayMs?: number
shouldRetry?: (err: 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 lastErr: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher()
} catch (err) {
lastErr = err
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs)
await new Promise(r => setTimeout(r, delay))
}
}
throw lastErr
}
+162 -363
View File
@@ -1,388 +1,187 @@
export type AuthMode = 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
const DEFAULT_URL = 'http://127.0.0.1:4567'
export class AuthRequiredError extends Error {
constructor(msg = 'Authentication required') {
super(msg)
this.name = 'AuthRequiredError'
interface AuthConfig {
baseUrl: string
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
user?: string
pass?: string
}
export interface UiAuthDebugStatus {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
hasSession: boolean
hasRefreshToken: boolean
accessExpiresAt: number | null
refreshExpiresAt: number | null
accessExpiresInMs: number | null
refreshExpiresInMs: number | null
shouldRefreshSoon: boolean
refreshInFlight: boolean
skewMs: number
}
const SKEW_MS = 60_000 * 2
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
let accessToken: string | null = null
let refreshToken: string | null = null
let accessExpiresAt: number | null = null
let refreshExpiresAt: number | null = null
let refreshInFlight = false
function parseExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
}
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)
export const authSession = {
clearTokens() {
accessToken = null
refreshToken = null
accessExpiresAt = null
refreshExpiresAt = null
},
}
function expiryFromJwt(token: string, jwt: JwtSettings | null) {
export function getUIAccessToken(): string | null {
return accessToken
}
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
const now = Date.now()
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
return {
accessExpiresAt: decodeJwtExpiry(token) ?? (jwt?.jwtTokenExpiry ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null),
refreshExpiresAt: jwt?.jwtRefreshExpiry ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null,
mode: config.mode,
hasSession: accessToken !== null,
hasRefreshToken: refreshToken !== null,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs,
refreshExpiresInMs,
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
refreshInFlight,
skewMs: SKEW_MS,
}
}
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 }
export function configureAuth(
baseUrl: string,
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
user?: string,
pass?: string,
): void {
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
authSession.clearTokens()
}
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) : {}) },
})
export function authHeaders(): Record<string, string> {
if (config.mode === 'BASIC_AUTH' && config.user && config.pass) {
return { Authorization: 'Basic ' + btoa(`${config.user}:${config.pass}`) }
}
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) },
})
if (config.mode === 'UI_LOGIN' && accessToken) {
return { Authorization: `Bearer ${accessToken}` }
}
return fetch(url, { ...init, signal, credentials: 'omit' })
return {}
}
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),
async function gqlRaw(query: string, variables?: Record<string, unknown>): Promise<unknown> {
const res = await fetch(`${config.baseUrl}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ query, variables }),
})
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 = ''
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
return json.data
}
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),
const res = await fetch(`${config.baseUrl}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
})
if (res.ok) return 'ok'
if (res.status === 401) return 'auth_required'
if (res.status === 401 || res.status === 403) return 'auth_required'
if (!res.ok) return 'unreachable'
const json = await res.json()
const isAuthError = json.errors?.some((e: { message: string }) =>
/unauthorized|unauthenticated/i.test(e.message)
)
return isAuthError ? 'auth_required' : 'ok'
} catch {
return 'unreachable'
} catch { return 'unreachable' }
}
}
}
export async function loginBasic(user: string, pass: string): Promise<void> {
config.user = user
config.pass = pass
config.mode = 'BASIC_AUTH'
const probe = await probeServer()
if (probe !== 'ok') throw new Error('Invalid credentials')
}
const LOGIN_MUTATION = `
mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken refreshToken
}
}
`
const REFRESH_MUTATION = `
mutation RefreshToken($refreshToken: String!) {
refreshToken(input: { refreshToken: $refreshToken }) {
accessToken
}
}
`
export async function loginUI(user: string, pass: string): Promise<void> {
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as {
login: { accessToken: string; refreshToken: string }
}
accessToken = data.login.accessToken
refreshToken = data.login.refreshToken
accessExpiresAt = parseExpiry(accessToken)
refreshExpiresAt = parseExpiry(refreshToken)
config.mode = 'UI_LOGIN'
config.user = user
}
export async function refreshAccessToken(): Promise<boolean> {
if (!refreshToken) return false
try {
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
refreshToken: { accessToken: string }
}
accessToken = data.refreshToken.accessToken
accessExpiresAt = parseExpiry(accessToken)
return true
} catch {
return false
}
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
if (config.mode !== 'UI_LOGIN') return null
if (!refreshToken) return null
const now = Date.now()
if (!force && accessExpiresAt !== null && accessExpiresAt - now > SKEW_MS) return accessToken
if (refreshInFlight) return accessToken
refreshInFlight = true
try {
const ok = await refreshAccessToken()
return ok ? accessToken : null
} finally {
refreshInFlight = false
}
}
+256
View File
@@ -0,0 +1,256 @@
import { invoke } from "@tauri-apps/api/core";
import {
persistSettings,
persistLibrary,
persistUpdates,
} from "$lib/core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
export async function exportAppData(): Promise<void> {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("export_app_data", { bytes: Array.from(zip) });
}
export async function importAppData(): Promise<void> {
const raw: number[] = await invoke("import_app_data");
const files = parseZip(new Uint8Array(raw));
const decode = (name: string) => {
const bytes = files.get(name);
if (!bytes) throw new Error(`Backup is missing ${name}`);
return JSON.parse(new TextDecoder().decode(bytes));
};
const s = decode("settings.json");
const l = decode("library.json");
const u = decode("updates.json");
await Promise.all([
persistSettings({
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
}),
]);
await showExitModal();
invoke("exit_app");
}
function showExitModal(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.className = "s-backdrop";
backdrop.style.cssText = "z-index:99999";
const modal = document.createElement("div");
modal.style.cssText = [
"background:var(--bg-surface)",
"border:1px solid var(--border-base)",
"border-radius:var(--radius-2xl)",
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
"width:min(400px,calc(100vw - 40px))",
"display:flex",
"flex-direction:column",
"overflow:hidden",
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
].join(";");
const header = document.createElement("div");
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
const title = document.createElement("p");
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
title.textContent = "Import complete";
header.appendChild(title);
const body = document.createElement("div");
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
const sub = document.createElement("p");
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
const counter = document.createElement("p");
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
counter.textContent = "Closing in 3…";
body.append(sub, counter);
const footer = document.createElement("div");
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
const btn = document.createElement("button");
btn.className = "s-btn s-btn-danger";
btn.textContent = "Close now";
footer.appendChild(btn);
modal.append(header, body, footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
let secs = 3;
const tick = setInterval(() => {
secs--;
counter.textContent = secs > 0 ? `Closing in ${secs}` : "Closing…";
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
}, 1000);
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
});
}
export async function autoBackupAppData(): Promise<void> {
try {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
const buf = new ArrayBuffer(30 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint32(14, crc32(data), true);
v.setUint32(18, data.byteLength, true);
v.setUint32(22, data.byteLength, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, 0, true);
new Uint8Array(buf).set(name, 30);
return new Uint8Array(buf);
}
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
const buf = new ArrayBuffer(46 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint16(14, 0, true);
v.setUint32(16, crc32(data), true);
v.setUint32(20, data.byteLength, true);
v.setUint32(24, data.byteLength, true);
v.setUint16(28, name.byteLength, true);
v.setUint16(30, 0, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, 0, true);
v.setUint32(42, offset, true);
new Uint8Array(buf).set(name, 46);
return new Uint8Array(buf);
}
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
const buf = new ArrayBuffer(22);
const v = new DataView(buf);
v.setUint32(0, 0x06054b50, true);
v.setUint16(4, 0, true);
v.setUint16(6, 0, true);
v.setUint16(8, count, true);
v.setUint16(10, count, true);
v.setUint32(12, cdSize, true);
v.setUint32(16, cdOffset, true);
v.setUint16(20, 0, true);
return new Uint8Array(buf);
}
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const offsets: number[] = [];
let pos = 0;
for (const { name, bytes } of files) {
const nameBytes = enc.encode(name);
const lh = localHeader(nameBytes, bytes);
offsets.push(pos);
parts.push(lh, bytes);
pos += lh.byteLength + bytes.byteLength;
}
const cdParts = files.map(({ name, bytes }, i) =>
centralHeader(enc.encode(name), bytes, offsets[i])
);
const cd = concat(cdParts);
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
}
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const files = new Map<string, Uint8Array>();
let pos = 0;
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
const fnLen = view.getUint16(pos + 26, true);
const exLen = view.getUint16(pos + 28, true);
const cSize = view.getUint32(pos + 18, true);
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
const start = pos + 30 + fnLen + exLen;
files.set(name, data.subarray(start, start + cSize));
pos = start + cSize;
}
return files;
}
function concat(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
return out;
}
+134
View File
@@ -0,0 +1,134 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { settingsState } from "$lib/state/settings.svelte";
import { getUIAccessToken } from "$lib/core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = settingsState.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = settingsState.serverAuthUser?.trim() ?? "";
const pass = settingsState.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
}
async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl);
return blobUrl;
}
function insertSorted(entry: QueueEntry) {
let lo = 0, 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()!;
active++;
doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => { active--; 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(err => {
inflight.delete(url);
return Promise.reject(err);
});
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);
const existing = inflight.get(url);
if (existing) {
const idx = queue.findIndex(e => e.url === url);
if (idx !== -1 && priority > queue[idx].priority) {
const [entry] = queue.splice(idx, 1);
entry.priority = priority;
insertSorted(entry);
}
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, i) => {
if (!url || cache.has(url) || inflight.has(url)) return;
enqueue(url, basePriority - i);
});
}
export function revokeBlobUrl(url: string): void {
const blob = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); 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();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
clearing = false;
}
+7 -7
View File
@@ -146,13 +146,13 @@ export const CACHE_GROUPS = {
} as const;
export const CACHE_KEYS = {
LIBRARY: "library",
LIBRARY: "library",
RECENT_UPDATES: "recent_updates",
ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories",
SEARCH: "search_all_manga",
SOURCES: "sources",
POPULAR: "popular",
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}`,
@@ -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(() => {});
}
+24
View File
@@ -0,0 +1,24 @@
import { appState } from '$lib/state/app.svelte'
import type { Manga } from '$lib/types'
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise(resolve => {
const worker = new Worker(new URL('./autoLinkWorker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data
for (const id of matches) appState.linkManga(focal.id, id)
worker.terminate()
resolve(matches.length)
}
worker.onerror = () => { worker.terminate(); resolve(0) }
worker.postMessage({
focalTitle: focal.title,
focalId: focal.id,
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
linkedIds: appState.settings.mangaLinks?.[focal.id] ?? [],
})
})
}
+17 -20
View File
@@ -1,29 +1,26 @@
interface WorkerMsg {
focalTitle: string;
focalId: number;
allManga: { id: number; title: string }[];
linkedIds: number[];
focalTitle: string
focalId: number
allManga: { id: number; title: string }[]
linkedIds: number[]
}
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wa = new Set(norm(a));
const wb = new Set(norm(b));
if (!wa.size || !wb.size) return 0;
const intersection = [...wa].filter(w => wb.has(w)).length;
return intersection / new Set([...wa, ...wb]).size;
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean)
const wa = new Set(norm(a))
const wb = new Set(norm(b))
if (!wa.size || !wb.size) return 0
const intersection = [...wa].filter(w => wb.has(w)).length
return intersection / new Set([...wa, ...wb]).size
}
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
const { focalTitle, focalId, allManga, linkedIds } = e.data;
const matches: number[] = [];
const { focalTitle, focalId, allManga, linkedIds } = e.data
const matches: number[] = []
for (const m of allManga) {
if (m.id === focalId) continue;
if (linkedIds.includes(m.id)) continue;
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
if (m.id === focalId) continue
if (linkedIds.includes(m.id)) continue
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id)
}
self.postMessage(matches);
};
self.postMessage(matches)
}
+28 -29
View File
@@ -1,54 +1,53 @@
const THUMB_SIZE = 16;
const DUPE_THRESH = 0.12;
const hashCache = new Map<string, Uint8ClampedArray>();
const THUMB_SIZE = 16
const DUPE_THRESH = 0.12
const hashCache = new Map<string, Uint8ClampedArray>()
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
const gray = new Uint8ClampedArray(pixels);
const gray = new Uint8ClampedArray(pixels)
for (let i = 0; i < pixels; i++) {
const o = i * 4;
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
const o = i * 4
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000
}
return gray;
return gray
}
function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = THUMB_SIZE;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
};
img.onerror = reject;
img.src = url;
});
const canvas = document.createElement('canvas')
canvas.width = canvas.height = THUMB_SIZE
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE)
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE))
}
img.onerror = reject
img.src = url
})
}
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / (a.length * 255);
let diff = 0
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i])
return diff / (a.length * 255)
}
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
if (hashCache.has(url)) return hashCache.get(url)!;
if (hashCache.has(url)) return hashCache.get(url)!
try {
const thumb = await loadThumb(url);
hashCache.set(url, thumb);
return thumb;
const thumb = await loadThumb(url)
hashCache.set(url, thumb)
return thumb
} catch {
return null;
return null
}
}
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH;
return similarity(a, b) <= DUPE_THRESH
}
export function clearHashCache(): void {
hashCache.clear();
hashCache.clear()
}
+92
View File
@@ -0,0 +1,92 @@
import { appState } from '$lib/state/app.svelte'
import { searchWithScore } from '$lib/core/algorithms/search'
import { getHash, areDuplicates } from '$lib/core/cover/coverHash'
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null }
export type CoverCandidate = {
mangaId: number
url: string
label: string
isActive: boolean
}
const FUZZY_SCORE_THRESHOLD = 0.65
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
u.search = ''
return u.href.toLowerCase()
} catch {
return url.toLowerCase()
}
}
export function resolvedCover(mangaId: number, ownUrl: string): string {
return appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
return searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
)
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id)
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = appState.getLinkedMangaIds(mangaId)
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById)
const current = appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]))
const raw: { mangaId: number; url: string; label: string }[] = [
{ mangaId, url: ownUrl, label: 'This source' },
...allIds.flatMap(id => {
const m = mangaById.get(id)
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : []
}),
]
const seen = new Set<string>()
return raw
.filter(c => {
const key = normalizeUrl(c.url)
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }))
}
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)))
const groups: number[][] = []
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i]
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false })
: undefined
if (existing) existing.push(i)
else groups.push([i])
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0]
const labels = [...new Set(group.map(i => candidates[i].label))]
return { ...candidates[active], label: labels.join(' · ') }
})
}
+3
View File
@@ -0,0 +1,3 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from './keybindEngine'
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
export type { Keybinds } from './defaultBinds'
+5
View File
@@ -0,0 +1,5 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
+166
View File
@@ -0,0 +1,166 @@
import { LazyStore } from "@tauri-apps/plugin-store";
const settingsStore = new LazyStore("settings.json", { autoSave: false });
const libraryStore = new LazyStore("library.json", { autoSave: false });
const updatesStore = new LazyStore("updates.json", { autoSave: false });
const backupsStore = new LazyStore("backups.json", { autoSave: false });
export interface PersistedData {
settings: any;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}
export async function loadAllStores(): Promise<PersistedData> {
const migrated = await migrateFromLocalStorage();
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
}
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
try {
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
localStorage.removeItem("moku-store");
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
}
}
export async function persistSettings(data: { settings: any; storeVersion: number }) {
await Promise.all([
settingsStore.set("settings", data.settings),
settingsStore.set("storeVersion", data.storeVersion),
]);
await settingsStore.save();
}
export async function persistLibrary(data: {
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any;
dailyReadCounts: Record<string, number>;
}) {
await Promise.all([
libraryStore.set("history", data.history),
libraryStore.set("bookmarks", data.bookmarks),
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog),
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
]);
await libraryStore.save();
}
export async function persistUpdates(data: {
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}) {
await Promise.all([
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]);
await updatesStore.save();
}
export interface BackupEntry { url: string; name: string; }
export async function loadBackups(): Promise<BackupEntry[]> {
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
}
export async function persistBackups(list: BackupEntry[]): Promise<void> {
await backupsStore.set("backupList", list);
await backupsStore.save();
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
}
+56
View File
@@ -0,0 +1,56 @@
import type { CustomTheme } from '$lib/types/settings'
let themeStyleEl: HTMLStyleElement | null = null
let mediaQuery: MediaQueryList | null = null
let mediaHandler: (() => void) | null = null
export function applyTheme(themeId: string, customThemes: CustomTheme[] = []) {
const custom = customThemes.find(t => t.id === themeId)
if (custom) {
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join('\n')
if (!themeStyleEl) {
themeStyleEl = document.createElement('style')
themeStyleEl.id = 'moku-custom-theme'
document.head.appendChild(themeStyleEl)
}
themeStyleEl.textContent = `:root {\n${vars}\n}`
document.documentElement.removeAttribute('data-theme')
return
}
if (themeStyleEl) {
themeStyleEl.remove()
themeStyleEl = null
}
document.documentElement.setAttribute('data-theme', themeId)
}
export function mountSystemThemeSync(
enabled: boolean,
darkTheme: string,
lightTheme: string,
onSwitch: (themeId: string) => void
) {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener('change', mediaHandler)
mediaHandler = null
}
if (!enabled) { mediaQuery = null; return }
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaHandler = () => onSwitch(mediaQuery!.matches ? darkTheme : lightTheme)
mediaQuery.addEventListener('change', mediaHandler)
onSwitch(mediaQuery.matches ? darkTheme : lightTheme)
}
export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener('change', mediaHandler)
mediaHandler = null
mediaQuery = null
}
}
+4 -10
View File
@@ -16,9 +16,7 @@ export class CapacitorAdapter implements PlatformAdapter {
async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
return 'stopped'
}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
async readFile(path: string): Promise<Uint8Array> {
const { Filesystem, Directory } = await import('@capacitor/filesystem')
@@ -37,9 +35,7 @@ export class CapacitorAdapter implements PlatformAdapter {
await Filesystem.writeFile({ path, data: base64, directory: Directory.Data })
}
async pickFolder(): Promise<string | null> {
return null
}
async pickFolder(): Promise<string | null> { return null }
async authenticateBiometric(): Promise<boolean> {
try {
@@ -70,6 +66,7 @@ export class CapacitorAdapter implements PlatformAdapter {
async minimize() {}
async maximize() {}
async close() {}
async toggleFullscreen() {}
async setDiscordPresence(_presence: DiscordPresence) {}
async clearDiscordPresence() {}
@@ -85,9 +82,6 @@ export class CapacitorAdapter implements PlatformAdapter {
await Browser.open({ url })
}
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
return null
}
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
async installAppUpdate(): Promise<void> {}
}
+27 -19
View File
@@ -1,10 +1,9 @@
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
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,
@@ -68,19 +67,25 @@ export class TauriAdapter implements PlatformAdapter {
}
async setTitle(title: string) {
await invoke('set_window_title', { title })
await getCurrentWindow().setTitle(title)
}
async minimize() {
await invoke('minimize_window')
await getCurrentWindow().minimize()
}
async maximize() {
await invoke('maximize_window')
const win = getCurrentWindow()
await (await win.isMaximized() ? win.unmaximize() : win.maximize())
}
async close() {
await invoke('close_window')
await getCurrentWindow().close()
}
async toggleFullscreen() {
const win = getCurrentWindow()
await win.setFullscreen(!await win.isFullscreen())
}
async setDiscordPresence(presence: DiscordPresence) {
@@ -100,20 +105,23 @@ export class TauriAdapter implements PlatformAdapter {
}
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
const update = await check()
if (!update?.available) return null
return {
version: update.version,
url: update.body ?? '',
notes: update.body,
}
const releases = await invoke<Array<{ tag_name: string; html_url: string; body: string }>>('list_releases')
const current = await getVersion()
const valid = releases.filter(r => r.tag_name?.trim())
if (!valid.length) return null
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
const latest = valid.map(r => r.tag_name).sort((a, b) => {
const pa = parse(a), pb = parse(b)
for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
return 0
})[0]
const pa = parse(latest), pb = parse(current)
if (!pa.some((n, i) => n > (pb[i] ?? 0))) return null
const rel = valid.find(r => r.tag_name === latest)!
return { version: latest.replace(/^v/, ''), url: rel.html_url, notes: rel.body }
}
async installAppUpdate() {
const update = await check()
if (update?.available) {
await update.downloadAndInstall()
await relaunch()
}
async installAppUpdate(tag: string) {
await invoke('download_and_install_update', { tag })
}
}
@@ -0,0 +1,40 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { toast } from "$lib/state/app.svelte";
function parse(tag: string): number[] {
return tag.replace(/^v/, "").split(".").map(Number);
}
function compare(a: number[], b: number[]): number {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
}
export async function checkForUpdateSilently(): Promise<void> {
try {
const [currentVersion, releases] = await Promise.all([
getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
toast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
+7 -6
View File
@@ -7,21 +7,21 @@ export type PlatformFeature =
| 'discord-rpc'
export interface ServerLaunchConfig {
jarPath: string
port: number
jarPath: string
port: number
dataPath: string
}
export interface DiscordPresence {
title: string
chapter: string
title: string
chapter: string
startTimestamp?: number
}
export interface AppUpdateInfo {
version: string
url: string
notes?: string
url: string
notes?: string
}
export interface PlatformAdapter {
@@ -44,6 +44,7 @@ export interface PlatformAdapter {
minimize(): Promise<void>
maximize(): Promise<void>
close(): Promise<void>
toggleFullscreen(): Promise<void>
setDiscordPresence(presence: DiscordPresence): Promise<void>
clearDiscordPresence(): Promise<void>
+16 -30
View File
@@ -15,52 +15,38 @@ export class WebAdapter implements PlatformAdapter {
async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
return 'stopped'
}
async readFile(_path: string): Promise<Uint8Array> {
return new Uint8Array()
}
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 pickFolder(): Promise<string | null> {
return null
}
async authenticateBiometric(): Promise<boolean> {
return false
}
async authenticateBiometric(): Promise<boolean> { return false }
async storeCredential(_key: string, _value: string) {}
async getCredential(_key: string): Promise<string | null> { return null }
async getCredential(_key: string): Promise<string | null> {
return null
}
async setTitle(title: string) {
document.title = title
}
async setTitle(title: string) { document.title = title }
async minimize() {}
async maximize() {}
async close() {}
async toggleFullscreen() {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen().catch(() => {})
} else {
await document.exitFullscreen().catch(() => {})
}
}
async setDiscordPresence(_presence: DiscordPresence) {}
async clearDiscordPresence() {}
async getVersion(): Promise<string> {
return __APP_VERSION__
}
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 checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
async installAppUpdate() {}
}
+26 -80
View File
@@ -1,10 +1,5 @@
import type {
PlatformAdapter,
PlatformFeature,
ServerLaunchConfig,
DiscordPresence,
AppUpdateInfo,
} from '$lib/platform-adapters/types'
import type { PlatformAdapter } from '$lib/platform-adapters/types'
import type { ServerLaunchConfig, DiscordPresence, AppUpdateInfo, PlatformFeature } from '$lib/platform-adapters/types'
let adapter: PlatformAdapter
@@ -12,87 +7,38 @@ export function initPlatformService(a: PlatformAdapter) {
adapter = a
}
function getAdapter(): PlatformAdapter {
function get(): PlatformAdapter {
if (!adapter) throw new Error('PlatformService not initialized')
return adapter
}
export function isSupported(feature: PlatformFeature): boolean {
return getAdapter().isSupported(feature)
}
export const platformService = {
isSupported: (f: PlatformFeature) => get().isSupported(f),
init: () => get().init(),
export function launchServer(config: ServerLaunchConfig) {
return getAdapter().launchServer(config)
}
launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
stopServer: () => get().stopServer(),
getServerStatus: () => get().getServerStatus(),
export function stopServer() {
return getAdapter().stopServer()
}
readFile: (path: string) => get().readFile(path),
writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data),
pickFolder: () => get().pickFolder(),
export function getServerStatus() {
return getAdapter().getServerStatus()
}
authenticateBiometric: () => get().authenticateBiometric(),
storeCredential: (k: string, v: string) => get().storeCredential(k, v),
getCredential: (k: string) => get().getCredential(k),
export function readFile(path: string) {
return getAdapter().readFile(path)
}
setTitle: (title: string) => get().setTitle(title),
minimize: () => get().minimize(),
maximize: () => get().maximize(),
close: () => get().close(),
toggleFullscreen: () => get().toggleFullscreen(),
export function writeFile(path: string, data: Uint8Array) {
return getAdapter().writeFile(path, data)
}
setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p),
clearDiscordPresence: () => get().clearDiscordPresence(),
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()
getVersion: () => get().getVersion(),
openExternal: (url: string) => get().openExternal(url),
checkForAppUpdate: () => get().checkForAppUpdate(),
installAppUpdate: () => get().installAppUpdate(),
}
+45 -7
View File
@@ -4,7 +4,7 @@ import { readerState } from '$lib/state/reader.svelte'
export async function loadChapters(mangaId: string) {
seriesState.chaptersLoading = true
seriesState.chaptersError = null
seriesState.chaptersError = null
try {
seriesState.chapters = await getAdapter().getChapters(mangaId)
} catch (e) {
@@ -14,12 +14,25 @@ export async function loadChapters(mangaId: string) {
}
}
export async function loadChapterPages(chapterId: string) {
readerState.pagesLoading = true
readerState.pagesError = null
export async function fetchChapters(mangaId: string) {
seriesState.chaptersLoading = true
seriesState.chaptersError = null
try {
readerState.pages = await getAdapter().getChapterPages(chapterId)
seriesState.chapters = await getAdapter().fetchChapters(mangaId)
} catch (e) {
seriesState.chaptersError = String(e)
} finally {
seriesState.chaptersLoading = false
}
}
export async function loadChapterPages(chapterId: string, signal?: AbortSignal) {
readerState.pagesLoading = true
readerState.pagesError = null
try {
readerState.pages = await getAdapter().getChapterPages(chapterId, signal)
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
readerState.pagesError = String(e)
} finally {
readerState.pagesLoading = false
@@ -28,13 +41,38 @@ export async function loadChapterPages(chapterId: string) {
export async function markRead(id: string, read: boolean) {
await getAdapter().markChapterRead(id, read)
const chapter = seriesState.chapters.find(c => c.id === id)
// chapter.id is a number; route params arrive as strings — compare via Number()
const numId = Number(id)
const chapter = seriesState.chapters.find(c => c.id === numId)
if (chapter) chapter.read = read
}
export async function markManyRead(ids: string[], read: boolean) {
await getAdapter().markChaptersRead(ids, read)
const numIds = new Set(ids.map(Number))
for (const c of seriesState.chapters) {
if (ids.includes(c.id)) c.read = read
if (numIds.has(c.id)) c.read = read
}
}
export async function updateChaptersProgress(
ids: string[],
patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number },
) {
await getAdapter().updateChaptersProgress(ids, patch)
const numIds = new Set(ids.map(Number))
for (const c of seriesState.chapters) {
if (!numIds.has(c.id)) continue
if (patch.isRead !== undefined) c.read = patch.isRead
if (patch.isBookmarked !== undefined) c.bookmarked = patch.isBookmarked
if (patch.lastPageRead !== undefined) c.lastPageRead = patch.lastPageRead
}
}
export async function deleteDownloadedChapters(ids: string[]) {
await getAdapter().deleteDownloadedChapters(ids)
const numIds = new Set(ids.map(Number))
for (const c of seriesState.chapters) {
if (numIds.has(c.id)) c.downloaded = false
}
}
+19
View File
@@ -14,12 +14,31 @@ export async function enqueueDownload(chapterId: string) {
await loadDownloads()
}
export async function enqueueDownloads(chapterIds: string[]) {
await getAdapter().enqueueDownloads(chapterIds)
await loadDownloads()
}
export async function dequeueDownload(chapterId: string) {
await getAdapter().dequeueDownload(chapterId)
downloadsState.items = downloadsState.items.filter(d => d.chapterId !== chapterId)
}
export async function dequeueDownloads(chapterIds: string[]) {
const ids = new Set(chapterIds)
await getAdapter().dequeueDownloads(chapterIds)
downloadsState.items = downloadsState.items.filter(d => !ids.has(d.chapterId))
}
export async function clearDownloads() {
await getAdapter().clearDownloads()
downloadsState.items = []
}
export async function startDownloader() {
await getAdapter().startDownloader()
}
export async function stopDownloader() {
await getAdapter().stopDownloader()
}
+31 -2
View File
@@ -1,9 +1,10 @@
import { getAdapter } from '$lib/request-manager'
import { extensionsState } from '$lib/state/extensions.svelte'
import type { SetServerAuthInput, SetSocksProxyInput, SetFlareSolverrInput } from '$lib/server-adapters/types'
export async function loadExtensions() {
extensionsState.loading = true
extensionsState.error = null
extensionsState.error = null
try {
extensionsState.items = await getAdapter().getExtensions()
} catch (e) {
@@ -26,6 +27,11 @@ export async function installExtension(id: string) {
await loadExtensions()
}
export async function installExternalExtension(url: string) {
await getAdapter().installExternalExtension(url)
await loadExtensions()
}
export async function uninstallExtension(id: string) {
await getAdapter().uninstallExtension(id)
extensionsState.items = extensionsState.items.filter(e => e.id !== id)
@@ -36,9 +42,16 @@ export async function updateExtension(id: string) {
await loadExtensions()
}
export async function updateAllExtensions() {
const updatable = extensionsState.items.filter(e => e.hasUpdate).map(e => e.id)
if (!updatable.length) return
await getAdapter().updateExtensions(updatable)
await loadExtensions()
}
export async function browseSource(sourceId: string, page: number) {
extensionsState.browseLoading = true
extensionsState.browseError = null
extensionsState.browseError = null
try {
const result = await getAdapter().browseSource(sourceId, page)
extensionsState.browseResults = result.items
@@ -49,3 +62,19 @@ export async function browseSource(sourceId: string, page: number) {
extensionsState.browseLoading = false
}
}
export async function getServerSecurity() {
return getAdapter().getServerSecurity()
}
export async function setServerAuth(input: SetServerAuthInput) {
await getAdapter().setServerAuth(input)
}
export async function setSocksProxy(input: SetSocksProxyInput) {
await getAdapter().setSocksProxy(input)
}
export async function setFlareSolverr(input: SetFlareSolverrInput) {
await getAdapter().setFlareSolverr(input)
}
+17
View File
@@ -1,4 +1,9 @@
import type { ServerAdapter } from '$lib/server-adapters/types'
import * as extensions from './extensions'
import * as chapters from './chapters'
import * as downloads from './downloads'
import * as manga from './manga'
import * as tracking from './tracking'
let adapter: ServerAdapter
@@ -9,4 +14,16 @@ export function initRequestManager(a: ServerAdapter) {
export function getAdapter(): ServerAdapter {
if (!adapter) throw new Error('RequestManager not initialized')
return adapter
}
export function clearPageCache(chapterId?: number): void {
getAdapter().clearPageCache(chapterId)
}
export const requestManager = {
extensions,
chapters,
downloads,
manga,
tracking,
}
+78 -5
View File
@@ -1,11 +1,12 @@
import { getAdapter } from '$lib/request-manager'
import { libraryState } from '$lib/state/library.svelte'
import { toast } from '$lib/state/notifications.svelte'
import { seriesState } from '$lib/state/series.svelte'
import type { MangaFilters, MangaMeta } from '$lib/server-adapters/types'
export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) {
libraryState.loading = true
libraryState.error = null
libraryState.error = null
try {
const result = await getAdapter().getMangaList(filters)
libraryState.items = result.items
@@ -18,7 +19,7 @@ export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) {
export async function loadManga(id: string) {
seriesState.loading = true
seriesState.error = null
seriesState.error = null
try {
seriesState.current = await getAdapter().getManga(id)
} catch (e) {
@@ -28,9 +29,21 @@ export async function loadManga(id: string) {
}
}
export async function fetchManga(id: string) {
seriesState.loading = true
seriesState.error = null
try {
seriesState.current = await getAdapter().fetchManga(id)
} catch (e) {
seriesState.error = String(e)
} finally {
seriesState.loading = false
}
}
export async function searchManga(query: string, sourceId?: string) {
libraryState.loading = true
libraryState.error = null
libraryState.error = null
try {
libraryState.searchResults = await getAdapter().searchManga(query, sourceId)
} catch (e) {
@@ -52,7 +65,67 @@ export async function removeFromLibrary(mangaId: string) {
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
await getAdapter().updateMangaMeta(id, meta)
if (String(seriesState.current?.id) === id) {
await loadManga(id)
if (String(seriesState.current?.id) === id) await loadManga(id)
}
export async function deleteMangaMeta(id: string, key: string) {
await getAdapter().deleteMangaMeta(id, key)
if (String(seriesState.current?.id) === id) await loadManga(id)
}
export async function refreshLibrary() {
libraryState.refreshing = true
try {
await getAdapter().checkForUpdates()
await loadLibrary()
toast({ kind: 'success', message: 'Library updated' })
} catch (e) {
toast({ kind: 'error', message: 'Update failed', detail: String(e) })
} finally {
libraryState.refreshing = false
}
}
export async function stopLibraryUpdate() {
await getAdapter().stopLibraryUpdate()
}
export async function pollLibraryUpdateStatus() {
return getAdapter().getLibraryUpdateStatus()
}
export async function bulkRemoveFromLibrary(ids: Set<number>) {
await Promise.allSettled([...ids].map(id => getAdapter().removeFromLibrary(String(id))))
libraryState.items = libraryState.items.filter(m => !ids.has(m.id))
libraryState.exitSelect()
}
export async function loadCategories() {
try {
libraryState.categories = await getAdapter().getCategories()
} catch (e) {
libraryState.error = String(e)
}
}
export async function createCategory(name: string) {
const category = await getAdapter().createCategory(name)
libraryState.categories = [...libraryState.categories, category]
}
export async function deleteCategory(id: number) {
await getAdapter().deleteCategory(id)
libraryState.categories = libraryState.categories.filter(c => c.id !== id)
}
export async function updateCategoryOrder(id: number, position: number) {
libraryState.categories = await getAdapter().updateCategoryOrder(id, position)
}
export async function updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]) {
await getAdapter().updateMangaCategories(mangaId, addTo, removeFrom)
}
export async function updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]) {
await getAdapter().updateMangasCategories(mangaIds, addTo, removeFrom)
}
+32 -3
View File
@@ -3,7 +3,7 @@ import { trackingState } from '$lib/state/tracking.svelte'
export async function loadTrackers() {
trackingState.loading = true
trackingState.error = null
trackingState.error = null
try {
trackingState.trackers = await getAdapter().getTrackers()
} catch (e) {
@@ -13,9 +13,38 @@ export async function loadTrackers() {
}
}
export async function loadMangaTrackRecords(mangaId: string) {
trackingState.recordsLoading = true
trackingState.recordsError = null
try {
trackingState.records = await getAdapter().getMangaTrackRecords(mangaId)
} catch (e) {
trackingState.recordsError = String(e)
} finally {
trackingState.recordsLoading = false
}
}
export async function searchTracker(trackerId: string, query: string) {
trackingState.searchLoading = true
trackingState.searchError = null
try {
trackingState.searchResults = await getAdapter().searchTracker(trackerId, query)
} catch (e) {
trackingState.searchError = String(e)
} finally {
trackingState.searchLoading = false
}
}
export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) {
await getAdapter().linkTracker(mangaId, trackerId, remoteId)
await loadTrackers()
await loadMangaTrackRecords(mangaId)
}
export async function unlinkTracker(mangaId: string, recordId: string) {
await getAdapter().unlinkTracker(recordId)
await loadMangaTrackRecords(mangaId)
}
export async function syncTracking(mangaId: string) {
@@ -25,4 +54,4 @@ export async function syncTracking(mangaId: string) {
} finally {
trackingState.syncing = false
}
}
}
+34 -1
View File
@@ -8,8 +8,9 @@ import type {
Page,
DownloadItem,
UpdateResult,
LibraryUpdateProgress,
} from '$lib/server-adapters/types'
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
function notImplemented(): never {
throw new Error('MokuAdapter: not implemented')
@@ -17,37 +18,69 @@ function notImplemented(): never {
export class MokuAdapter implements ServerAdapter {
async connect(_config: ServerConfig): Promise<void> { notImplemented() }
getServerUrl(): string { return 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 fetchManga(_id: string): Promise<Manga> { return notImplemented() }
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() }
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() }
async updateMangas(_ids: string[], _patch: { inLibrary?: boolean }): Promise<void> { notImplemented() }
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() }
async deleteMangaMeta(_id: string, _key: string): 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 fetchChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
async getRecentlyUpdated(): Promise<Chapter[]> { return notImplemented() }
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() }
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() }
async updateChaptersProgress(_ids: string[], _patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void> { notImplemented() }
async deleteDownloadedChapters(_ids: string[]): Promise<void> { notImplemented() }
async setChapterMeta(_chapterId: string, _key: string, _value: string): Promise<void> { notImplemented() }
async deleteChapterMeta(_chapterId: string, _key: string): Promise<void> { notImplemented() }
async getDownloads(): Promise<DownloadItem[]> { return notImplemented() }
async enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
async enqueueDownloads(_chapterIds: string[]): Promise<void> { notImplemented() }
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
async dequeueDownloads(_chapterIds: string[]): Promise<void> { notImplemented() }
async clearDownloads(): Promise<void> { notImplemented() }
async startDownloader(): Promise<void> { notImplemented() }
async stopDownloader(): 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 updateExtensions(_ids: string[]): Promise<void> { notImplemented() }
async installExternalExtension(_url: string): Promise<void> { notImplemented() }
async getSources(): Promise<Source[]> { return notImplemented() }
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() }
async getCategories(): Promise<Category[]> { return notImplemented() }
async createCategory(_name: string): Promise<Category> { return notImplemented() }
async deleteCategory(_id: number): Promise<void> { notImplemented() }
async updateCategoryOrder(_id: number, _position: number): Promise<Category[]> { return notImplemented() }
async updateMangaCategories(_mangaId: string, _addTo: number[], _removeFrom: number[]): Promise<void> { notImplemented() }
async updateMangasCategories(_mangaIds: string[], _addTo: number[], _removeFrom: number[]): Promise<void> { notImplemented() }
async updateCategoryManga(_categoryId: number): Promise<void> { notImplemented() }
async getTrackers(): Promise<Tracker[]> { return notImplemented() }
async getMangaTrackRecords(_mangaId: string): Promise<unknown[]> { return notImplemented() }
async searchTracker(_trackerId: string, _query: string): Promise<unknown[]> { return notImplemented() }
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() }
async unlinkTracker(_recordId: string): Promise<void> { notImplemented() }
async fetchTrackRecord(_recordId: string): Promise<void> { notImplemented() }
async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() }
async stopLibraryUpdate(): Promise<void> { notImplemented() }
async getLibraryUpdateStatus(): Promise<LibraryUpdateProgress> { return notImplemented() }
clearPageCache(_chapterId?: number): void {}
}
@@ -9,6 +9,15 @@ export const GET_CHAPTERS = `
}
`
export const GET_CHAPTER = `
query GetChapter($id: Int!) {
chapter(id: $id) {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
`
export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
+281 -32
View File
@@ -8,55 +8,126 @@ import type {
Page,
DownloadItem,
UpdateResult,
LibraryUpdateProgress,
ServerSecurity,
SetServerAuthInput,
SetSocksProxyInput,
SetFlareSolverrInput,
} from '$lib/server-adapters/types'
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
import {
GET_LIBRARY,
GET_MANGA,
GET_CATEGORIES,
FETCH_MANGA,
UPDATE_MANGA,
SET_MANGA_META,
UPDATE_MANGAS,
UPDATE_MANGA_CATEGORIES,
UPDATE_MANGAS_CATEGORIES,
CREATE_CATEGORY,
DELETE_CATEGORY,
UPDATE_CATEGORY_ORDER,
UPDATE_CATEGORY_MANGA,
UPDATE_LIBRARY,
UPDATE_LIBRARY_MANGA,
UPDATE_STOP,
SET_MANGA_META,
DELETE_MANGA_META,
FETCH_SOURCE_MANGA,
LIBRARY_UPDATE_STATUS,
} from './manga'
import {
GET_CHAPTERS,
GET_CHAPTER,
GET_RECENTLY_UPDATED,
FETCH_CHAPTERS,
FETCH_CHAPTER_PAGES,
MARK_CHAPTER_READ,
MARK_CHAPTERS_READ,
UPDATE_CHAPTERS_PROGRESS,
DELETE_DOWNLOADED_CHAPTERS,
SET_CHAPTER_META,
DELETE_CHAPTER_META,
} from './chapters'
import {
GET_DOWNLOAD_STATUS,
ENQUEUE_DOWNLOAD,
ENQUEUE_CHAPTERS_DOWNLOAD,
DEQUEUE_DOWNLOAD,
DEQUEUE_CHAPTERS_DOWNLOAD,
START_DOWNLOADER,
STOP_DOWNLOADER,
CLEAR_DOWNLOADER,
} from './downloads'
import {
GET_EXTENSIONS,
GET_SOURCES,
GET_SERVER_SECURITY,
FETCH_EXTENSIONS,
UPDATE_EXTENSION,
UPDATE_EXTENSIONS,
INSTALL_EXTERNAL_EXTENSION,
SET_SERVER_AUTH,
} from './extensions'
import {
GET_TRACKERS,
GET_MANGA_TRACK_RECORDS,
SEARCH_TRACKER,
BIND_TRACK,
UNLINK_TRACK,
TRACK_PROGRESS,
UPDATE_TRACK,
} from './tracking'
import {
GQLResponse,
type GQLResponse,
mapManga,
mapChapter,
mapExtension,
mapDownloadItem,
mapCategory,
} from './types'
import { initPageCache, clearPageCache as _clearPageCache } from './pageCache'
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 SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings { socksProxyEnabled socksProxyHost socksProxyPort }
}
}
`
const SET_FLARE_SOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings { flareSolverrEnabled flareSolverrUrl }
}
}
`
@@ -65,12 +136,17 @@ export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567'
private authHeader: string | null = null
async connect(config: ServerConfig) {
async connect(config: ServerConfig): Promise<void> {
this.baseUrl = config.baseUrl.replace(/\/$/, '')
if (config.credentials) {
const { username, password } = config.credentials
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
}
initPageCache(this.gql.bind(this), this.getServerUrl.bind(this))
}
getServerUrl(): string {
return this.baseUrl
}
async getStatus(): Promise<ServerStatus> {
@@ -92,11 +168,16 @@ export class SuwayomiAdapter implements ServerAdapter {
return h
}
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
private async gql<T>(
query: string,
variables?: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query, variables }),
signal,
})
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
const json: GQLResponse<T> = await res.json()
@@ -109,39 +190,54 @@ export class SuwayomiAdapter implements ServerAdapter {
return mapManga(data.manga)
}
async fetchManga(id: string): Promise<Manga> {
const data = await this.gql<{ fetchManga: { manga: Record<string, unknown> } }>(
FETCH_MANGA, { id: Number(id) }
)
return mapManga(data.fetchManga.manga)
}
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
let items = data.mangas.nodes.map(mapManga)
if (filters.status) items = items.filter(m => m.status === filters.status)
if (filters.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)
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 []
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] }
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
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) {
async addToLibrary(mangaId: string): Promise<void> {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
}
async removeFromLibrary(mangaId: string) {
async removeFromLibrary(mangaId: string): Promise<void> {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
}
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
async updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void> {
await this.gql(UPDATE_MANGAS, { ids: ids.map(Number), ...patch })
}
async updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void> {
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) })
}
}
async deleteMangaMeta(id: string, key: string): Promise<void> {
await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key })
}
async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: Number(mangaId) }
@@ -156,21 +252,54 @@ export class SuwayomiAdapter implements ServerAdapter {
return mapChapter(data.chapter)
}
async getChapterPages(id: string): Promise<Page[]> {
async getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]> {
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>(
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }, signal
)
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
}
async markChapterRead(id: string, read: boolean) {
async fetchChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ fetchChapters: { chapters: Record<string, unknown>[] } }>(
FETCH_CHAPTERS, { mangaId: Number(mangaId) }
)
return data.fetchChapters.chapters.map(mapChapter)
}
async getRecentlyUpdated(): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_RECENTLY_UPDATED
)
return data.chapters.nodes.map(mapChapter)
}
async markChapterRead(id: string, read: boolean): Promise<void> {
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read })
}
async markChaptersRead(ids: string[], read: boolean) {
async markChaptersRead(ids: string[], read: boolean): Promise<void> {
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read })
}
async updateChaptersProgress(
ids: string[],
patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number },
): Promise<void> {
await this.gql(UPDATE_CHAPTERS_PROGRESS, { ids: ids.map(Number), ...patch })
}
async deleteDownloadedChapters(ids: string[]): Promise<void> {
await this.gql(DELETE_DOWNLOADED_CHAPTERS, { ids: ids.map(Number) })
}
async setChapterMeta(chapterId: string, key: string, value: string): Promise<void> {
await this.gql(SET_CHAPTER_META, { chapterId: Number(chapterId), key, value })
}
async deleteChapterMeta(chapterId: string, key: string): Promise<void> {
await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key })
}
async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
GET_DOWNLOAD_STATUS
@@ -178,36 +307,60 @@ export class SuwayomiAdapter implements ServerAdapter {
return data.downloadStatus.queue.map(mapDownloadItem)
}
async enqueueDownload(chapterId: string) {
async enqueueDownload(chapterId: string): Promise<void> {
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
}
async dequeueDownload(chapterId: string) {
async enqueueDownloads(chapterIds: string[]): Promise<void> {
await this.gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) })
}
async dequeueDownload(chapterId: string): Promise<void> {
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
}
async clearDownloads() {
async dequeueDownloads(chapterIds: string[]): Promise<void> {
await this.gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) })
}
async clearDownloads(): Promise<void> {
await this.gql(CLEAR_DOWNLOADER)
}
async startDownloader(): Promise<void> {
await this.gql(START_DOWNLOADER)
}
async stopDownloader(): Promise<void> {
await this.gql(STOP_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)
}
async installExtension(id: string) {
async installExtension(id: string): Promise<void> {
await this.gql(UPDATE_EXTENSION, { id, install: true })
}
async uninstallExtension(id: string) {
async uninstallExtension(id: string): Promise<void> {
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
}
async updateExtension(id: string) {
async updateExtension(id: string): Promise<void> {
await this.gql(UPDATE_EXTENSION, { id, update: true })
}
async updateExtensions(ids: string[]): Promise<void> {
await this.gql(UPDATE_EXTENSIONS, { ids, update: true })
}
async installExternalExtension(url: string): Promise<void> {
await this.gql(INSTALL_EXTERNAL_EXTENSION, { url })
}
async getSources(): Promise<Source[]> {
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
return data.sources.nodes
@@ -223,12 +376,61 @@ export class SuwayomiAdapter implements ServerAdapter {
}
}
async getCategories(): Promise<Category[]> {
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
return data.categories.nodes.map(mapCategory)
}
async createCategory(name: string): Promise<Category> {
const data = await this.gql<{ createCategory: { category: Record<string, unknown> } }>(
CREATE_CATEGORY, { name }
)
return mapCategory(data.createCategory.category)
}
async deleteCategory(id: number): Promise<void> {
await this.gql(DELETE_CATEGORY, { id })
}
async updateCategoryOrder(id: number, position: number): Promise<Category[]> {
const data = await this.gql<{ updateCategoryOrder: { categories: Record<string, unknown>[] } }>(
UPDATE_CATEGORY_ORDER, { id, position }
)
return data.updateCategoryOrder.categories.map(mapCategory)
}
async updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void> {
await this.gql(UPDATE_MANGA_CATEGORIES, { mangaId: Number(mangaId), addTo, removeFrom })
}
async updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void> {
await this.gql(UPDATE_MANGAS_CATEGORIES, { ids: mangaIds.map(Number), addTo, removeFrom })
}
async updateCategoryManga(categoryId: number): Promise<void> {
await this.gql(UPDATE_CATEGORY_MANGA, { categoryId })
}
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) {
async getMangaTrackRecords(mangaId: string): Promise<unknown[]> {
const data = await this.gql<{ manga: { trackRecords: { nodes: unknown[] } } }>(
GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }
)
return data.manga.trackRecords.nodes
}
async searchTracker(trackerId: string, query: string): Promise<unknown[]> {
const data = await this.gql<{ searchTracker: { trackSearches: unknown[] } }>(
SEARCH_TRACKER, { trackerId: Number(trackerId), query }
)
return data.searchTracker.trackSearches
}
async linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void> {
await this.gql(BIND_TRACK, {
mangaId: Number(mangaId),
trackerId: Number(trackerId),
@@ -236,16 +438,45 @@ export class SuwayomiAdapter implements ServerAdapter {
})
}
async syncTracking(mangaId: string) {
async unlinkTracker(recordId: string): Promise<void> {
await this.gql(UNLINK_TRACK, { trackRecordId: Number(recordId) })
}
async fetchTrackRecord(recordId: string): Promise<void> {
await this.gql(UPDATE_TRACK, { recordId: Number(recordId) })
}
async syncTracking(mangaId: string): Promise<void> {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
}
async getServerSecurity(): Promise<ServerSecurity> {
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
return data.settings
}
async setServerAuth(input: SetServerAuthInput): Promise<void> {
await this.gql(SET_SERVER_AUTH, {
authMode: input.authMode,
authUsername: input.authUsername,
authPassword: input.authPassword,
})
}
async setSocksProxy(input: SetSocksProxyInput): Promise<void> {
await this.gql(SET_SOCKS_PROXY, input)
}
async setFlareSolverr(input: SetFlareSolverrInput): Promise<void> {
await this.gql(SET_FLARE_SOLVERR, input)
}
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) })
await this.gql(UPDATE_LIBRARY_MANGA, { mangaId: Number(id) })
const after = await this.getChapters(id)
results.push({ mangaId: id, newChapters: after.length - before.length })
}
@@ -254,4 +485,22 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(UPDATE_LIBRARY)
return []
}
async stopLibraryUpdate(): Promise<void> {
await this.gql(UPDATE_STOP)
}
async getLibraryUpdateStatus(): Promise<LibraryUpdateProgress> {
const data = await this.gql<{
libraryUpdateStatus: {
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number }
}
}>(LIBRARY_UPDATE_STATUS)
const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo
return { isRunning, finishedJobs, totalJobs }
}
clearPageCache(chapterId?: number): void {
_clearPageCache(chapterId)
}
}
+45 -17
View File
@@ -3,9 +3,12 @@ export const GET_LIBRARY = `
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
description status author artist genre inLibraryAt lastFetchedAt
description status author artist genre
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
source { id name displayName }
chapters { totalCount }
latestFetchedChapter { id uploadDate }
latestUploadedChapter { id uploadDate }
lastReadChapter { id chapterNumber }
firstUnreadChapter { id chapterNumber }
}
@@ -17,7 +20,7 @@ export const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
inLibraryAt lastFetchedAt updateStrategy
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
source { id name displayName }
lastReadChapter { id chapterNumber lastPageRead }
firstUnreadChapter { id chapterNumber }
@@ -39,6 +42,21 @@ export const GET_CATEGORIES = `
}
`
export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus {
libraryUpdateStatus {
jobsInfo {
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
}
mangaUpdates {
status
manga { id title thumbnailUrl unreadCount }
}
}
lastUpdateTimestamp { timestamp }
}
`
export const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
@@ -52,18 +70,9 @@ export const MANGAS_BY_GENRE = `
}
`
export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus {
libraryUpdateStatus {
jobsInfo {
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
}
mangaUpdates {
status
manga { id title thumbnailUrl unreadCount }
}
}
lastUpdateTimestamp { timestamp }
export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath {
settings { downloadsPath localSourcePath }
}
`
@@ -142,6 +151,14 @@ export const UPDATE_CATEGORY_ORDER = `
}
`
export const UPDATE_CATEGORY_MANGA = `
mutation UpdateCategoryManga($categoryId: Int!) {
updateCategoryManga(input: { categoryId: $categoryId }) {
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
}
}
`
export const UPDATE_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
@@ -158,6 +175,14 @@ export const UPDATE_LIBRARY_MANGA = `
}
`
export const UPDATE_STOP = `
mutation UpdateStop {
updateStop(input: {}) {
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
}
}
`
export const SET_MANGA_META = `
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
@@ -189,8 +214,11 @@ export const RESTORE_BACKUP = `
}
`
export const GET_RESTORE_STATUS = `
query GetRestoreStatus($id: String!) {
restoreStatus(id: $id) { mangaProgress state totalManga }
export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas { id title thumbnailUrl inLibrary }
hasNextPage
}
}
`
@@ -0,0 +1,93 @@
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
import { dedupeRequest } from "$lib/core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "$lib/server-adapters/suwayomi/chapters";
type GqlFn = <T>(query: string, vars?: Record<string, unknown>, signal?: AbortSignal) => Promise<T>;
let _gql: GqlFn;
let _getServerUrl: () => string;
export function initPageCache(gql: GqlFn, getServerUrl: () => string): void {
_gql = gql;
_getServerUrl = getServerUrl;
}
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url);
const cached = resolvedUrlCache.get(url);
if (cached) return cached;
const p = getBlobUrl(url, priority).catch(err => {
resolvedUrlCache.delete(url);
return Promise.reject(err);
});
resolvedUrlCache.set(url, p);
return p;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<string[]> {
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 p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
_gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => {
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${_getServerUrl()}${p}`);
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
pageCache.set(chapterId, urls);
return urls;
})
).finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p);
}
const base = inflight.get(chapterId)!;
if (!signal) return base;
return new Promise((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> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return resolveUrl(url, useBlob).then(src => new Promise(res => {
const img = new Image();
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
img.onerror = () => res(0.67);
img.src = src;
}));
}
export function preloadImage(url: string, useBlob: boolean): void {
if (useBlob) { preloadBlobUrls([url], 0); return; }
resolveUrl(url, useBlob).then(src => { new Image().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);
} else {
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
aspectCache.clear();
}
}
+51 -26
View File
@@ -1,4 +1,4 @@
import type { Manga, Chapter, Extension } from '$lib/types'
import type { Manga, Chapter, Extension, Category } from '$lib/types'
import type { DownloadItem } from '$lib/server-adapters/types'
export interface GQLResponse<T> {
@@ -7,33 +7,46 @@ export interface GQLResponse<T> {
}
export 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,
id: raw.id as number,
title: raw.title as string,
description: raw.description as string | null | undefined,
thumbnailUrl: raw.thumbnailUrl as string | null | undefined,
status: raw.status as string | undefined,
author: raw.author as string | null | undefined,
artist: raw.artist as string | null | undefined,
tags: raw.genre as string[] | undefined,
inLibrary: raw.inLibrary as boolean,
realUrl: raw.realUrl as string | null | undefined,
source: raw.source as Manga['source'],
unreadCount: raw.unreadCount as number | undefined,
downloadCount: raw.downloadCount as number | undefined,
bookmarkCount: raw.bookmarkCount as number | undefined,
lastReadChapter: raw.lastReadChapter as Manga['lastReadChapter'],
firstUnreadChapter: raw.firstUnreadChapter as Manga['firstUnreadChapter'],
addedAt: raw.inLibraryAt ? new Date(raw.inLibraryAt as string).getTime() : undefined,
lastReadAt: raw.lastReadChapter ? Date.now() : undefined,
}
}
export function mapChapter(raw: Record<string, unknown>): Chapter {
return {
id: raw.id as number,
name: raw.name as string,
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'],
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'],
}
}
@@ -46,14 +59,14 @@ export function mapExtension(raw: Record<string, unknown>): Extension {
export function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
const chapter = raw.chapter as Record<string, unknown>
const manga = chapter?.manga as Record<string, unknown>
const manga = chapter?.manga as Record<string, unknown>
return {
chapterId: String(chapter?.id),
mangaId: String(chapter?.mangaId ?? manga?.id),
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),
mangaTitle: manga?.title as string,
progress: (raw.progress as number) ?? 0,
state: mapDownloadState(raw.state as string),
}
}
@@ -64,4 +77,16 @@ function mapDownloadState(state: string): DownloadItem['state'] {
case 'ERROR': return 'error'
default: return 'queued'
}
}
export function mapCategory(raw: Record<string, unknown>): Category {
return {
id: raw.id as number,
name: raw.name as string,
order: raw.order as number,
default: raw.default as boolean,
includeInUpdate: raw.includeInUpdate as boolean,
includeInDownload: raw.includeInDownload as boolean,
mangas: (raw.mangas as { nodes: Record<string, unknown>[] })?.nodes?.map(mapManga),
}
}
+93 -10
View File
@@ -1,10 +1,4 @@
import type {
Manga,
Chapter,
Extension,
Source,
Tracker,
} from '$lib/types'
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
export interface ServerConfig {
baseUrl: string
@@ -21,7 +15,13 @@ export interface MangaFilters {
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[]
@@ -47,6 +47,7 @@ export interface DownloadItem {
mangaId: string
chapterName: string
mangaTitle: string
thumbnailUrl?: string
progress: number
state: 'queued' | 'downloading' | 'finished' | 'error'
}
@@ -56,39 +57,121 @@ export interface UpdateResult {
newChapters: number
}
export interface LibraryUpdateProgress {
isRunning: boolean
finishedJobs: number
totalJobs: number
}
export interface ServerSecurity {
authMode: string
authUsername: string
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
export interface SetServerAuthInput {
authMode: string
authUsername: string
authPassword: string
}
export interface SetSocksProxyInput {
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
socksProxyPassword: string
}
export interface SetFlareSolverrInput {
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
export interface ServerAdapter {
connect(config: ServerConfig): Promise<void>
getStatus(): Promise<ServerStatus>
getServerUrl(): string
getManga(id: string): Promise<Manga>
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
searchManga(query: string, sourceId?: string): Promise<Manga[]>
fetchManga(id: string): Promise<Manga>
addToLibrary(mangaId: string): Promise<void>
removeFromLibrary(mangaId: string): Promise<void>
updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void>
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
deleteMangaMeta(id: string, key: string): Promise<void>
getChapters(mangaId: string): Promise<Chapter[]>
getChapter(id: string): Promise<Chapter>
getChapterPages(id: string): Promise<Page[]>
getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]>
fetchChapters(mangaId: string): Promise<Chapter[]>
getRecentlyUpdated(): Promise<Chapter[]>
markChapterRead(id: string, read: boolean): Promise<void>
markChaptersRead(ids: string[], read: boolean): Promise<void>
updateChaptersProgress(ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void>
deleteDownloadedChapters(ids: string[]): Promise<void>
setChapterMeta(chapterId: string, key: string, value: string): Promise<void>
deleteChapterMeta(chapterId: string, key: string): Promise<void>
getDownloads(): Promise<DownloadItem[]>
enqueueDownload(chapterId: string): Promise<void>
enqueueDownloads(chapterIds: string[]): Promise<void>
dequeueDownload(chapterId: string): Promise<void>
dequeueDownloads(chapterIds: string[]): Promise<void>
clearDownloads(): Promise<void>
startDownloader(): Promise<void>
stopDownloader(): Promise<void>
getExtensions(): Promise<Extension[]>
installExtension(id: string): Promise<void>
uninstallExtension(id: string): Promise<void>
updateExtension(id: string): Promise<void>
updateExtensions(ids: string[]): Promise<void>
installExternalExtension(url: string): Promise<void>
getSources(): Promise<Source[]>
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
getCategories(): Promise<Category[]>
createCategory(name: string): Promise<Category>
deleteCategory(id: number): Promise<void>
updateCategoryOrder(id: number, position: number): Promise<Category[]>
updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void>
updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void>
updateCategoryManga(categoryId: number): Promise<void>
getTrackers(): Promise<Tracker[]>
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
searchTracker(trackerId: string, query: string): Promise<unknown[]>
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
unlinkTracker(recordId: string): Promise<void>
fetchTrackRecord(recordId: string): Promise<void>
syncTracking(mangaId: string): Promise<void>
getServerSecurity(): Promise<ServerSecurity>
setServerAuth(input: SetServerAuthInput): Promise<void>
setSocksProxy(input: SetSocksProxyInput): Promise<void>
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
}
stopLibraryUpdate(): Promise<void>
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
clearPageCache(chapterId?: number): void
}
+43 -8
View File
@@ -1,11 +1,46 @@
export type NavPage =
| 'home' | 'library' | 'sources' | 'explore'
| 'downloads' | 'extensions' | 'history' | 'search' | 'tracking'
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
class AppStore {
navPage: NavPage = $state('home')
settingsOpen: boolean = $state(false)
searchPrefill: string = $state('')
searchQuery: string = $state('')
genreFilter: string = $state('')
scrollPositions: Map<string, number> = $state(new Map())
setNavPage(next: NavPage) { this.navPage = next }
setSettingsOpen(next: boolean) { this.settingsOpen = next }
setSearchPrefill(next: string) { this.searchPrefill = next }
setSearchQuery(next: string) { this.searchQuery = next }
setGenreFilter(next: string) { this.genreFilter = next }
saveScroll(key: string, top: number) {
const m = new Map(this.scrollPositions)
m.set(key, top)
this.scrollPositions = m
}
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0 }
}
export const app = new AppStore()
export const appState = $state({
status: 'booting' as AppStatus,
error: null as string | null,
serverUrl: '',
authenticated: false,
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
platform: 'web' as 'web' | 'tauri' | 'capacitor',
version: '',
})
status: 'booting' as AppStatus,
error: null as string | null,
serverUrl: '',
authenticated: false,
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
platform: 'web' as 'web' | 'tauri' | 'capacitor',
version: '',
})
export function setNavPage(next: NavPage) { app.setNavPage(next) }
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
export function setSearchPrefill(next: string) { app.setSearchPrefill(next) }
export function setSearchQuery(next: string) { app.setSearchQuery(next) }
export function setGenreFilter(next: string) { app.setGenreFilter(next) }
export function saveScroll(key: string, top: number) { app.saveScroll(key, top) }
export function getScroll(key: string): number { return app.getScroll(key) }
+156
View File
@@ -0,0 +1,156 @@
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
import { appState } from '$lib/state/app.svelte'
const MAX_ATTEMPTS = 15
const BG_MAX_ATTEMPTS = 60
export const boot = $state({
failed: false,
notConfigured: false,
loginRequired: false,
loginError: null as string | null,
loginBusy: false,
loginUser: '',
loginPass: '',
sessionExpired: false,
skipped: false,
})
let probeGeneration = 0
function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return
boot.failed = false
boot.skipped = false
appState.authenticated = true
appState.status = 'ready'
}
function handleAuthRequired(gen: number, authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', user: string, pass: string) {
if (gen !== probeGeneration) return
boot.failed = false
if (authMode === 'BASIC_AUTH' && user && pass) {
loginBasic(user, pass)
.then(() => { if (gen === probeGeneration) handleProbeSuccess(gen) })
.catch(() => {
if (gen !== probeGeneration) return
boot.loginUser = user
boot.loginRequired = true
appState.status = 'auth'
})
return
}
boot.loginUser = user
boot.loginRequired = true
appState.status = 'auth'
}
export function startProbe(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '',
pass = '',
) {
const gen = ++probeGeneration
boot.failed = false
boot.loginRequired = false
boot.skipped = false
appState.status = 'booting'
let tries = 0
async function probe() {
if (gen !== probeGeneration) return
tries++
const result = await probeServer()
if (gen !== probeGeneration) return
if (result === 'ok') { handleProbeSuccess(gen); return }
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
if (tries >= MAX_ATTEMPTS) { boot.failed = true; appState.status = 'error'; startBackgroundProbe(gen, authMode, user, pass); return }
setTimeout(probe, Math.min(300 + tries * 150, 1500))
}
setTimeout(probe, 100)
}
function startBackgroundProbe(
gen: number,
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
user: string,
pass: string,
) {
let bgTries = 0
async function bgProbe() {
if (gen !== probeGeneration) return
bgTries++
const result = await probeServer()
if (gen !== probeGeneration) return
if (result === 'ok') { handleProbeSuccess(gen); return }
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
if (bgTries >= BG_MAX_ATTEMPTS) return
setTimeout(bgProbe, 2000)
}
setTimeout(bgProbe, 2000)
}
export function stopProbe() {
probeGeneration++
}
export async function submitLogin(): Promise<void> {
if (!boot.loginUser.trim() || !boot.loginPass.trim()) {
boot.loginError = 'Username and password are required'
return
}
boot.loginBusy = true
boot.loginError = null
try {
if (appState.authMode === 'UI_LOGIN') {
await loginUI(boot.loginUser.trim(), boot.loginPass.trim())
} else {
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim())
}
boot.loginRequired = false
boot.sessionExpired = false
boot.skipped = false
boot.loginPass = ''
boot.loginError = null
appState.authenticated = true
appState.status = 'ready'
} catch (e: unknown) {
boot.loginError = e instanceof Error ? e.message : 'Login failed'
} finally {
boot.loginBusy = false
}
}
export function retryBoot(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '',
pass = '',
) {
boot.failed = false
boot.notConfigured = false
boot.loginRequired = false
boot.skipped = false
startProbe(authMode, user, pass)
}
export function bypassBoot(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '',
pass = '',
) {
const gen = probeGeneration
boot.loginRequired = false
boot.sessionExpired = false
boot.skipped = true
appState.status = 'ready'
startBackgroundProbe(gen, authMode, user, pass)
}
+9 -7
View File
@@ -5,12 +5,14 @@ export const downloadsState = $state({
error: null as string | null,
})
export const activeDownloads = $derived(
downloadsState.items.filter(d => d.state === 'downloading')
)
export function activeDownloads() {
return downloadsState.items.filter(d => d.state === 'downloading')
}
export const queuedDownloads = $derived(
downloadsState.items.filter(d => d.state === 'queued')
)
export function queuedDownloads() {
return downloadsState.items.filter(d => d.state === 'queued')
}
export const downloadCount = $derived(downloadsState.items.length)
export function downloadCount() {
return downloadsState.items.length
}
+2 -2
View File
@@ -18,7 +18,7 @@ export const extensionsState = $state({
browseHasMore: false,
})
export const filteredExtensions = $derived.by(() => {
export function filteredExtensions() {
let result = extensionsState.items
if (extensionsState.filter.installed) {
@@ -33,4 +33,4 @@ export const filteredExtensions = $derived.by(() => {
}
return result
})
}
+42
View File
@@ -0,0 +1,42 @@
export interface HistoryEntry {
mangaId: number
mangaTitle: string
thumbnailUrl: string
chapterId: number
chapterName: string
chapterNumber: number
pageNumber: number
readAt: number
}
export interface ReadingStats {
currentStreakDays: number
totalChaptersRead: number
totalMinutesRead: number
totalMangaRead: number
longestStreakDays: number
}
export const homeState = $state({
history: [] as HistoryEntry[],
dailyReadCounts: {} as Record<string, number>,
stats: {
currentStreakDays: 0,
totalChaptersRead: 0,
totalMinutesRead: 0,
totalMangaRead: 0,
longestStreakDays: 0,
} as ReadingStats,
heroSlots: [null, null, null, null] as [number | null, number | null, number | null, number | null],
})
export function setHeroSlot(i: 1 | 2 | 3, mangaId: number | null) {
homeState.heroSlots[i] = mangaId
}
export function recordRead(entry: HistoryEntry) {
homeState.history = [entry, ...homeState.history.filter(e => e.chapterId !== entry.chapterId)]
const dateStr = new Date(entry.readAt).toISOString().slice(0, 10)
homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1
homeState.stats.totalChaptersRead++
}
+80 -44
View File
@@ -2,52 +2,88 @@ import type { Manga } from '$lib/types'
import type { MangaStatus } from '$lib/server-adapters/types'
export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded'
export type LibraryTab = 'saved' | 'downloaded'
export const libraryState = $state({
items: [] as Manga[],
searchResults: [] as Manga[],
loading: false,
error: null as string | null,
filter: {
status: 'all' as MangaStatus | 'all',
tags: [] as string[],
unread: false,
query: '',
},
sort: 'alphabetical' as LibrarySortOption,
sortDesc: false,
view: 'grid' as 'grid' | 'list',
selected: new Set<string>(),
})
class LibraryState {
items = $state<Manga[]>([])
loading = $state(false)
error = $state<string | null>(null)
refreshing = $state(false)
export const filteredItems = $derived.by(() => {
let result = libraryState.items
tab = $state<LibraryTab>('saved')
sort = $state<LibrarySortOption>('alphabetical')
sortDesc = $state(false)
if (libraryState.filter.unread) {
result = result.filter(m => m.unreadCount > 0)
}
if (libraryState.filter.status !== 'all') {
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 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 'alphabetical':
default: return a.title.localeCompare(b.title)
}
filter = $state({
status: 'all' as MangaStatus | 'all',
unread: false,
downloaded: false,
bookmarked: false,
query: '',
})
return libraryState.sortDesc ? sorted.reverse() : sorted
})
selected = $state(new Set<number>())
selectMode = $state(false)
filteredItems = $derived.by(() => {
let result = this.tab === 'downloaded'
? this.items.filter(m => (m.downloadCount ?? 0) > 0)
: this.items.filter(m => m.inLibrary)
if (this.filter.unread) result = result.filter(m => (m.unreadCount ?? 0) > 0)
if (this.filter.downloaded) result = result.filter(m => (m.downloadCount ?? 0) > 0)
if (this.filter.bookmarked) result = result.filter(m => (m.bookmarkCount ?? 0) > 0)
if (this.filter.status !== 'all') {
result = result.filter(
m => m.status?.toUpperCase().replace(/\s+/g, '_') === this.filter.status
)
}
if (this.filter.query) {
const q = this.filter.query.toLowerCase()
result = result.filter(m => m.title.toLowerCase().includes(q))
}
const sorted = [...result].sort((a, b) => {
switch (this.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)
default: return a.title.localeCompare(b.title)
}
})
return this.sortDesc ? sorted.reverse() : sorted
})
get hasActiveFilters() {
return this.filter.status !== 'all'
|| this.filter.unread
|| this.filter.downloaded
|| this.filter.bookmarked
}
enterSelect(id?: number) {
this.selectMode = true
if (id !== undefined) this.selected = new Set([id])
}
exitSelect() {
this.selectMode = false
this.selected = new Set()
}
toggleSelect(id: number) {
const next = new Set(this.selected)
if (next.has(id)) next.delete(id); else next.add(id)
this.selected = next
if (next.size === 0) this.exitSelect()
}
selectAll() {
this.selected = new Set(this.filteredItems.map(m => m.id))
}
}
export const libraryState = new LibraryState()
+28 -15
View File
@@ -1,25 +1,38 @@
export type ToastKind = 'info' | 'success' | 'error' | 'download'
export interface Toast {
id: string
kind: ToastKind
message: string
detail?: string
id: string
kind: ToastKind
message: string
detail?: string
duration?: number
}
export const notificationsState = $state({
toasts: [] as Toast[],
})
export interface ActiveDownload {
chapterId: number
mangaId: number
progress: number
}
export function toast(kind: ToastKind, message: string, detail?: string, duration = 4000) {
const id = crypto.randomUUID()
notificationsState.toasts.push({ id, kind, message, detail, duration })
if (duration > 0) {
setTimeout(() => dismissToast(id), duration)
class NotificationStore {
toasts: Toast[] = $state([])
activeDownloads: ActiveDownload[] = $state([])
toast(toast: Omit<Toast, 'id'>) {
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5)
}
dismissToast(id: string) {
this.toasts = this.toasts.filter(x => x.id !== id)
}
setActiveDownloads(next: ActiveDownload[]) {
this.activeDownloads = next
}
}
export function dismissToast(id: string) {
notificationsState.toasts = notificationsState.toasts.filter(t => t.id !== id)
}
export const notifications = new NotificationStore()
export function toast(toast: Omit<Toast, 'id'>) { notifications.toast(toast) }
export function dismissToast(id: string) { notifications.dismissToast(id) }
export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next) }
+13 -10
View File
@@ -25,17 +25,20 @@ export const readerState = $state({
fullscreen: false,
})
export const currentPageData = $derived(
readerState.pages[readerState.currentPage] ?? null
)
export function currentPageData() {
return readerState.pages[readerState.currentPage] ?? null
}
export const progress = $derived(
readerState.pages.length > 0
export function progress() {
return readerState.pages.length > 0
? (readerState.currentPage + 1) / readerState.pages.length
: 0
)
}
export const hasPrev = $derived(readerState.currentPage > 0)
export const hasNext = $derived(
readerState.currentPage < readerState.pages.length - 1
)
export function hasPrev() {
return readerState.currentPage > 0
}
export function hasNext() {
return readerState.currentPage < readerState.pages.length - 1
}
+22 -30
View File
@@ -1,36 +1,28 @@
import type { Manga, Chapter } from '$lib/types'
export const seriesState = $state({
current: null as Manga | null,
loading: false,
error: null as string | null,
class SeriesState {
current = $state<Manga | null>(null)
loading = $state(false)
error = $state<string | null>(null)
chapters: [] as Chapter[],
chaptersLoading: false,
chaptersError: null as string | null,
chapters = $state<Chapter[]>([])
chaptersLoading = $state(false)
chaptersError = $state<string | null>(null)
chapterFilter: {
unread: false,
downloaded: false,
query: '',
},
chapterSortDesc: true,
})
chapterSortDesc = $state(true)
chapterFilter = $state({ unread: false, downloaded: false, query: '' })
export const filteredChapters = $derived.by(() => {
let result = seriesState.chapters
filteredChapters = $derived.by(() => {
let result = this.chapters
if (this.chapterFilter.unread) result = result.filter(c => !c.read)
if (this.chapterFilter.downloaded) result = result.filter(c => c.downloaded)
if (this.chapterFilter.query) {
const q = this.chapterFilter.query.toLowerCase()
result = result.filter(c => c.name.toLowerCase().includes(q))
}
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
return this.chapterSortDesc ? sorted.reverse() : sorted
})
}
if (seriesState.chapterFilter.unread) {
result = result.filter(c => !c.read)
}
if (seriesState.chapterFilter.downloaded) {
result = result.filter(c => c.downloaded)
}
if (seriesState.chapterFilter.query) {
const q = seriesState.chapterFilter.query.toLowerCase()
result = result.filter(c => c.name.toLowerCase().includes(q))
}
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
return seriesState.chapterSortDesc ? sorted.reverse() : sorted
})
export const seriesState = new SeriesState()
+38
View File
@@ -0,0 +1,38 @@
import type { Settings } from '$lib/types/settings'
import { DEFAULT_SETTINGS } from '$lib/types/settings'
const KEY = 'moku_settings'
function load(): Settings {
try {
const raw = localStorage.getItem(KEY)
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }
} catch {}
return { ...DEFAULT_SETTINGS }
}
function save(s: Settings) {
try { localStorage.setItem(KEY, JSON.stringify(s)) } catch {}
}
export const settingsState = $state({ settings: load() })
if (typeof document !== 'undefined') {
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
}
export function updateSettings(patch: Partial<Settings>) {
Object.assign(settingsState.settings, patch)
save(settingsState.settings)
if (typeof document !== 'undefined') {
if (patch.uiZoom !== undefined) {
document.documentElement.style.zoom = String(patch.uiZoom)
}
}
}
export function resetSettings() {
settingsState.settings = { ...DEFAULT_SETTINGS }
save(settingsState.settings)
}
+53 -5
View File
@@ -1,8 +1,56 @@
import type { Tracker } from '$lib/types'
import type { Tracker, TrackRecord } from '$lib/types'
import type { Chapter } from '$lib/types/chapter'
import type { MangaPrefs } from '$lib/types/settings'
export const trackingState = $state({
trackers: [] as Tracker[],
loading: false,
error: null as string | null,
syncing: false,
trackers: [] as Tracker[],
loading: false,
error: null as string | null,
syncing: false,
records: [] as unknown[],
recordsLoading: false,
recordsError: null as string | null,
searchResults: [] as unknown[],
searchLoading: false,
searchError: null as string | null,
})
export async function syncBackFromTracker(
records: TrackRecord[],
chapters: Chapter[],
opts: {
threshold: number | null
respectScanlatorFilter: boolean
chapterPrefs: Partial<MangaPrefs>
},
markChaptersRead: (ids: string[], read: boolean) => Promise<void>,
): Promise<Chapter[]> {
const marked: Chapter[] = []
const activeScanlators: string[] | null =
opts.respectScanlatorFilter && opts.chapterPrefs.scanlatorFilter?.length
? opts.chapterPrefs.scanlatorFilter
: null
for (const record of records) {
const lastRead = record.lastChapterRead ?? 0
if (lastRead <= 0) continue
const toMark = chapters.filter(ch => {
if (ch.read) return false
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
return opts.threshold !== null
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - opts.threshold
: ch.chapterNumber <= lastRead
})
if (toMark.length === 0) continue
await markChaptersRead(toMark.map(ch => String(ch.id)), true)
marked.push(...toMark)
}
return marked
}
+139
View File
@@ -0,0 +1,139 @@
[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;
}
[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;
}
+20 -16
View File
@@ -1,19 +1,23 @@
export interface Chapter {
id: number
name: string
id: number
name: string
chapterNumber: number
sourceOrder: number
read: boolean
downloaded: boolean
bookmarked: boolean
pageCount: number
mangaId: number
fetchedAt?: string
uploadDate?: string | null
realUrl?: string | null
url?: string
sourceOrder: number
read: boolean
downloaded: boolean
bookmarked: boolean
pageCount: number
mangaId: number
fetchedAt?: string
uploadDate?: string | null
realUrl?: string | null
lastPageRead?: number
lastReadAt?: string
scanlator?: string | null
manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null
}
lastReadAt?: string
scanlator?: string | null
manga?: {
id: number
title: string
thumbnailUrl: string
inLibrary: boolean
} | null
}
+17 -18
View File
@@ -1,24 +1,23 @@
export interface Source {
id: string
name: string
lang: string
displayName: string
iconUrl: string
isNsfw: boolean
isConfigurable: boolean
supportsLatest: boolean
baseUrl?: string | null
id: string
name: string
lang: string
displayName: string
iconUrl: string
isNsfw: boolean
isConfigurable: boolean
supportsLatest: boolean
}
export interface Extension {
apkName: string
pkgName: string
name: string
lang: string
id: string
apkName: string
pkgName: string
name: string
lang: string
versionName: string
isInstalled: boolean
isObsolete: boolean
hasUpdate: boolean
iconUrl: string
id: string
}
isObsolete: boolean
hasUpdate: boolean
iconUrl: string
}
+4 -539
View File
@@ -1,539 +1,4 @@
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 { Manga, MangaDetail, Category, ChapterRef } from './manga'
export type { Chapter } from './chapter'
export type { Extension, Source } from './extension'
export type { Tracker, TrackRecord, TrackerStatus } from './tracking'
+44 -53
View File
@@ -1,13 +1,3 @@
export interface Category {
id: number
name: string
order: number
default: boolean
includeInUpdate: string
includeInDownload: string
mangas?: { nodes: Manga[] }
}
export interface ChapterRef {
id: number
chapterNumber: number
@@ -15,48 +5,49 @@ export interface ChapterRef {
lastPageRead?: number
}
export interface Manga {
id: number
title: string
thumbnailUrl: string
inLibrary: boolean
initialized?: boolean
downloadCount?: number
unreadCount?: number
bookmarkCount?: number
hasDuplicateChapters?: boolean
chapters?: { totalCount: number }
description?: string | null
status?: string | null
author?: string | null
artist?: string | null
genre?: string[]
tags?: string[]
realUrl?: string | null
url?: string
sourceId?: string
inLibraryAt?: string | null
lastFetchedAt?: string | null
chaptersLastFetchedAt?: string | null
thumbnailUrlLastFetched?: string | null
addedAt?: number
lastReadAt?: number
age?: string | null
chaptersAge?: string | null
updateStrategy?: 'ALWAYS_UPDATE' | 'ONLY_FETCH_ONCE'
latestFetchedChapter?: ChapterRef | null
latestUploadedChapter?: ChapterRef | null
latestReadChapter?: ChapterRef | null
lastReadChapter?: ChapterRef | null
firstUnreadChapter?: ChapterRef | null
highestNumberedChapter?: ChapterRef | null
source?: { id: string; name: string; displayName: string } | null
export interface Category {
id: number
name: string
order: number
default: boolean
includeInUpdate: boolean
includeInDownload: boolean
mangas?: Manga[]
}
export interface MangaDetail extends Manga {
description: string | null
author: string | null
artist: string | null
status: string | null
genre: string[]
}
export interface Manga {
id: number
title: string
thumbnailUrl: string
inLibrary: boolean
downloadCount?: number
unreadCount?: number
bookmarkCount?: number
description?: string | null
status?: string | null
author?: string | null
artist?: string | null
genre?: string[]
tags?: string[]
realUrl?: string | null
sourceId?: string
inLibraryAt?: string | null
lastFetchedAt?: string | null
chaptersLastFetchedAt?: string | null
thumbnailUrlLastFetched?: string | null
addedAt?: number
lastReadAt?: number
updateStrategy?: 'ALWAYS_UPDATE' | 'ONLY_FETCH_ONCE'
latestFetchedChapter?: ChapterRef | null
latestUploadedChapter?: ChapterRef | null
lastReadChapter?: ChapterRef | null
firstUnreadChapter?: ChapterRef | null
highestNumberedChapter?: ChapterRef | null
source?: { id: string; name: string; displayName: string } | null
}
+135 -283
View File
@@ -1,312 +1,164 @@
import type { Keybinds } from "$lib/core/keybinds/defaultBinds";
import { DEFAULT_KEYBINDS, 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 ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc";
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
export type ContentLevel = "strict" | "moderate" | "unrestricted";
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 LibrarySortMode =
| "az" | "unreadCount" | "totalChapters"
| "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded";
| 'az' | 'unreadCount' | 'totalChapters'
| 'recentlyAdded' | 'recentlyRead' | 'latestFetched' | 'latestUploaded'
export type LibrarySortDir = "asc" | "desc";
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 LibrarySortDir = 'asc' | 'desc'
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 interface ThemeTokens {
"bg-void": string;
"bg-base": string;
"bg-surface": string;
"bg-raised": string;
"bg-overlay": string;
"bg-subtle": string;
"border-dim": string;
"border-base": string;
"border-strong": string;
"border-focus": string;
"text-primary": string;
"text-secondary": string;
"text-muted": string;
"text-faint": string;
"text-disabled": string;
"accent": string;
"accent-dim": string;
"accent-muted": string;
"accent-fg": string;
"accent-bright": string;
"color-error": string;
"color-error-bg": string;
"color-success": string;
"color-info": string;
"color-info-bg": string;
'bg-void': string; 'bg-base': string; 'bg-surface': string
'bg-raised': string; 'bg-overlay': string; 'bg-subtle': string
'border-dim': string; 'border-base': string; 'border-strong': string; 'border-focus': string
'text-primary': string; 'text-secondary': string; 'text-muted': string
'text-faint': string; 'text-disabled': string
'accent': string; 'accent-dim': string; 'accent-muted': string
'accent-fg': string; 'accent-bright': string
'color-error': string; 'color-error-bg': string
'color-success': string; 'color-info': string; 'color-info-bg': string
}
export interface CustomTheme {
id: string;
name: string;
tokens: ThemeTokens;
}
export interface CustomTheme { id: string; name: string; tokens: ThemeTokens }
export const DEFAULT_THEME_TOKENS: ThemeTokens = {
"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",
};
'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',
}
export interface MangaPrefs {
autoDownload: boolean;
downloadAhead: number;
deleteOnRead: boolean;
deleteDelayHours: number;
maxKeepChapters: number;
pauseUpdates: boolean;
refreshInterval: "global" | "daily" | "weekly" | "manual";
preferredScanlator: string;
scanlatorFilter: string[];
scanlatorBlacklist: string[];
scanlatorForce: boolean;
autoDownloadScanlators: string[];
coverUrl?: string;
autoDownload: boolean; downloadAhead: number; deleteOnRead: boolean
deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean
refreshInterval: 'global' | 'daily' | 'weekly' | 'manual'
preferredScanlator: string; scanlatorFilter: string[]
scanlatorBlacklist: string[]; scanlatorForce: boolean
autoDownloadScanlators: string[]
coverUrl?: string
}
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false,
downloadAhead: 0,
deleteOnRead: false,
deleteDelayHours: 0,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: "global",
preferredScanlator: "",
scanlatorFilter: [],
scanlatorBlacklist: [],
scanlatorForce: false,
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
refreshInterval: 'global', preferredScanlator: '', scanlatorFilter: [],
scanlatorBlacklist: [], scanlatorForce: false,
autoDownloadScanlators: [],
};
}
export interface ReaderSettings {
pageStyle: PageStyle;
fitMode: FitMode;
readingDirection: ReadingDirection;
readerZoom: number;
pageGap: boolean;
optimizeContrast: boolean;
offsetDoubleSpreads: boolean;
barPosition?: "top" | "left" | "right";
pageStyle: PageStyle
fitMode: FitMode
readingDirection: ReadingDirection
readerZoom: number
pageGap: boolean
optimizeContrast: boolean
offsetDoubleSpreads: boolean
barPosition?: 'top' | 'left' | 'right'
}
export interface ReaderPreset {
id: string;
name: string;
settings: ReaderSettings;
id: string
name: string
settings: ReaderSettings
}
export interface Settings {
pageStyle: PageStyle;
readingDirection: ReadingDirection;
fitMode: FitMode;
readerZoom: number;
pageGap: boolean;
optimizeContrast: boolean;
offsetDoubleSpreads: boolean;
preloadPages: number;
autoMarkRead: boolean;
autoNextChapter: boolean;
libraryCropCovers: boolean;
libraryPageSize: number;
contentLevel: ContentLevel;
sourceOverridesEnabled: boolean;
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
discordRpc: boolean;
chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode;
chapterPageSize: number;
uiZoom: number;
compactSidebar: boolean;
gpuAcceleration: boolean;
serverUrl: string;
serverBinary: string;
serverBinaryArgs: string;
autoStartServer: boolean;
suwayomiWebUI: boolean;
preferredExtensionLang: string;
keybinds: Keybinds;
idleTimeoutMin?: number;
splashCards?: boolean;
storageLimitGb: number | null;
markReadOnNext: boolean;
readerDebounceMs: number;
autoBookmark: boolean;
theme: Theme;
libraryBranches: boolean;
renderLimit: number;
heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>;
mangaPrefs: Record<number, Partial<MangaPrefs>>;
serverAuthUser: string;
serverAuthPass: string;
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
socksProxyEnabled: boolean;
socksProxyHost: string;
socksProxyPort: string;
socksProxyVersion: number;
socksProxyUsername: string;
socksProxyPassword: string;
flareSolverrEnabled: boolean;
flareSolverrUrl: string;
flareSolverrTimeout: number;
flareSolverrSessionName: string;
flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
appLockEnabled: boolean;
appLockPin: string;
customThemes: CustomTheme[];
hiddenCategoryIds: number[];
defaultLibraryCategoryId: number | null;
savedIsDefaultCategory: boolean;
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>;
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
maxPageWidth?: number;
uiScale?: number;
extraScanDirs: string[];
serverDownloadsPath: string;
serverLocalSourcePath: string;
qolAnimations: boolean;
libraryStatsAlways: boolean;
pinnedSourceIds: string[];
readerPresets: ReaderPreset[];
mangaReaderSettings: Record<number, ReaderSettings>;
barPosition?: "top" | "left" | "right";
trackerSyncBack: boolean;
trackerSyncBackThreshold: number | null;
trackerRespectScanlatorFilter: boolean;
pinchZoom?: boolean;
autoLinkOnOpen: boolean;
downloadToastsEnabled: boolean;
downloadAutoRetry: boolean;
hiddenLibraryTabs: string[];
libraryPinnedTabOrder: string[];
autoScroll?: boolean;
autoScrollSpeed?: number;
disableAutoComplete: boolean;
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode
readerZoom: number; pageGap: boolean; optimizeContrast: boolean
offsetDoubleSpreads: boolean; preloadPages: number
autoMarkRead: boolean; autoNextChapter: boolean
libraryCropCovers: boolean; libraryPageSize: number
contentLevel: ContentLevel; sourceOverridesEnabled: boolean
nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[]
discordRpc: boolean
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean
serverUrl: string; serverBinary: string; serverBinaryArgs: string; autoStartServer: boolean; suwayomiWebUI: boolean
preferredExtensionLang: string; keybinds: Keybinds
idleTimeoutMin?: number; splashCards?: boolean
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number
autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number
heroSlots: (number | null)[]; mangaLinks: Record<number, number[]>
mangaPrefs: Record<number, Partial<MangaPrefs>>
serverAuthUser: string; serverAuthPass: string
serverAuthMode: 'NONE' | 'BASIC_AUTH' | 'SIMPLE_LOGIN' | 'UI_LOGIN'
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string
socksProxyVersion: number; socksProxyUsername: string; socksProxyPassword: string
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number
flareSolverrSessionName: string; flareSolverrSessionTtl: number; flareSolverrAsResponseFallback: boolean
appLockEnabled: boolean; appLockPin: string
customThemes: CustomTheme[]; hiddenCategoryIds: number[]
defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>
libraryTabStatus: Record<string, LibraryStatusFilter>
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>
maxPageWidth?: number; uiScale?: number
extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string
qolAnimations: boolean; libraryStatsAlways: boolean; pinnedSourceIds: string[]
readerPresets: ReaderPreset[]; mangaReaderSettings: Record<number, ReaderSettings>
barPosition?: 'top' | 'left' | 'right'
trackerSyncBack: boolean; trackerSyncBackThreshold: number | null; trackerRespectScanlatorFilter: boolean
pinchZoom?: boolean; autoLinkOnOpen: boolean
downloadToastsEnabled: boolean; downloadAutoRetry: boolean
hiddenLibraryTabs: string[]; libraryPinnedTabOrder: string[]
autoScroll?: boolean; autoScrollSpeed?: number; disableAutoComplete: boolean
systemThemeSync?: boolean; systemThemeDark?: string; systemThemeLight?: string
closeAction?: 'ask' | 'tray' | 'quit'
overlayBars?: boolean; tapToToggleBar?: boolean
automationEnabled?: boolean; automationEnforceGlobal?: boolean
automationDefaults?: Partial<MangaPrefs>
libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean
}
export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip",
readingDirection: "ltr",
fitMode: "width",
readerZoom: 1.0,
pageGap: true,
optimizeContrast: false,
offsetDoubleSpreads: false,
preloadPages: 3,
autoMarkRead: true,
autoNextChapter: true,
libraryCropCovers: true,
libraryPageSize: 48,
contentLevel: "strict",
sourceOverridesEnabled: false,
nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [],
pageStyle: 'longstrip', readingDirection: 'ltr', fitMode: 'width',
readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
libraryCropCovers: true, libraryPageSize: 48,
contentLevel: 'strict', sourceOverridesEnabled: false,
nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
discordRpc: false,
chapterSortDir: "desc",
chapterSortMode: "source",
chapterPageSize: 25,
uiZoom: 1.0,
compactSidebar: false,
gpuAcceleration: true,
serverUrl: "http://localhost:4567",
serverBinary: "",
serverBinaryArgs: "",
autoStartServer: true,
suwayomiWebUI: false,
preferredExtensionLang: "en",
keybinds: {} as Keybinds,
idleTimeoutMin: 5,
splashCards: true,
storageLimitGb: null,
markReadOnNext: true,
readerDebounceMs: 120,
autoBookmark: true,
theme: "dark",
libraryBranches: true,
renderLimit: 48,
heroSlots: [null, null, null, null],
mangaLinks: {},
mangaPrefs: {},
serverAuthUser: "",
serverAuthPass: "",
serverAuthMode: "NONE",
socksProxyEnabled: false,
socksProxyHost: "",
socksProxyPort: "1080",
socksProxyVersion: 5,
socksProxyUsername: "",
socksProxyPassword: "",
flareSolverrEnabled: false,
flareSolverrUrl: "http://localhost:8191",
flareSolverrTimeout: 60,
flareSolverrSessionName: "moku",
flareSolverrSessionTtl: 15,
flareSolverrAsResponseFallback: false,
appLockEnabled: false,
appLockPin: "",
customThemes: [],
hiddenCategoryIds: [],
defaultLibraryCategoryId: null,
chapterSortDir: 'desc', chapterSortMode: 'source', chapterPageSize: 25,
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
serverUrl: 'http://localhost:4567', serverBinary: '', serverBinaryArgs: '', autoStartServer: true, suwayomiWebUI: false,
preferredExtensionLang: 'en', keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
theme: 'dark', libraryBranches: true, renderLimit: 48,
heroSlots: [null, null, null, null], mangaLinks: {}, mangaPrefs: {},
serverAuthUser: '', serverAuthPass: '', serverAuthMode: 'NONE',
socksProxyEnabled: false, socksProxyHost: '', socksProxyPort: '1080',
socksProxyVersion: 5, socksProxyUsername: '', socksProxyPassword: '',
flareSolverrEnabled: false, flareSolverrUrl: 'http://localhost:8191',
flareSolverrTimeout: 60, flareSolverrSessionName: 'moku',
flareSolverrSessionTtl: 15, flareSolverrAsResponseFallback: false,
appLockEnabled: false, appLockPin: '',
customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null,
savedIsDefaultCategory: false,
libraryTabSort: {},
libraryTabStatus: {},
libraryTabFilters: {},
extraScanDirs: [],
serverDownloadsPath: "",
serverLocalSourcePath: "",
qolAnimations: true,
libraryStatsAlways: false,
pinnedSourceIds: [],
readerPresets: [],
mangaReaderSettings: {},
trackerSyncBack: false,
trackerSyncBackThreshold: 20,
trackerRespectScanlatorFilter: true,
pinchZoom: false,
autoLinkOnOpen: false,
downloadToastsEnabled: true,
downloadAutoRetry: false,
hiddenLibraryTabs: [],
libraryPinnedTabOrder: [],
autoScroll: false,
autoScrollSpeed: 5,
disableAutoComplete: false,
};
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
extraScanDirs: [], serverDownloadsPath: '', serverLocalSourcePath: '',
qolAnimations: true, libraryStatsAlways: false, pinnedSourceIds: [],
readerPresets: [], mangaReaderSettings: {},
trackerSyncBack: false, trackerSyncBackThreshold: 20, trackerRespectScanlatorFilter: true,
pinchZoom: false, autoLinkOnOpen: false,
downloadToastsEnabled: true, downloadAutoRetry: false,
hiddenLibraryTabs: [], libraryPinnedTabOrder: [],
autoScroll: false, autoScrollSpeed: 5, disableAutoComplete: false,
}

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