diff --git a/index.html b/index.html index d21faea..9b0c1df 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Moku -
- +
+ - \ No newline at end of file + diff --git a/package.json b/package.json index f974eee..34fe5e4 100644 --- a/package.json +++ b/package.json @@ -1,41 +1,24 @@ { "name": "moku", - "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "vite build", "preview": "vite preview", - "tauri": "tauri", - "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json", - "tauri:build": "tauri build" + "tauri": "tauri" }, "dependencies": { - "@phosphor-icons/react": "^2.1.10", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-progress": "^1.1.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2.0.0", - "@tauri-apps/plugin-shell": "~2", "clsx": "^2.1.1", - "lucide-react": "^0.575.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.0", - "zustand": "^5.0.0" + "svelte-spa-router": "^4.0.1" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.4", "@tauri-apps/cli": "^2.0.0", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.40", - "tailwindcss": "^3.4.7", - "typescript": "^5.5.3", - "vite": "^5.4.0" + "svelte": "^5.0.0", + "svelte-check": "^3.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afed34a..3a5aa9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,166 +8,37 @@ importers: .: dependencies: - '@phosphor-icons/react': - specifier: ^2.1.10 - version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dropdown-menu': - specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-progress': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-scroll-area': - specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tooltip': - specifier: ^1.2.8 - version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tanstack/react-virtual': - specifier: ^3.13.18 - version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tauri-apps/api': specifier: ^2.0.0 version: 2.10.1 - '@tauri-apps/plugin-shell': - specifier: ~2 - version: 2.3.5 clsx: specifier: ^2.1.1 version: 2.1.1 - lucide-react: - specifier: ^0.575.0 - version: 0.575.0(react@18.3.1) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - react-router-dom: - specifier: ^6.26.0 - version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - zustand: - specifier: ^5.0.0 - version: 5.0.11(@types/react@18.3.28)(react@18.3.1) + svelte-spa-router: + specifier: ^4.0.1 + version: 4.0.2 devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^4.0.4 + version: 4.0.4(svelte@5.54.0)(vite@5.4.21) '@tauri-apps/cli': specifier: ^2.0.0 - version: 2.10.0 - '@types/react': - specifier: ^18.3.3 - version: 18.3.28 - '@types/react-dom': - specifier: ^18.3.0 - version: 18.3.7(@types/react@18.3.28) - '@vitejs/plugin-react': - specifier: ^4.3.1 - version: 4.7.0(vite@5.4.21) - autoprefixer: - specifier: ^10.4.20 - version: 10.4.24(postcss@8.5.6) - postcss: - specifier: ^8.4.40 - version: 8.5.6 - tailwindcss: - specifier: ^3.4.7 - version: 3.4.19 + version: 2.10.1 + svelte: + specifier: ^5.0.0 + version: 5.54.0 + svelte-check: + specifier: ^3.0.0 + version: 3.8.6(postcss@8.5.8)(svelte@5.54.0) typescript: - specifier: ^5.5.3 + specifier: ^5.0.0 version: 5.9.3 vite: - specifier: ^5.4.0 + specifier: ^5.0.0 version: 5.4.21 packages: - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -306,21 +177,6 @@ packages: cpu: [x64] os: [win32] - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -337,672 +193,272 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@phosphor-icons/react@2.1.10': - resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} - engines: {node: '>=10'} - peerDependencies: - react: '>= 16.8' - react-dom: '>= 16.8' - - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.3': - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-progress@1.1.8': - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.58.0': - resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.58.0': - resolution: {integrity: sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.58.0': - resolution: {integrity: sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.58.0': - resolution: {integrity: sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.58.0': - resolution: {integrity: sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.58.0': - resolution: {integrity: sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.58.0': - resolution: {integrity: sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.58.0': - resolution: {integrity: sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.58.0': - resolution: {integrity: sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.58.0': - resolution: {integrity: sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.58.0': - resolution: {integrity: sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.58.0': - resolution: {integrity: sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==} + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.58.0': - resolution: {integrity: sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==} + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.58.0': - resolution: {integrity: sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==} + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.58.0': - resolution: {integrity: sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==} + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.58.0': - resolution: {integrity: sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==} + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.58.0': - resolution: {integrity: sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==} + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.58.0': - resolution: {integrity: sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.58.0': - resolution: {integrity: sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.58.0': - resolution: {integrity: sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.58.0': - resolution: {integrity: sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==} + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.58.0': - resolution: {integrity: sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==} + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.58.0': - resolution: {integrity: sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.58.0': - resolution: {integrity: sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.58.0': - resolution: {integrity: sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==} + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] - '@tanstack/react-virtual@3.13.18': - resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + acorn: ^8.9.0 - '@tanstack/virtual-core@3.13.18': - resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + '@sveltejs/vite-plugin-svelte-inspector@3.0.1': + resolution: {integrity: sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^4.0.0-next.0||^4.0.0 + svelte: ^5.0.0-next.96 || ^5.0.0 + vite: ^5.0.0 + + '@sveltejs/vite-plugin-svelte@4.0.4': + resolution: {integrity: sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0-next.96 || ^5.0.0 + vite: ^5.0.0 '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} - '@tauri-apps/cli-darwin-arm64@2.10.0': - resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} + '@tauri-apps/cli-darwin-arm64@2.10.1': + resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.10.0': - resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} + '@tauri-apps/cli-darwin-x64@2.10.1': + resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': + resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} + '@tauri-apps/cli-linux-arm64-gnu@2.10.1': + resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} + '@tauri-apps/cli-linux-arm64-musl@2.10.1': + resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': + resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} + '@tauri-apps/cli-linux-x64-gnu@2.10.1': + resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-musl@2.10.0': - resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} + '@tauri-apps/cli-linux-x64-musl@2.10.1': + resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} + '@tauri-apps/cli-win32-arm64-msvc@2.10.1': + resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} + '@tauri-apps/cli-win32-ia32-msvc@2.10.1': + resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} + '@tauri-apps/cli-win32-x64-msvc@2.10.1': + resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.10.0': - resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} + '@tauri-apps/cli@2.10.1': + resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==} engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-shell@2.3.5': - resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/react@18.3.28': - resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} - engines: {node: '>=6.0.0'} - hasBin: true + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - - caniuse-lite@1.0.30001770: - resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} @@ -1012,20 +468,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -1036,86 +480,65 @@ packages: supports-color: optional: true - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} - electron-to-chromium@1.5.302: - resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1128,79 +551,56 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.575.0: - resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1208,228 +608,106 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.8: - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.7.2: - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - - react-style-singleton@2.2.3: - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} - - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} + regexparam@2.0.2: + resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} + engines: {node: '>=8'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@4.58.0: - resolution: {integrity: sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + sorcery@0.11.1: + resolution: {integrity: sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==} hasBin: true source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + svelte-check@3.8.6: + resolution: {integrity: sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==} hasBin: true + peerDependencies: + svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} + svelte-preprocess@5.1.4: + resolution: {integrity: sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true - tailwindcss@3.4.19: - resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} - engines: {node: '>=14.0.0'} - hasBin: true + svelte-spa-router@4.0.2: + resolution: {integrity: sha512-T1WYYk+ymwCr5m5U+n91k4dRAT6cw5HgmoPaI/TpKgAmuugymFoSBlfzkcKIK83QH4H8gUMn4tdQ0B9enFBM6g==} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} + svelte@5.54.0: + resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==} + engines: {node: '>=18'} to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - use-callback-ref@1.3.3: - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.3: - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1461,143 +739,22 @@ packages: terser: optional: true - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - zustand@5.0.11: - resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} - engines: {node: '>=12.20.0'} + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: + vite: optional: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + snapshots: - '@alloc/quick-lru@5.2.0': {} - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1667,23 +824,6 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true - '@floating-ui/core@1.7.4': - dependencies: - '@floating-ui/utils': 0.2.10 - - '@floating-ui/dom@1.7.5': - dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 - - '@floating-ui/react-dom@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/dom': 1.7.5 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@floating-ui/utils@0.2.10': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1703,570 +843,189 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@phosphor-icons/react@2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@radix-ui/number@1.1.1': {} - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-context@1.1.3(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/rect': 1.1.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/rect@1.1.1': {} - - '@remix-run/router@1.23.2': {} - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.58.0': + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.58.0': + '@rollup/rollup-android-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.58.0': + '@rollup/rollup-darwin-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.58.0': + '@rollup/rollup-darwin-x64@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.58.0': + '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.58.0': + '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.58.0': + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.58.0': + '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.58.0': + '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.58.0': + '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.58.0': + '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.58.0': + '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.58.0': + '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.58.0': + '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.58.0': + '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.58.0': + '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.58.0': + '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.58.0': + '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.58.0': + '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-openbsd-x64@4.58.0': + '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-openharmony-arm64@4.58.0': + '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.58.0': + '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.58.0': + '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.58.0': + '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.58.0': + '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true - '@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': dependencies: - '@tanstack/virtual-core': 3.13.18 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + acorn: 8.16.0 - '@tanstack/virtual-core@3.13.18': {} - - '@tauri-apps/api@2.10.1': {} - - '@tauri-apps/cli-darwin-arm64@2.10.0': - optional: true - - '@tauri-apps/cli-darwin-x64@2.10.0': - optional: true - - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - optional: true - - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - optional: true - - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - optional: true - - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - optional: true - - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - optional: true - - '@tauri-apps/cli-linux-x64-musl@2.10.0': - optional: true - - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - optional: true - - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - optional: true - - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - optional: true - - '@tauri-apps/cli@2.10.0': - optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.0 - '@tauri-apps/cli-darwin-x64': 2.10.0 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 - '@tauri-apps/cli-linux-arm64-musl': 2.10.0 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-musl': 2.10.0 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 - '@tauri-apps/cli-win32-x64-msvc': 2.10.0 - - '@tauri-apps/plugin-shell@2.3.5': + '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.54.0)(vite@5.4.21))(svelte@5.54.0)(vite@5.4.21)': dependencies: - '@tauri-apps/api': 2.10.1 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/estree@1.0.8': {} - - '@types/prop-types@15.7.15': {} - - '@types/react-dom@18.3.7(@types/react@18.3.28)': - dependencies: - '@types/react': 18.3.28 - - '@types/react@18.3.28': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - - '@vitejs/plugin-react@4.7.0(vite@5.4.21)': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.54.0)(vite@5.4.21) + debug: 4.4.3 + svelte: 5.54.0 vite: 5.4.21 transitivePeerDependencies: - supports-color - any-promise@1.3.0: {} + '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.54.0)(vite@5.4.21)': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.54.0)(vite@5.4.21))(svelte@5.54.0)(vite@5.4.21) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.54.0 + vite: 5.4.21 + vitefu: 1.1.2(vite@5.4.21) + transitivePeerDependencies: + - supports-color + + '@tauri-apps/api@2.10.1': {} + + '@tauri-apps/cli-darwin-arm64@2.10.1': + optional: true + + '@tauri-apps/cli-darwin-x64@2.10.1': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.10.1': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.10.1': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.10.1': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.10.1': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.10.1': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.10.1': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.10.1': + optional: true + + '@tauri-apps/cli@2.10.1': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.10.1 + '@tauri-apps/cli-darwin-x64': 2.10.1 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1 + '@tauri-apps/cli-linux-arm64-gnu': 2.10.1 + '@tauri-apps/cli-linux-arm64-musl': 2.10.1 + '@tauri-apps/cli-linux-riscv64-gnu': 2.10.1 + '@tauri-apps/cli-linux-x64-gnu': 2.10.1 + '@tauri-apps/cli-linux-x64-musl': 2.10.1 + '@tauri-apps/cli-win32-arm64-msvc': 2.10.1 + '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 + '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + + '@types/estree@1.0.8': {} + + '@types/pug@2.0.10': {} + + '@types/trusted-types@2.0.7': {} + + '@typescript-eslint/types@8.57.1': {} + + acorn@8.16.0: {} anymatch@3.1.3: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - arg@5.0.2: {} + aria-query@5.3.1: {} - aria-hidden@1.2.6: - dependencies: - tslib: 2.8.1 + axobject-query@4.1.0: {} - autoprefixer@10.4.24(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001770 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - baseline-browser-mapping@2.10.0: {} + balanced-match@1.0.2: {} binary-extensions@2.3.0: {} + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + braces@3.0.3: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001770 - electron-to-chromium: 1.5.302 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - - camelcase-css@2.0.1: {} - - caniuse-lite@1.0.30001770: {} + buffer-crc32@1.0.0: {} chokidar@3.6.0: dependencies: @@ -2282,25 +1041,19 @@ snapshots: clsx@2.1.1: {} - commander@4.1.1: {} - - convert-source-map@2.0.0: {} - - cssesc@3.0.0: {} - - csstype@3.2.3: {} + concat-map@0.0.1: {} debug@4.4.3: dependencies: ms: 2.1.3 - detect-node-es@1.1.0: {} + deepmerge@4.3.1: {} - didyoumean@1.2.2: {} + detect-indent@6.1.0: {} - dlv@1.1.3: {} + devalue@5.6.4: {} - electron-to-chromium@1.5.302: {} + es6-promise@3.3.1: {} esbuild@0.21.5: optionalDependencies: @@ -2328,59 +1081,48 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - escalade@3.2.0: {} + esm-env@1.2.2: {} - fast-glob@3.3.3: + esrap@2.2.4: dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.57.1 fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - fraction.js@5.3.4: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-nonce@1.0.1: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: + glob@7.2.3: dependencies: - is-glob: 4.0.3 + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 - hasown@2.0.2: + graceful-fs@4.2.11: {} + + inflight@1.0.6: dependencies: - function-bind: 1.1.2 + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2389,313 +1131,192 @@ snapshots: is-number@7.0.0: {} - jiti@1.21.7: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json5@2.2.3: {} - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - loose-envify@1.4.0: + is-reference@3.0.3: dependencies: - js-tokens: 4.0.0 + '@types/estree': 1.0.8 - lru-cache@5.1.1: + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + magic-string@0.30.21: dependencies: - yallist: 3.1.1 + '@jridgewell/sourcemap-codec': 1.5.5 - lucide-react@0.575.0(react@18.3.1): + min-indent@1.0.1: {} + + minimatch@3.1.5: dependencies: - react: 18.3.1 + brace-expansion: 1.1.12 - merge2@1.4.1: {} + minimist@1.2.8: {} - micromatch@4.0.8: + mkdirp@0.5.6: dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + minimist: 1.2.8 + + mri@1.2.0: {} ms@2.1.3: {} - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - nanoid@3.3.11: {} - node-releases@2.0.27: {} - normalize-path@3.0.0: {} - object-assign@4.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 - object-hash@3.0.0: {} - - path-parse@1.0.7: {} + path-is-absolute@1.0.1: {} picocolors@1.1.1: {} picomatch@2.3.1: {} - picomatch@4.0.3: {} - - pify@2.3.0: {} - - pirates@4.0.7: {} - - postcss-import@15.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.11 - - postcss-js@4.1.0(postcss@8.5.6): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.6 - - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 1.21.7 - postcss: 8.5.6 - - postcss-nested@6.2.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - queue-microtask@1.2.3: {} - - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - - react-refresh@0.17.0: {} - - react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1): - dependencies: - react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - react-remove-scroll@2.7.2(@types/react@18.3.28)(react@18.3.1): - dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.28)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.28)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - - react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.3(react@18.3.1) - - react-router@6.30.3(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - - react-style-singleton@2.2.3(@types/react@18.3.28)(react@18.3.1): - dependencies: - get-nonce: 1.0.1 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - readdirp@3.6.0: dependencies: picomatch: 2.3.1 - resolve@1.22.11: + regexparam@2.0.2: {} + + rimraf@2.7.1: dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 + glob: 7.2.3 - reusify@1.1.0: {} - - rollup@4.58.0: + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.58.0 - '@rollup/rollup-android-arm64': 4.58.0 - '@rollup/rollup-darwin-arm64': 4.58.0 - '@rollup/rollup-darwin-x64': 4.58.0 - '@rollup/rollup-freebsd-arm64': 4.58.0 - '@rollup/rollup-freebsd-x64': 4.58.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.58.0 - '@rollup/rollup-linux-arm-musleabihf': 4.58.0 - '@rollup/rollup-linux-arm64-gnu': 4.58.0 - '@rollup/rollup-linux-arm64-musl': 4.58.0 - '@rollup/rollup-linux-loong64-gnu': 4.58.0 - '@rollup/rollup-linux-loong64-musl': 4.58.0 - '@rollup/rollup-linux-ppc64-gnu': 4.58.0 - '@rollup/rollup-linux-ppc64-musl': 4.58.0 - '@rollup/rollup-linux-riscv64-gnu': 4.58.0 - '@rollup/rollup-linux-riscv64-musl': 4.58.0 - '@rollup/rollup-linux-s390x-gnu': 4.58.0 - '@rollup/rollup-linux-x64-gnu': 4.58.0 - '@rollup/rollup-linux-x64-musl': 4.58.0 - '@rollup/rollup-openbsd-x64': 4.58.0 - '@rollup/rollup-openharmony-arm64': 4.58.0 - '@rollup/rollup-win32-arm64-msvc': 4.58.0 - '@rollup/rollup-win32-ia32-msvc': 4.58.0 - '@rollup/rollup-win32-x64-gnu': 4.58.0 - '@rollup/rollup-win32-x64-msvc': 4.58.0 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - run-parallel@1.2.0: + sade@1.8.1: dependencies: - queue-microtask: 1.2.3 + mri: 1.2.0 - scheduler@0.23.2: + sander@0.5.1: dependencies: - loose-envify: 1.4.0 + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 - semver@6.3.1: {} + sorcery@0.11.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + buffer-crc32: 1.0.0 + minimist: 1.2.8 + sander: 0.5.1 source-map-js@1.2.1: {} - sucrase@3.35.1: + strip-indent@3.0.0: dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 + min-indent: 1.0.1 - supports-preserve-symlinks-flag@1.0.0: {} - - tailwindcss@3.4.19: + svelte-check@3.8.6(postcss@8.5.8)(svelte@5.54.0): dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 + '@jridgewell/trace-mapping': 0.3.31 chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) - postcss-nested: 6.2.0(postcss@8.5.6) - postcss-selector-parser: 6.1.2 - resolve: 1.22.11 - sucrase: 3.35.1 + sade: 1.8.1 + svelte: 5.54.0 + svelte-preprocess: 5.1.4(postcss@8.5.8)(svelte@5.54.0)(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - - tsx - - yaml + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss - thenify-all@1.6.0: + svelte-preprocess@5.1.4(postcss@8.5.8)(svelte@5.54.0)(typescript@5.9.3): dependencies: - thenify: 3.3.1 + '@types/pug': 2.0.10 + detect-indent: 6.1.0 + magic-string: 0.30.21 + sorcery: 0.11.1 + strip-indent: 3.0.0 + svelte: 5.54.0 + optionalDependencies: + postcss: 8.5.8 + typescript: 5.9.3 - thenify@3.3.1: + svelte-spa-router@4.0.2: dependencies: - any-promise: 1.3.0 + regexparam: 2.0.2 - tinyglobby@0.2.15: + svelte@5.54.0: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.4 + esm-env: 1.2.2 + esrap: 2.2.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-interface-checker@0.1.13: {} - - tslib@2.8.1: {} - typescript@5.9.3: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - use-callback-ref@1.3.3(@types/react@18.3.28)(react@18.3.1): - dependencies: - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - use-sidecar@1.1.3(@types/react@18.3.28)(react@18.3.1): - dependencies: - detect-node-es: 1.1.0 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - util-deprecate@1.0.2: {} - vite@5.4.21: dependencies: esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.58.0 + postcss: 8.5.8 + rollup: 4.59.0 optionalDependencies: fsevents: 2.3.3 - yallist@3.1.1: {} - - zustand@5.0.11(@types/react@18.3.28)(react@18.3.1): + vitefu@1.1.2(vite@5.4.21): optionalDependencies: - '@types/react': 18.3.28 - react: 18.3.1 + vite: 5.4.21 + + wrappy@1.0.2: {} + + zimmerframe@1.1.4: {} diff --git a/src/App.module.css b/src/App.module.css deleted file mode 100644 index 33b8920..0000000 --- a/src/App.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.root { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.content { - flex: 1; - overflow: hidden; - min-height: 0; -} \ No newline at end of file diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..98ecca6 --- /dev/null +++ b/src/App.svelte @@ -0,0 +1,163 @@ + + +{#if devSplash} + setTimeout(() => devSplash = false, EXIT_MS + 20)} /> +{:else if !appReady} + appReady = true} + onRetry={handleRetry} /> +{:else} +
+ {#if idle && !$activeChapter} + setTimeout(() => idle = false, EXIT_MS + 20)} /> + {/if} + {#if !$activeChapter}{/if} +
+ {#if $activeChapter}{:else}{/if} +
+ {#if $settingsOpen}{/if} + +
+{/if} + + diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 72974ac..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; -import { gql } from "./lib/client"; -import { GET_DOWNLOAD_STATUS } from "./lib/queries"; -import "./styles/global.css"; -import { useStore } from "./store"; -import Layout from "./components/layout/Layout"; -import Reader from "./components/pages/Reader"; -import Settings from "./components/settings/Settings"; -import MangaPreview from "./components/explore/MangaPreview"; -import TitleBar from "./components/layout/TitleBar"; -import Toaster from "./components/layout/Toaster"; -import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen"; -import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; -import s from "./App.module.css"; - -const MAX_ATTEMPTS = 30; - -export default function App() { - const activeChapter = useStore((s) => s.activeChapter); - const settingsOpen = useStore((s) => s.settingsOpen); - const settings = useStore((s) => s.settings); - const setActiveDownloads = useStore((s) => s.setActiveDownloads); - const addToast = useStore((s) => s.addToast); - - // serverProbeOk = server responded, but we wait for ring to finish before showing UI - const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer); - // appReady = ring filled + transition done, show main UI - const [appReady, setAppReady] = useState(!settings.autoStartServer); - const [failed, setFailed] = useState(false); - const [retryKey, setRetryKey] = useState(0); - const [idle, setIdle] = useState(false); - // dev tools: force show splash - const [devSplash, setDevSplash] = useState(false); - - const prevQueueRef = useRef([]); - const idleTimerRef = useRef | null>(null); - const idleRef = useRef(false); - - // expose devSplash trigger via window for settings - useEffect(() => { - (window as any).__mokuShowSplash = () => setDevSplash(true); - return () => { delete (window as any).__mokuShowSplash; }; - }, []); - - // Keep idleRef in sync so resetIdle can check it without a stale closure - useEffect(() => { idleRef.current = idle; }, [idle]); - - useEffect(() => { - if (!appReady) return; - function resetIdle() { - // While the idle splash is visible, don't reset — let SplashScreen's own - // dismiss flow handle teardown so the exit animation plays fully. - if (idleRef.current) return; - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000; - if (idleTimeoutMs === 0) return; - idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs); - } - const events = ["mousemove","mousedown","keydown","touchstart","wheel"]; - events.forEach(e => window.addEventListener(e, resetIdle, { passive:true })); - resetIdle(); - return () => { - events.forEach(e => window.removeEventListener(e, resetIdle)); - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - }; - }, [appReady, settings.idleTimeoutMin]); - - function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) { - for (const item of prev) { - if (item.state !== "DOWNLOADING") continue; - if (!next.some(q => q.chapter.id === item.chapter.id)) { - const manga = item.chapter.manga; - addToast({ kind:"success", title:"Chapter downloaded", - body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name, - duration: 4000 }); - } - } - } - - function applyQueue(next: DownloadQueueItem[]) { - detectCompletions(prevQueueRef.current, next); - prevQueueRef.current = next; - setActiveDownloads(next.map(item => ({ - chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress, - }))); - } - - useEffect(() => { - document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`; - }, [settings.uiScale]); - - useEffect(() => { - const theme = settings.theme ?? "dark"; - document.documentElement.setAttribute("data-theme", theme); - }, [settings.theme]); - - useEffect(() => { - const p = (e: MouseEvent) => e.preventDefault(); - document.addEventListener("contextmenu", p); - return () => document.removeEventListener("contextmenu", p); - }, []); - - useEffect(() => { - if (!settings.autoStartServer) return; - invoke("spawn_server", { binary: settings.serverBinary }).catch(err => - console.warn("Could not start server:", err)); - return () => { invoke("kill_server").catch(() => {}); }; - }, [settings.autoStartServer, settings.serverBinary]); - - // Poll until server responds - useEffect(() => { - if (serverProbeOk) return; - let cancelled = false, tries = 0; - async function probe() { - if (cancelled) return; - tries++; - try { - const res = await fetch(`${settings.serverUrl}/api/graphql`, { - method:"POST", headers:{"Content-Type":"application/json"}, - body: JSON.stringify({ query:"{ __typename }" }), - signal: AbortSignal.timeout(2000), - }); - if (res.ok && !cancelled) { setServerProbeOk(true); return; } - } catch {} - if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; } - if (!cancelled) setTimeout(probe, 800); - } - const t = setTimeout(probe, 800); - return () => { cancelled = true; clearTimeout(t); }; - }, [serverProbeOk, settings.serverUrl, retryKey]); - - useEffect(() => { - if (!appReady) return; - function poll() { - gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) - .then(d => applyQueue(d.downloadStatus.queue)).catch(console.error); - } - poll(); - const id = setInterval(poll, 2000); - return () => clearInterval(id); - }, [appReady]); - - useEffect(() => { - type P = { chapterId:number; mangaId:number; progress:number }[]; - const unsub = listen

("download-progress", e => setActiveDownloads(e.payload)); - return () => { unsub.then(fn => fn()); }; - }, [setActiveDownloads]); - - // Dev splash overlay — shows idle mode so you can dismiss with any interaction - if (devSplash) { - return ( - { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }} - /> - ); - } - - // Loading splash — shown until ring fills + transition completes - if (!appReady) { - return ( - setAppReady(true)} - onRetry={() => { - setFailed(false); - setServerProbeOk(false); - setRetryKey(k => k+1); - }} - /> - ); - } - - return ( -

- {idle && !activeChapter && ( - { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }} - /> - )} - {!activeChapter && } -
- {activeChapter ? : } -
- {settingsOpen && } - - -
- ); -} \ No newline at end of file diff --git a/src/components/context/ContextMenu.module.css b/src/components/context/ContextMenu.module.css deleted file mode 100644 index c0513f9..0000000 --- a/src/components/context/ContextMenu.module.css +++ /dev/null @@ -1,83 +0,0 @@ -.menu { - position: fixed; - z-index: 200; - background: var(--bg-raised); - border: 1px solid var(--border-base); - border-radius: var(--radius-lg); - padding: var(--sp-1); - min-width: 190px; - box-shadow: - 0 0 0 1px rgba(0,0,0,0.08), - 0 4px 12px rgba(0,0,0,0.35), - 0 16px 40px rgba(0,0,0,0.25); - animation: scaleIn 0.1s ease both; - transform-origin: top left; -} - -.item { - display: flex; - align-items: center; - gap: var(--sp-2); - width: 100%; - padding: 5px var(--sp-2); - border-radius: var(--radius-md); - font-size: var(--text-sm); - color: var(--text-secondary); - text-align: left; - cursor: pointer; - transition: background var(--t-fast), color var(--t-fast); - border: none; - background: none; - outline: none; -} - -.item:hover:not(:disabled), -.itemFocused:not(:disabled) { - background: var(--bg-overlay); - color: var(--text-primary); -} - -/* Icon area — fixed-width column so labels align */ -.itemIconWrap { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - flex-shrink: 0; - color: var(--text-faint); - transition: color var(--t-fast); - border-radius: var(--radius-sm); -} - -.item:hover .itemIconWrap, -.itemFocused .itemIconWrap { - color: var(--text-muted); -} - -.itemLabel { - flex: 1; - line-height: 1.3; -} - -/* Danger variant */ -.itemDanger { color: var(--color-error); } -.itemDanger:hover:not(:disabled), -.itemDanger.itemFocused:not(:disabled) { - background: var(--color-error-bg); - color: var(--color-error); -} -.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; } - -/* Disabled */ -.itemDisabled { - opacity: 0.3; - cursor: default; - pointer-events: none; -} - -.separator { - height: 1px; - background: var(--border-dim); - margin: 3px var(--sp-1); -} \ No newline at end of file diff --git a/src/components/context/ContextMenu.tsx b/src/components/context/ContextMenu.tsx deleted file mode 100644 index cafddd2..0000000 --- a/src/components/context/ContextMenu.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useEffect, useRef, useCallback, useState } from "react"; -import { createPortal } from "react-dom"; -import s from "./ContextMenu.module.css"; - -export interface ContextMenuItem { - label: string; - icon?: React.ReactNode; - onClick: () => void; - danger?: boolean; - disabled?: boolean; - separator?: never; -} - -export interface ContextMenuSeparator { - separator: true; - label?: never; - icon?: never; - onClick?: never; - danger?: never; - disabled?: never; -} - -export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator; - -interface Props { - x: number; - y: number; - items: ContextMenuEntry[]; - onClose: () => void; -} - -export default function ContextMenu({ x, y, items, onClose }: Props) { - const menuRef = useRef(null); - const [focused, setFocused] = useState(-1); - - // Build list of actionable (non-separator, non-disabled) indices for keyboard nav - const actionable = items - .map((_, i) => i) - .filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled); - - useEffect(() => { - function onDown(e: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") { e.stopPropagation(); onClose(); return; } - if (e.key === "ArrowDown") { - e.preventDefault(); - setFocused((prev) => { - const cur = actionable.indexOf(prev); - return actionable[(cur + 1) % actionable.length] ?? actionable[0]; - }); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setFocused((prev) => { - const cur = actionable.indexOf(prev); - return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0]; - }); - return; - } - if (e.key === "Enter" && focused >= 0) { - e.preventDefault(); - const item = items[focused] as ContextMenuItem; - if (item && !item.disabled) { item.onClick(); onClose(); } - return; - } - } - document.addEventListener("mousedown", onDown, true); - document.addEventListener("keydown", onKey, true); - return () => { - document.removeEventListener("mousedown", onDown, true); - document.removeEventListener("keydown", onKey, true); - }; - }, [onClose, focused, actionable, items]); - - // Focus first item on open - useEffect(() => { - if (actionable.length) setFocused(actionable[0]); - }, []); - - const getPosition = useCallback(() => { - const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1; - const scaledX = x / zoom; - const scaledY = y / zoom; - const menuW = 200; - const menuH = items.length * 34; - const vw = window.innerWidth / zoom; - const vh = window.innerHeight / zoom; - const left = scaledX + menuW > vw ? scaledX - menuW : scaledX; - const top = scaledY + menuH > vh ? scaledY - menuH : scaledY; - return { left: Math.max(4, left), top: Math.max(4, top) }; - }, [x, y, items.length]); - - return createPortal( -
e.preventDefault()} - > - {items.map((item, i) => { - if ("separator" in item && item.separator) { - return
; - } - const mi = item as ContextMenuItem; - const isFocused = focused === i; - return ( - - ); - })} -
, - document.body - ); -} \ No newline at end of file diff --git a/src/components/downloads/DownloadQueue.module.css b/src/components/downloads/DownloadQueue.module.css deleted file mode 100644 index be88260..0000000 --- a/src/components/downloads/DownloadQueue.module.css +++ /dev/null @@ -1,215 +0,0 @@ -.root { - padding: var(--sp-6); - overflow-y: auto; - height: 100%; - animation: fadeIn 0.14s ease both; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--sp-5); -} - -.heading { - font-family: var(--font-ui); - font-size: var(--text-xs); - font-weight: var(--weight-normal); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -.headerActions { display: flex; gap: var(--sp-2); } - -.iconBtn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - color: var(--text-muted); - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } -.iconBtn:disabled { opacity: 0.3; cursor: default; } -/* Loading state — accent tint so it's visually distinct */ -.iconBtnLoading { - border-color: var(--accent-dim); - color: var(--accent-fg); - background: var(--accent-muted); -} -.iconBtnLoading:hover:not(:disabled) { - border-color: var(--accent-dim); - color: var(--accent-fg); - background: var(--accent-muted); -} - -.statusBar { - display: flex; - align-items: center; - gap: var(--sp-3); - padding: var(--sp-3); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - margin-bottom: var(--sp-4); -} - -.statusDot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--text-faint); - flex-shrink: 0; - transition: background var(--t-base); -} - -.statusDotActive { - background: var(--accent); - animation: pulse 1.6s ease infinite; -} - -.statusText { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-muted); - flex: 1; - letter-spacing: var(--tracking-wide); - transition: color var(--t-base); -} - -.statusCount { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.list { display: flex; flex-direction: column; gap: var(--sp-2); } - -.row { - display: flex; - align-items: center; - gap: var(--sp-3); - padding: var(--sp-3); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - transition: border-color var(--t-fast), opacity var(--t-base); -} - -.rowActive { border-color: var(--accent-dim); } - -/* Fade out rows being removed */ -.rowRemoving { opacity: 0.4; pointer-events: none; } - -/* Thumbnail */ -.thumb { - width: 36px; - height: 54px; - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--bg-overlay); - flex-shrink: 0; - border: 1px solid var(--border-dim); -} - -.thumbImg { - width: 100%; - height: 100%; - object-fit: cover; -} - -/* Info block */ -.info { - flex: 1; - display: flex; - flex-direction: column; - gap: 3px; - overflow: hidden; - min-width: 0; -} - -.mangaTitle { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.chapterName { - font-size: var(--text-xs); - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.pagesLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.progressWrap { - height: 2px; - background: var(--border-base); - border-radius: var(--radius-full); - overflow: hidden; - margin-top: 4px; -} - -.progressBar { - height: 100%; - background: var(--accent); - border-radius: var(--radius-full); - transition: width 0.4s ease; -} - -/* Right side */ -.rowRight { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: var(--sp-1); - flex-shrink: 0; -} - -.stateLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -.removeBtn { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: var(--radius-sm); - color: var(--text-faint); - transition: color var(--t-base), background var(--t-base); -} -.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); } -.removeBtn:disabled { opacity: 0.5; cursor: default; } - -.empty { - display: flex; - align-items: center; - justify-content: center; - height: 160px; - color: var(--text-faint); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); -} \ No newline at end of file diff --git a/src/components/downloads/DownloadQueue.tsx b/src/components/downloads/DownloadQueue.tsx deleted file mode 100644 index 0e63191..0000000 --- a/src/components/downloads/DownloadQueue.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useEffect, useState, useCallback } from "react"; -import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { - GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, - CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD, -} from "../../lib/queries"; -import { useStore } from "../../store"; -import type { DownloadStatus } from "../../lib/types"; -import s from "./DownloadQueue.module.css"; - -export default function DownloadQueue() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [togglingPlay, setTogglingPlay] = useState(false); - const [clearing, setClearing] = useState(false); - const [dequeueing, setDequeueing] = useState>(new Set()); - const setActiveDownloads = useStore((s) => s.setActiveDownloads); - - // Apply status to local state + global store. - // Completion toasting is handled globally in App.tsx — no duplication here. - const applyStatus = useCallback((ds: DownloadStatus) => { - setStatus(ds); - setActiveDownloads( - ds.queue.map((item) => ({ - chapterId: item.chapter.id, - mangaId: item.chapter.mangaId, - progress: item.progress, - })) - ); - }, [setActiveDownloads]); - - async function poll() { - gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) - .then((d) => applyStatus(d.downloadStatus)) - .catch(console.error) - .finally(() => setLoading(false)); - } - - useEffect(() => { - poll(); - const id = setInterval(poll, 2000); - return () => clearInterval(id); - }, []); - - // ── Actions ───────────────────────────────────────────────────────────────── - - async function togglePlay() { - if (togglingPlay) return; - setTogglingPlay(true); - const wasRunning = status?.state === "STARTED"; - setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev); - try { - if (wasRunning) { - const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER); - applyStatus(d.stopDownloader.downloadStatus); - } else { - const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER); - applyStatus(d.startDownloader.downloadStatus); - } - } catch (e) { - console.error(e); - poll(); - } finally { - setTogglingPlay(false); - } - } - - async function clear() { - if (clearing) return; - setClearing(true); - setStatus((prev) => prev ? { ...prev, queue: [] } : prev); - setActiveDownloads([]); - try { - const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER); - applyStatus(d.clearDownloader.downloadStatus); - } catch (e) { - console.error(e); - poll(); - } finally { - setClearing(false); - } - } - - async function dequeue(chapterId: number) { - if (dequeueing.has(chapterId)) return; - setDequeueing((prev) => new Set(prev).add(chapterId)); - setStatus((prev) => - prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev - ); - try { - await gql(DEQUEUE_DOWNLOAD, { chapterId }); - poll(); - } catch (e) { - console.error(e); - poll(); - } finally { - setDequeueing((prev) => { - const next = new Set(prev); - next.delete(chapterId); - return next; - }); - } - } - - const queue = status?.queue ?? []; - const isRunning = status?.state === "STARTED"; - - function pagesDownloaded(progress: number, pageCount: number): number { - return Math.round(progress * pageCount); - } - - return ( -
-
-

Downloads

-
- - - -
-
- -
-
- - {togglingPlay - ? (isRunning ? "Pausing…" : "Starting…") - : isRunning ? "Downloading" : "Paused"} - - {queue.length} queued -
- - {loading ? ( -
- -
- ) : queue.length === 0 ? ( -
Queue is empty.
- ) : ( -
- {queue.map((item, i) => { - const isActive = i === 0 && isRunning; - const pages = item.chapter.pageCount ?? 0; - const done = pagesDownloaded(item.progress, pages); - const manga = item.chapter.manga; - const isRemoving = dequeueing.has(item.chapter.id); - - return ( -
- {manga?.thumbnailUrl && ( -
- {manga.title} -
- )} - -
- {manga?.title && {manga.title}} - {item.chapter.name} - {pages > 0 && ( - - {isActive ? `${done} / ${pages} pages` : `${pages} pages`} - - )} - {isActive && ( -
-
-
- )} -
- -
- {item.state} - {!isActive && ( - - )} -
-
- ); - })} -
- )} -
- ); -} \ No newline at end of file diff --git a/src/components/downloads/Downloads.svelte b/src/components/downloads/Downloads.svelte new file mode 100644 index 0000000..be726a7 --- /dev/null +++ b/src/components/downloads/Downloads.svelte @@ -0,0 +1 @@ +
Downloads.svelte
diff --git a/src/components/explore/Explore.module.css b/src/components/explore/Explore.module.css deleted file mode 100644 index 08d64fc..0000000 --- a/src/components/explore/Explore.module.css +++ /dev/null @@ -1,441 +0,0 @@ -.root { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - animation: fadeIn 0.14s ease both; -} - -/* ── Header / Tab switcher ───────────────────────────────────────────────── */ -.header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-4) var(--sp-6); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; - gap: var(--sp-4); -} - -.headerLeft { - display: flex; - align-items: center; - gap: var(--sp-4); -} - -.heading { - font-family: var(--font-ui); - font-size: var(--text-xs); - font-weight: var(--weight-normal); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - flex-shrink: 0; -} - -.tabs { - display: flex; - 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: none; - background: none; - color: var(--text-faint); - cursor: pointer; - transition: background var(--t-base), color var(--t-base); - white-space: nowrap; -} - -.tab:hover { color: var(--text-muted); } - -.tabActive { - background: var(--accent-muted); - color: var(--accent-fg); - border: 1px solid var(--accent-dim); -} - -.tabActive:hover { color: var(--accent-fg); } - -/* Source picker */ -.sourcePicker { - display: flex; - align-items: center; - gap: var(--sp-2); -} - -.sourcePickerLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - text-transform: uppercase; - white-space: nowrap; -} - -.sourceSelect { - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - padding: 4px 8px; - color: var(--text-secondary); - font-size: var(--text-sm); - font-family: var(--font-ui); - outline: none; - cursor: pointer; - transition: border-color var(--t-base); - max-width: 160px; -} - -.sourceSelect:focus { border-color: var(--border-strong); } - -/* ── Scrollable body ─────────────────────────────────────────────────────── */ -.body { - flex: 1; - overflow-y: auto; - padding: var(--sp-5) 0 var(--sp-6); - will-change: scroll-position; - -webkit-overflow-scrolling: touch; -} - -/* ── Section ─────────────────────────────────────────────────────────────── */ -.section { - margin-bottom: var(--sp-6); -} - -.sectionHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 var(--sp-6) var(--sp-3); -} - -.sectionTitle { - font-family: var(--font-ui); - font-size: var(--text-xs); - font-weight: var(--weight-normal); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -.sectionTitleIcon { - display: inline-flex; - align-items: center; - gap: var(--sp-2); -} - -.seeAll { - display: flex; - align-items: center; - gap: 4px; - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - text-transform: uppercase; - color: var(--text-faint); - background: none; - border: none; - cursor: pointer; - padding: 2px 0; - transition: color var(--t-base); -} - -.seeAll:hover { color: var(--accent-fg); } - -/* ── Horizontal scroll row ───────────────────────────────────────────────── */ -.row { - display: flex; - gap: var(--sp-3); - padding: 0 var(--sp-6); - overflow-x: auto; - scrollbar-width: none; - -ms-overflow-style: none; - scroll-behavior: smooth; -} - -.row::-webkit-scrollbar { display: none; } - -/* ── Card (shared by all rows) ───────────────────────────────────────────── */ -.card { - flex-shrink: 0; - width: 110px; - background: none; - border: none; - padding: 0; - cursor: pointer; - text-align: left; -} - -.card:hover .cover { filter: brightness(1.06); } -.card:hover .title { color: var(--text-primary); } - -.coverWrap { - 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); -} - -.cover { - width: 100%; - height: 100%; - object-fit: cover; - transition: filter var(--t-base); - will-change: filter; -} - -.inLibraryBadge { - 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); -} - -.progressBar { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 3px; - background: var(--bg-overlay); -} - -.progressFill { - height: 100%; - background: var(--accent-fg); - border-radius: 0 2px 0 0; - transition: width 0.2s ease; -} - -.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); -} - -.subtitle { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - margin-top: 2px; - letter-spacing: var(--tracking-wide); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Ghost card — invisible placeholder to fill row trailing space */ -.ghostCard { - flex-shrink: 0; - width: 110px; - aspect-ratio: 2 / 3; - pointer-events: none; - visibility: hidden; -} - -/* ── Skeleton ─────────────────────────────────────────────────────────────── */ -.skeletonRow { - display: flex; - gap: var(--sp-3); - padding: 0 var(--sp-6); - overflow: hidden; -} - -.cardSkeleton { flex-shrink: 0; width: 110px; } - -.coverSkeleton { - aspect-ratio: 2 / 3; - border-radius: var(--radius-md); -} - -.titleSkeleton { - height: 11px; - margin-top: var(--sp-2); - width: 80%; -} - -/* ── Genre drill-down grid ───────────────────────────────────────────────── */ -.drillRoot { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - animation: fadeIn 0.14s ease both; -} - -.drillHeader { - 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; - background: none; - border: none; - cursor: pointer; - padding: 0; - transition: color var(--t-base); - flex-shrink: 0; -} - -.back:hover { color: var(--text-secondary); } - -.drillTitle { - font-size: var(--text-base); - font-weight: var(--weight-medium); - color: var(--text-secondary); - letter-spacing: var(--tracking-tight); -} - -.drillGrid { - 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; -} - -.drillCard { - background: none; - border: none; - padding: 0; - cursor: pointer; - text-align: left; -} - -.drillCard:hover .cover { filter: brightness(1.06); } -.drillCard:hover .title { color: var(--text-primary); } - -/* ── Empty state ─────────────────────────────────────────────────────────── */ -.empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--sp-8) var(--sp-6); - color: var(--text-faint); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - gap: var(--sp-2); - text-align: center; -} - -.emptyHint { - font-size: var(--text-2xs); - color: var(--text-faint); - opacity: 0.6; -} - -/* ── No source state ─────────────────────────────────────────────────────── */ -.noSource { - display: flex; - align-items: center; - justify-content: center; - padding: var(--sp-4) var(--sp-6); - color: var(--text-faint); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); -} -/* ── Explore More end-cap card ───────────────────────────────────────────── */ -.exploreMoreCard { - flex-shrink: 0; - width: 110px; - aspect-ratio: 2 / 3; - border-radius: var(--radius-md); - border: 1px dashed var(--border-strong); - background: var(--bg-raised); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: border-color var(--t-base), background var(--t-base); - padding: 0; -} -.exploreMoreCard:hover { - border-color: var(--accent); - background: var(--accent-muted); -} -.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); } -.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); } - -.exploreMoreInner { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--sp-2); - padding: var(--sp-3); - pointer-events: none; -} - -.exploreMoreIcon { - color: var(--text-faint); - transition: color var(--t-base); -} - -.exploreMoreLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - text-transform: uppercase; - color: var(--text-faint); - transition: color var(--t-base); - text-align: center; -} - -.exploreMoreGenre { - font-size: var(--text-2xs); - color: var(--text-faint); - opacity: 0.6; - text-align: center; - font-family: var(--font-ui); - letter-spacing: var(--tracking-wide); -} \ No newline at end of file diff --git a/src/components/explore/Explore.tsx b/src/components/explore/Explore.tsx deleted file mode 100644 index bc46cf2..0000000 --- a/src/components/explore/Explore.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import { useEffect, useState, useMemo, useRef, memo } from "react"; -import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; -import GenreDrillPage from "./GenreDrillPage"; -import { gql, thumbUrl } from "../../lib/client"; -import { UPDATE_MANGA } from "../../lib/queries"; -import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache"; -import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils"; -import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; -import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; -import { useStore } from "../../store"; -import type { Manga, Source } from "../../lib/types"; -import SourceList from "../sources/SourceList"; -import SourceBrowse from "../sources/SourceBrowse"; -import s from "./Explore.module.css"; - -// ── Frecency score ──────────────────────────────────────────────────────────── - -function frecencyScore(readAt: number, count: number): number { - const hoursSince = (Date.now() - readAt) / 3_600_000; - return count / Math.log(hoursSince + 2); -} - -// ── Ghost / Skeleton ────────────────────────────────────────────────────────── - -function GhostCard() { return
; } -const GHOST_COUNT = 3; -const ROW_CAP = 25; - -// Hijack vertical wheel delta → horizontal scroll on .row divs -function handleRowWheel(e: React.WheelEvent) { - if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - const el = e.currentTarget; - const canScrollLeft = el.scrollLeft > 0; - const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1; - if (!canScrollLeft && !canScrollRight) return; - e.stopPropagation(); - el.scrollLeft += e.deltaY; -} - -function SkeletonRow({ count = 8 }: { count?: number }) { - return ( -
- {Array.from({ length: count }).map((_, i) => ( -
-
-
-
- ))} -
- ); -} - -// ── Cover image with fade-in ────────────────────────────────────────────────── - -const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) { - const [loaded, setLoaded] = useState(false); - return ( - {alt} setLoaded(true)} - style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }} - /> - ); -}); - -// ── Mini card ───────────────────────────────────────────────────────────────── - -const MiniCard = memo(function MiniCard({ - manga, onClick, onContextMenu, subtitle, progress, -}: { - manga: Manga; - onClick: () => void; - onContextMenu?: (e: React.MouseEvent) => void; - subtitle?: string; - progress?: number; -}) { - return ( - - ); -}); - -// ── Explore More end-cap ────────────────────────────────────────────────────── - -const ExploreMoreCard = memo(function ExploreMoreCard({ - genre, onClick, -}: { genre: string; onClick: () => void }) { - return ( - - ); -}); - -// ── Section ─────────────────────────────────────────────────────────────────── - -function Section({ - title, icon, onSeeAll, loading, children, -}: { - title: string; icon?: React.ReactNode; onSeeAll?: () => void; - loading?: boolean; children: React.ReactNode; -}) { - return ( -
-
- - {icon}{title} - - {onSeeAll && ( - - )} -
- {loading ? : children} -
- ); -} - -// ── Main component ──────────────────────────────────────────────────────────── - -type ExploreMode = "explore" | "sources"; - -export default function Explore() { - const [mode, setMode] = useState("explore"); - const activeSource = useStore((s) => s.activeSource); - const genreFilter = useStore((s) => s.genreFilter); - - if (activeSource) return ; - if (genreFilter) return ; - - return ( -
-
-
-

Explore

-
- - -
-
-
- {/* Keep ExploreFeed always mounted so data survives tab switches */} -
- {mode === "sources" && } -
- ); -} - -// ── Explore feed ────────────────────────────────────────────────────────────── - -const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"]; - -// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge -const EXPLORE_ALL_MANGA = ` - query ExploreAllManga { - mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) { - nodes { - id title thumbnailUrl inLibrary genre status - source { id displayName } - } - } - } -`; - -// Fast genre row query against the local DB -const MANGAS_BY_GENRE_EXPLORE = ` - query MangasByGenreExplore($genre: String!, $first: Int) { - mangas( - filter: { genre: { includesInsensitive: $genre } } - first: $first - orderBy: IN_LIBRARY_AT - orderByType: DESC - ) { - nodes { - id title thumbnailUrl inLibrary genre status - source { id displayName } - } - } - } -`; - -function ExploreFeed() { - const [allManga, setAllManga] = useState([]); - const [loadingLib, setLoadingLib] = useState(true); - const [popularManga, setPopularManga] = useState([]); - const [loadingPopular, setLoadingPopular] = useState(true); - const [genreResults, setGenreResults] = useState>(new Map()); - const [loadingGenres, setLoadingGenres] = useState(false); - const [sources, setSources] = useState([]); - const [loadError, setLoadError] = useState(false); - const [retryCount, setRetryCount] = useState(0); - const abortRef = useRef(null); - const fetchedGenresRef = useRef(""); - - const history = useStore((s) => s.history); - const settings = useStore((s) => s.settings); - const setPreviewManga = useStore((s) => s.setPreviewManga); - const setGenreFilter = useStore((s) => s.setGenreFilter); - const folders = useStore((s) => s.settings.folders); - const addFolder = useStore((s) => s.addFolder); - const assignMangaToFolder = useStore((s) => s.assignMangaToFolder); - const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); - - useEffect(() => { - return () => { abortRef.current?.abort(); }; - }, []); - - function openCtx(e: React.MouseEvent, m: Manga) { - e.preventDefault(); e.stopPropagation(); - setCtx({ x: e.clientX, y: e.clientY, manga: m }); - } - - function buildCtxItems(m: Manga): ContextMenuEntry[] { - return [ - { - label: m.inLibrary ? "In Library" : "Add to library", - icon: , - disabled: m.inLibrary, - onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) - .then(() => { cache.clear(CACHE_KEYS.LIBRARY); }) - .catch(console.error), - }, - ...(folders.length > 0 ? [ - { separator: true } as ContextMenuEntry, - ...folders.map((f): ContextMenuEntry => ({ - label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, - icon: , - onClick: () => assignMangaToFolder(f.id, m.id), - })), - ] : []), - { separator: true }, - { - label: "New folder & add", - icon: , - onClick: () => { - const name = prompt("Folder name:"); - if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } - }, - }, - ]; - } - - // ── Data load ───────────────────────────────────────────────────────────── - // Library + genre rows: single local DB query each — instant, no source calls. - // Popular: still needs fetchSourceManga since there's no local equivalent. - useEffect(() => { - const alreadyLoaded = allManga.length > 0; - if (alreadyLoaded) return; - - setLoadingLib(true); - setLoadingPopular(true); - setLoadError(false); - - const preferredLang = settings.preferredExtensionLang || "en"; - if (retryCount > 0) { - cache.clear(CACHE_KEYS.LIBRARY); - cache.clear(CACHE_KEYS.SOURCES); - fetchedGenresRef.current = ""; - } - - // Single query for all manga — library flag included - cache.get(CACHE_KEYS.LIBRARY, () => - gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA) - .then((d) => d.mangas.nodes) - ).then(setAllManga) - .catch((e) => { console.error(e); setLoadError(true); }) - .finally(() => setLoadingLib(false)); - - // Sources — only needed for Popular section - cache.get(CACHE_KEYS.SOURCES, () => - gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => dedupeSources(d.sources.nodes, preferredLang)) - ).then((allSources) => { - if (allSources.length === 0) { setLoadingPopular(false); return; } - const topSources = getTopSources(allSources).slice(0, 2); - setSources(allSources); - - cache.get(CACHE_KEYS.POPULAR, () => - Promise.allSettled( - topSources.map((src) => - gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: src.id, type: "POPULAR", page: 1, query: null, - }).then((d) => d.fetchSourceManga.mangas) - ) - ).then((results) => { - const merged: Manga[] = []; - for (const r of results) - if (r.status === "fulfilled") merged.push(...r.value); - return dedupeMangaByTitle(merged).slice(0, 30); - }) - ).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false)); - }).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [retryCount]); - - // ── Frecency genres (derived from history + library) ────────────────────── - const frecencyGenres = useMemo(() => { - const mangaScores = new Map(); - const mangaReadAt = new Map(); - for (const entry of history) { - mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1); - if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0)) - mangaReadAt.set(entry.mangaId, entry.readAt); - } - const genreWeights = new Map(); - const mangaMap = new Map(allManga.map((m) => [m.id, m])); - for (const [mangaId, count] of mangaScores.entries()) { - const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count); - for (const genre of mangaMap.get(mangaId)?.genre ?? []) - genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score); - } - if (genreWeights.size === 0) - allManga.filter((m) => m.inLibrary).forEach((m) => - (m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1))); - if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3); - return Array.from(genreWeights.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([g]) => g); - }, [allManga, history]); - - // ── Genre rows: query local DB directly ───────────────────────────────── - // One query per genre against the local mangas table — instant, no source I/O. - useEffect(() => { - if (frecencyGenres.length === 0 || allManga.length === 0) return; - - const genreKey = frecencyGenres.join(","); - if (fetchedGenresRef.current === genreKey) return; - fetchedGenresRef.current = genreKey; - - setLoadingGenres(true); - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - const streamingMap = new Map(); - - Promise.allSettled( - frecencyGenres.map((genre) => - cache.get(CACHE_KEYS.GENRE(genre), () => - gql<{ mangas: { nodes: Manga[] } }>( - MANGAS_BY_GENRE_EXPLORE, - { genre, first: 25 }, - ctrl.signal, - ).then((d) => d.mangas.nodes) - ).then((mangas) => { - if (ctrl.signal.aborted) return; - streamingMap.set(genre, mangas); - setGenreResults(new Map(streamingMap)); - }) - ) - ) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) - .finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); }); - }, [frecencyGenres, allManga]); - - function openManga(m: Manga) { setPreviewManga(m); } - - // ── Continue reading ────────────────────────────────────────────────────── - const continueReading = useMemo(() => { - const mangaMap = new Map(allManga.map((m) => [m.id, m])); - const seen = new Set(); - const result: { manga: Manga; chapterName: string; progress: number }[] = []; - for (const entry of history) { - if (seen.has(entry.mangaId)) continue; - seen.add(entry.mangaId); - const manga = mangaMap.get(entry.mangaId); - if (!manga) continue; - result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 }); - if (result.length >= 12) break; - } - return result; - }, [history, allManga]); - - // ── Recommended ─────────────────────────────────────────────────────────── - const recommended = useMemo(() => { - if (allManga.length === 0 || frecencyGenres.length === 0) return []; - const continueIds = new Set(continueReading.map((r) => r.manga.id)); - return allManga - .filter((m) => m.inLibrary && !continueIds.has(m.id) && - frecencyGenres.some((g) => (m.genre ?? []).includes(g))) - .slice(0, 20); - }, [allManga, frecencyGenres, continueReading]); - - const genresLoading = loadingGenres; - - return ( -
- - {(continueReading.length > 0 || loadingLib) && ( -
} loading={loadingLib}> -
- {continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => ( - openManga(manga)} - onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} /> - ))} - {Array.from({ length: GHOST_COUNT }).map((_, i) => )} -
-
- )} - - {(recommended.length > 0 || loadingLib) && ( -
} loading={loadingLib}> -
- {recommended.slice(0, ROW_CAP).map((m) => ( - openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> - ))} - {Array.from({ length: GHOST_COUNT }).map((_, i) => )} -
-
- )} - - {(popularManga.length > 0 || loadingPopular) && ( -
1 ? `Popular across ${sources.length} sources` : "Popular"} - icon={} - loading={loadingPopular} - > - {sources.length === 0 ? ( -
No sources installed. Add extensions first.
- ) : ( -
- {popularManga.slice(0, ROW_CAP).map((m) => ( - openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> - ))} - {Array.from({ length: GHOST_COUNT }).map((_, i) => )} -
- )} -
- )} - - {frecencyGenres.map((genre) => { - const items = genreResults.get(genre) ?? []; - const isLoading = genresLoading && items.length === 0; - if (!isLoading && items.length === 0) return null; - return ( -
setGenreFilter(genre)} loading={isLoading}> -
- {items.slice(0, ROW_CAP).map((m) => ( - openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> - ))} - {items.length >= ROW_CAP && ( - setGenreFilter(genre)} /> - )} - {Array.from({ length: GHOST_COUNT }).map((_, i) => )} -
-
- ); - })} - - {!loadingLib && !loadingPopular && !loadingGenres && - continueReading.length === 0 && recommended.length === 0 && - popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && ( -
- {loadError ? ( - <> - Could not reach Suwayomi - Make sure the server is running, then try again. - - - ) : ( - <> - Nothing to explore yet - Add manga to your library or install sources to get started. - - )} -
- )} - - {ctx && ( - setCtx(null)} /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/explore/GenreDrillPage.module.css b/src/components/explore/GenreDrillPage.module.css deleted file mode 100644 index f3a79bc..0000000 --- a/src/components/explore/GenreDrillPage.module.css +++ /dev/null @@ -1,176 +0,0 @@ -.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; - background: none; - border: none; - cursor: pointer; - padding: 0; - transition: color var(--t-base); - flex-shrink: 0; -} -.back:hover { color: var(--text-secondary); } - -.title { - font-size: var(--text-base); - font-weight: var(--weight-medium); - color: var(--text-secondary); - letter-spacing: var(--tracking-tight); -} - -.loadingHint { - margin-left: auto; - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -/* Grid fills entire remaining height, no show-more needed */ -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr)); - gap: var(--sp-4); - padding: var(--sp-5) var(--sp-6) var(--sp-6); - overflow-y: auto; - flex: 1; - align-content: start; - /* Smooth GPU-accelerated scrolling */ - 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 .cover { filter: brightness(1.06); } -.card:hover .cardTitle { color: var(--text-primary); } - -.coverWrap { - position: relative; - aspect-ratio: 2 / 3; - overflow: hidden; - border-radius: var(--radius-md); - /* Solid bg shown while image fades in — matches skeleton color */ - background: var(--bg-raised); - border: 1px solid var(--border-dim); - transform: translateZ(0); -} - -.cover { - width: 100%; - height: 100%; - object-fit: cover; - transition: filter var(--t-base); - will-change: filter; -} - -.inLibraryBadge { - 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); -} - -.cardTitle { - 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); -} - -/* Skeletons */ -.cardSkeleton { padding: 0; } -.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); } -.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; } - -.empty { - display: flex; - align-items: center; - justify-content: center; - flex: 1; - color: var(--text-faint); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); -} - -.resultCount { - margin-left: auto; - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -/* Show more — spans full grid width */ -.showMoreCell { - grid-column: 1 / -1; - display: flex; - justify-content: center; - padding: var(--sp-2) 0 var(--sp-4); -} - -.showMoreBtn { - display: flex; - align-items: center; - gap: var(--sp-2); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - padding: 7px 20px; - border-radius: var(--radius-md); - background: var(--bg-raised); - color: var(--text-muted); - border: 1px solid var(--border-dim); - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base); -} -.showMoreBtn:hover:not(:disabled) { - color: var(--text-secondary); - border-color: var(--border-strong); -} -.showMoreBtn:disabled { - opacity: 0.5; - cursor: default; -} \ No newline at end of file diff --git a/src/components/explore/GenreDrillPage.tsx b/src/components/explore/GenreDrillPage.tsx deleted file mode 100644 index b94f805..0000000 --- a/src/components/explore/GenreDrillPage.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react"; -import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; -import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; -import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils"; -import { useStore } from "../../store"; -import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; -import type { Manga, Source } from "../../lib/types"; -import s from "./GenreDrillPage.module.css"; - -// ── Constants ───────────────────────────────────────────────────────────────── -const PAGE_SIZE = 50; -const INITIAL_PAGES = 3; -const MAX_SOURCES = 12; -const CONCURRENCY = 4; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/** - * genreFilter in the store is either a single tag ("Action") or a `+`-joined - * multi-tag string ("Action+Romance"). Parse it into an array. - * - * Callers set multi-tag filters via: - * setGenreFilter("Action+Romance") - * - * The Explore feed's "See all" button continues to pass single strings and - * requires no change. - */ -function parseTags(genreFilter: string): string[] { - return genreFilter.split("+").map((t) => t.trim()).filter(Boolean); -} - -/** "Action", "Action & Romance", "Action, Romance & Isekai" */ -function tagsLabel(tags: string[]): string { - if (tags.length === 1) return tags[0]; - return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1]; -} - -/** - * Client-side AND filter. - * Sources only accept a single query string, so we send the first tag and - * drop results that don't also have the remaining tags in their genre list. - */ -function matchesAllTags(m: Manga, tags: string[]): boolean { - const genres = (m.genre ?? []).map((g) => g.toLowerCase()); - return tags.every((t) => genres.includes(t.toLowerCase())); -} - -async function runConcurrent( - items: T[], - fn: (item: T) => Promise, - signal: AbortSignal, -): Promise { - 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)); -} - -// ── CoverImg ────────────────────────────────────────────────────────────────── -const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) { - const [loaded, setLoaded] = useState(false); - return ( - {alt} setLoaded(true)} - style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }} - /> - ); -}); - -// ── GenreDrillPage ──────────────────────────────────────────────────────────── -export default function GenreDrillPage() { - const genreFilter = useStore((st) => st.genreFilter); - const setGenreFilter = useStore((st) => st.setGenreFilter); - const setPreviewManga = useStore((st) => st.setPreviewManga); - const settings = useStore((st) => st.settings); - const folders = useStore((st) => st.settings.folders); - const addFolder = useStore((st) => st.addFolder); - const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); - - // Parse the filter string into individual tags - const tags = useMemo(() => parseTags(genreFilter), [genreFilter]); - // First tag is sent as the source query string (sources accept only one term) - const primaryTag = tags[0] ?? ""; - - const [libraryManga, setLibraryManga] = useState([]); - const [sourceManga, setSourceManga] = useState([]); - const [loadingInitial, setLoadingInitial] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); - const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); - - // Per-source next-page tracker; -1 means exhausted - const nextPageRef = useRef>(new Map()); - const sourcesRef = useRef([]); - const abortRef = useRef(null); - - // ── Initial load ───────────────────────────────────────────────────────── - useEffect(() => { - if (tags.length === 0) return; - - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - setLoadingInitial(true); - setSourceManga([]); - setLibraryManga([]); - setVisibleCount(PAGE_SIZE); - nextPageRef.current = new Map(); - - const preferredLang = settings.preferredExtensionLang || "en"; - - // ── Library (local DB, instant) ─────────────────────────────────────── - cache.get(CACHE_KEYS.LIBRARY, () => - Promise.all([ - gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), - gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY), - ]).then(([all, lib]) => { - const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m])); - return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m); - }) - ) - .then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); }) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }); - - // ── Sources: stream results as each source responds ─────────────────── - // Source list is stable within a session — cache indefinitely. - cache.get(CACHE_KEYS.SOURCES, () => - gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)), - Infinity, - ).then(async (allSources) => { - const sources = allSources.slice(0, MAX_SOURCES); - sourcesRef.current = sources; - for (const src of sources) nextPageRef.current.set(src.id, -1); - - await runConcurrent(sources, async (src) => { - if (ctrl.signal.aborted) return; - - // PageSet tracks which pages we've already fetched for this (source, tags) bucket. - // On navigation-away → back the pages are still in the TTL store, so fetchPage - // returns the cached promise immediately without hitting the network. - const ps = getPageSet(src.id, "SEARCH", tags); - const pageItems: Manga[] = []; - - for (let page = 1; page <= INITIAL_PAGES; page++) { - if (ctrl.signal.aborted) return; - - const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags); - const result = await cache - .get<{ mangas: Manga[]; hasNextPage: boolean }>( - pageKey, - () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page, query: primaryTag }, - ctrl.signal, - ).then((d) => d.fetchSourceManga), - ) - .catch((e: any) => { - if (e?.name !== "AbortError") console.error(e); - return null; - }); - - if (!result || ctrl.signal.aborted) break; - - ps.add(page); - - // For multi-tag searches: client-side AND filter for tags beyond the first. - // Sources only support a single query string, so we send primaryTag and - // drop results that don't contain the remaining tags in their genre array. - const matching = tags.length > 1 - ? result.mangas.filter((m) => matchesAllTags(m, tags)) - : result.mangas; - - pageItems.push(...matching); - - if (!result.hasNextPage) { - nextPageRef.current.set(src.id, -1); - break; - } else if (page === INITIAL_PAGES) { - nextPageRef.current.set(src.id, INITIAL_PAGES + 1); - } - } - - if (!ctrl.signal.aborted && pageItems.length > 0) { - setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems])); - setLoadingInitial(false); - } - }, ctrl.signal); - - if (!ctrl.signal.aborted) setLoadingInitial(false); - }).catch((e) => { - if (e?.name !== "AbortError") console.error(e); - if (!ctrl.signal.aborted) setLoadingInitial(false); - }); - - return () => { ctrl.abort(); }; - // genreFilter (not tags) as the dep — tags is derived from it and would - // cause an extra render on every parse; genreFilter is the stable identity. - }, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps - - // ── Derived merged list ─────────────────────────────────────────────────── - const filtered = useMemo(() => { - // For multi-tag: library results must match ALL tags - const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags)); - const libIds = new Set(libMatches.map((m) => m.id)); - const srcOnly = sourceManga.filter((m) => !libIds.has(m.id)); - return dedupeMangaById([...libMatches, ...srcOnly]); - }, [libraryManga, sourceManga, tags]); - - // ── Load more ───────────────────────────────────────────────────────────── - const hasMoreVisible = visibleCount < filtered.length; - const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); - const hasMore = hasMoreVisible || hasMoreNetwork; - - const loadMore = useCallback(async () => { - if (loadingMore) return; - - // Fast path: buffered results already in memory - if (hasMoreVisible) { - setVisibleCount((v) => v + PAGE_SIZE); - return; - } - - // Slow path: fetch next pages from sources - const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); - if (!sources.length) return; - - setLoadingMore(true); - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - try { - await runConcurrent(sources, async (src) => { - const page = nextPageRef.current.get(src.id)!; - if (ctrl.signal.aborted) return; - - const ps = getPageSet(src.id, "SEARCH", tags); - const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags); - - const result = await cache - .get<{ mangas: Manga[]; hasNextPage: boolean }>( - pageKey, - () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page, query: primaryTag }, - ctrl.signal, - ).then((d) => d.fetchSourceManga), - ) - .catch((e: any) => { - if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); - return null; - }); - - if (!result || ctrl.signal.aborted) return; - - ps.add(page); - nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1); - - const matching = tags.length > 1 - ? result.mangas.filter((m) => matchesAllTags(m, tags)) - : result.mangas; - - if (matching.length > 0) - setSourceManga((prev) => dedupeMangaById([...prev, ...matching])); - }, ctrl.signal); - } finally { - if (!ctrl.signal.aborted) { - setVisibleCount((v) => v + PAGE_SIZE); - setLoadingMore(false); - } - } - }, [loadingMore, hasMoreVisible, primaryTag, tags]); - - // ── Context menu ────────────────────────────────────────────────────────── - function openCtx(e: React.MouseEvent, m: Manga) { - e.preventDefault(); e.stopPropagation(); - setCtx({ x: e.clientX, y: e.clientY, manga: m }); - } - - function buildCtxItems(m: Manga): ContextMenuEntry[] { - return [ - { - label: m.inLibrary ? "In Library" : "Add to library", - icon: , - disabled: m.inLibrary, - onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) - .then(() => { - setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)); - cache.clear(CACHE_KEYS.LIBRARY); - }) - .catch(console.error), - }, - ...(folders.length > 0 ? [ - { separator: true } as ContextMenuEntry, - ...folders.map((f): ContextMenuEntry => ({ - label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, - icon: , - onClick: () => assignMangaToFolder(f.id, m.id), - })), - ] : []), - { separator: true }, - { - label: "New folder & add", - icon: , - onClick: () => { - const name = prompt("Folder name:"); - if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } - }, - }, - ]; - } - - const visibleItems = filtered.slice(0, visibleCount); - const label = tagsLabel(tags); - - return ( -
-
- - {label} - {loadingInitial && filtered.length === 0 ? null : ( - - {visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length} - - )} - {!loadingInitial && hasMoreNetwork && ( - More loading… - )} -
- - {loadingInitial && filtered.length === 0 ? ( -
- {Array.from({ length: 50 }).map((_, i) => ( -
-
-
-
- ))} -
- ) : filtered.length === 0 ? ( -
No manga found for "{label}".
- ) : ( -
- {visibleItems.map((m) => ( - - ))} - {hasMore && ( -
- -
- )} -
- )} - - {ctx && ( - setCtx(null)} /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/explore/MangaPreview.module.css b/src/components/explore/MangaPreview.module.css deleted file mode 100644 index 3b1f1f8..0000000 --- a/src/components/explore/MangaPreview.module.css +++ /dev/null @@ -1,395 +0,0 @@ -/* ── Animations ──────────────────────────────────────────────────────────── */ -@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } -@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } -@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } } - -/* ── Backdrop ────────────────────────────────────────────────────────────── */ -.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; - animation: fadeIn 0.12s ease both; - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); -} - -/* ── Modal shell ─────────────────────────────────────────────────────────── */ -.modal { - width: min(800px, calc(100vw - 48px)); - height: min(560px, calc(100vh - 80px)); - display: flex; - background: var(--bg-surface); - border: 1px solid var(--border-base); - border-radius: var(--radius-xl); - overflow: hidden; - animation: scaleIn 0.16s ease both; - box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4); -} - -/* ── Cover column ────────────────────────────────────────────────────────── */ -.coverCol { - 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-y: auto; overflow-x: hidden; - scrollbar-width: none; -} -.coverCol::-webkit-scrollbar { display: none; } - -.coverWrap { - position: relative; - width: 100%; -} - -.cover { - width: 100%; aspect-ratio: 2 / 3; object-fit: cover; - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - display: block; -} - -.coverSpinner { - 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); -} - -.coverActions { - display: flex; flex-direction: column; gap: var(--sp-2); -} - -/* ── Cover action buttons ────────────────────────────────────────────────── */ -.actionBtn { - display: flex; align-items: center; justify-content: 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; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - text-align: center; -} -.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); } -.actionBtn:disabled { opacity: 0.4; cursor: default; } - -.actionBtnActive { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} -.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); } - -.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); } - -.actionBtnLabel { - flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; -} - -/* ── Folder picker ───────────────────────────────────────────────────────── */ -.folderWrap { position: relative; width: 100%; } - -.folderMenu { - 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; -} - -.folderEmpty { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-faint); padding: var(--sp-2) var(--sp-3); -} - -.folderItem { - 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); -} -.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); } -.folderItemOn { color: var(--accent-fg); } - -.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; } - -.folderCreateRow { - display: flex; gap: var(--sp-1); padding: var(--sp-1); -} -.folderInput { - flex: 1; 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; min-width: 0; -} -.folderInput:focus { border-color: var(--border-focus); } - -.folderOkBtn { - 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); -} -.folderOkBtn:disabled { opacity: 0.4; cursor: default; } -.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); } - -.folderNewBtn { - 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); -} -.folderNewBtn:hover { color: var(--accent-fg); } - -/* ── Content column ──────────────────────────────────────────────────────── */ -.content { - flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; -} - -/* ── Header ──────────────────────────────────────────────────────────────── */ -.contentHeader { - 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; -} - -.titleBlock { - 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); -} - -.skByline { - height: 14px; width: 55%; - background: var(--bg-overlay); border-radius: var(--radius-sm); - animation: pulse 1.4s ease infinite; -} - -.closeBtn { - display: flex; align-items: center; justify-content: center; - width: 28px; height: 28px; border-radius: var(--radius-sm); - color: var(--text-faint); border: none; background: none; - cursor: pointer; flex-shrink: 0; - transition: color var(--t-base), background var(--t-base); -} -.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } - -/* ── Scrollable body ─────────────────────────────────────────────────────── */ -.contentBody { - flex: 1; overflow-y: auto; - padding: var(--sp-5) var(--sp-6); - display: flex; flex-direction: column; gap: var(--sp-4); - scrollbar-width: thin; -} - -/* ── Error banner ────────────────────────────────────────────────────────── */ -.errorBanner { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--color-warn, #f59e0b); - background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent); - border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent); - border-radius: var(--radius-sm); padding: 6px var(--sp-3); -} - -/* ── Skeleton rows ───────────────────────────────────────────────────────── */ -.skRow { - display: flex; gap: var(--sp-2); align-items: center; -} -.skBadge { - height: 20px; width: 54px; - background: var(--bg-overlay); border-radius: var(--radius-sm); - animation: pulse 1.4s ease infinite; -} - -.skDesc { - display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; -} -.skLine { - height: 13px; background: var(--bg-overlay); - border-radius: var(--radius-sm); - animation: pulse 1.4s ease infinite; -} - -/* ── Badges ──────────────────────────────────────────────────────────────── */ -.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); -} -.badgeGreen { - background: color-mix(in srgb, #22c55e 12%, transparent); - border-color: color-mix(in srgb, #22c55e 30%, transparent); - color: #22c55e; -} -.badgeDim { /* default */ } -.badgeAccent { - background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); -} -.badgeUnread { - background: color-mix(in srgb, #f59e0b 12%, transparent); - border-color: color-mix(in srgb, #f59e0b 30%, transparent); - color: #f59e0b; -} -.badgeNsfw { - background: color-mix(in srgb, #ef4444 12%, transparent); - border-color: color-mix(in srgb, #ef4444 30%, transparent); - color: #ef4444; -} - -/* ── Chapter box — clearly separated from description ────────────────────── */ -.chapterBox { - 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); -} - -.chapterLoading { - display: flex; align-items: center; gap: var(--sp-2); -} -.chapterLoadingLabel { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-faint); letter-spacing: var(--tracking-wide); -} - -.chapterMeta { - display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); -} - -.chapterLabel { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-muted); letter-spacing: var(--tracking-wide); -} - -.dlAllBtn { - 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: 3px 10px; border-radius: var(--radius-sm); - border: 1px solid var(--border-dim); background: none; color: var(--text-faint); - cursor: pointer; flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base); -} -.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); } -.dlAllBtn:disabled { opacity: 0.5; cursor: default; } - -.progressTrack { - height: 3px; background: var(--bg-overlay); - border-radius: var(--radius-full); overflow: hidden; -} -.progressFill { - height: 100%; background: var(--accent); - border-radius: var(--radius-full); - transition: width 0.3s ease; -} - -.readBtn { - display: flex; align-items: center; gap: var(--sp-2); - 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; align-self: flex-start; - transition: filter var(--t-base); -} -.readBtn:hover { filter: brightness(1.1); } - -/* ── Description block ───────────────────────────────────────────────────── */ -.descBlock { - 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; -} -.descOpen { - display: block; -webkit-line-clamp: unset; overflow: visible; -} - -.descToggle { - display: flex; align-items: center; gap: var(--sp-1); - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-faint); background: none; border: none; - cursor: pointer; padding: 0; align-self: flex-start; - transition: color var(--t-base); -} -.descToggle:hover { color: var(--accent-fg); } - -/* ── Genre tags ──────────────────────────────────────────────────────────── */ -.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); } - -.genreTag { - 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); -} - -.genreTagClickable { - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.genreTagClickable:hover { - color: var(--accent-fg); - border-color: var(--accent-dim); - background: var(--accent-muted); -} - -/* ── Metadata table ──────────────────────────────────────────────────────── */ -.metaTable { - display: flex; flex-direction: column; gap: 1px; - border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); -} - -.metaRow { - display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; -} -.metaKey { - 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; -} -.metaVal { - font-size: var(--text-sm); color: var(--text-secondary); - line-height: var(--leading-snug); -} -.metaLink { - 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); -} -.metaLink:hover { opacity: 0.75; } \ No newline at end of file diff --git a/src/components/explore/MangaPreview.tsx b/src/components/explore/MangaPreview.tsx deleted file mode 100644 index 33f7215..0000000 --- a/src/components/explore/MangaPreview.tsx +++ /dev/null @@ -1,569 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from "react"; -import { - X, BookmarkSimple, ArrowSquareOut, Play, - CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, -} from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { - GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, -} from "../../lib/queries"; -import { cache, CACHE_KEYS } from "../../lib/cache"; -import { useStore } from "../../store"; -import type { Manga, Chapter } from "../../lib/types"; -import s from "./MangaPreview.module.css"; - -export default function MangaPreview() { - const previewManga = useStore((st) => st.previewManga); - const setPreviewManga = useStore((st) => st.setPreviewManga); - const setActiveManga = useStore((st) => st.setActiveManga); - const setNavPage = useStore((st) => st.setNavPage); - const setGenreFilter = useStore((st) => st.setGenreFilter); - const openReader = useStore((st) => st.openReader); - const addToast = useStore((st) => st.addToast); - const folders = useStore((st) => st.settings.folders); - const addFolder = useStore((st) => st.addFolder); - const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); - const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder); - - const [manga, setManga] = useState(null); - const [chapters, setChapters] = useState([]); - const [loadingDetail, setLoadingDetail] = useState(false); - const [loadingChapters, setLoadingChapters] = useState(false); - const [togglingLib, setTogglingLib] = useState(false); - const [descExpanded, setDescExpanded] = useState(false); - const [folderOpen, setFolderOpen] = useState(false); - const [newFolderName, setNewFolderName] = useState(""); - const [creatingFolder, setCreatingFolder] = useState(false); - const [queueingAll, setQueueingAll] = useState(false); - const [fetchError, setFetchError] = useState(null); - - const backdropRef = useRef(null); - const detailAbort = useRef(null); - const chapterAbort = useRef(null); - const folderRef = useRef(null); - - const close = useCallback(() => { - detailAbort.current?.abort(); - chapterAbort.current?.abort(); - setPreviewManga(null); - setManga(null); - setChapters([]); - setDescExpanded(false); - setFolderOpen(false); - setCreatingFolder(false); - setNewFolderName(""); - setFetchError(null); - }, [setPreviewManga]); - - // ── Fetch detail + chapters on open ────────────────────────────────────── - useEffect(() => { - if (!previewManga) return; - - // Abort any in-flight requests from previous manga - detailAbort.current?.abort(); - chapterAbort.current?.abort(); - - const dCtrl = new AbortController(); - const cCtrl = new AbortController(); - detailAbort.current = dCtrl; - chapterAbort.current = cCtrl; - - setManga(null); - setChapters([]); - setDescExpanded(false); - setFetchError(null); - setLoadingDetail(true); - setLoadingChapters(true); - - const id = previewManga.id; - - // ── Detail fetch strategy ───────────────────────────────────────────── - // For source/explore manga we must call FETCH_MANGA (mutation that - // hits the source and syncs to the local DB). GET_MANGA only works for - // manga already in the local DB with full metadata. - // - // Fast path: if we already cached a full record, use it directly. - // Slow path: always try FETCH_MANGA first — it never fails for valid IDs - // and returns the richest data. Fall back to GET_MANGA if it errors. - // - (async (): Promise => { - const cacheKey = CACHE_KEYS.MANGA(id); - - // Already have a cached rich record — no network needed - if (cache.has(cacheKey)) { - return cache.get(cacheKey, () => - Promise.resolve(previewManga as Manga) - ) as Promise; - } - - // Try FETCH_MANGA first — works for all manga regardless of whether - // they are in the local DB yet (it fetches from source and syncs). - try { - const d = await gql<{ fetchManga: { manga: Manga } }>( - FETCH_MANGA, { id }, dCtrl.signal - ); - return d.fetchManga.manga; - } catch (e: any) { - if (e?.name === "AbortError") throw e; - // FETCH_MANGA failed (e.g. source offline) — fall back to local DB - const local = await gql<{ manga: Manga }>( - GET_MANGA, { id }, dCtrl.signal - ).then((d) => d.manga); - if (local) return local; - throw new Error("Could not load manga details"); - } - })() - .then((fullManga) => { - if (dCtrl.signal.aborted) return; - // Cache the rich record so re-opening is instant - if (!cache.has(CACHE_KEYS.MANGA(id))) { - cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga)); - } - setManga(fullManga); - setLoadingDetail(false); - }) - .catch((e) => { - if (e?.name === "AbortError") return; - console.error("MangaPreview detail fetch:", e); - // Show whatever sparse data we have from previewManga - setManga(previewManga as Manga); - setFetchError("Could not load full details — showing cached data"); - setLoadingDetail(false); - }); - - // ── Chapter fetch — local DB first, fall back to source fetch ──────── - gql<{ chapters: { nodes: Chapter[] } }>( - GET_CHAPTERS, { mangaId: id }, cCtrl.signal - ) - .then(async (d) => { - if (cCtrl.signal.aborted) return; - let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); - // If no local chapters yet (explore/source manga), fetch from source - if (nodes.length === 0) { - try { - const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>( - FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal - ); - if (!cCtrl.signal.aborted) - nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); - } catch (e: any) { - if (e?.name === "AbortError") return; - // Leave nodes empty — not a fatal error - } - } - if (!cCtrl.signal.aborted) setChapters(nodes); - }) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) - .finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); }); - - return () => { dCtrl.abort(); cCtrl.abort(); }; - }, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps - - // ── Keyboard close ──────────────────────────────────────────────────────── - useEffect(() => { - if (!previewManga) return; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [previewManga, close]); - - // ── Folder outside click ────────────────────────────────────────────────── - useEffect(() => { - if (!folderOpen) return; - const handler = (e: MouseEvent) => { - if (folderRef.current && !folderRef.current.contains(e.target as Node)) { - setFolderOpen(false); setCreatingFolder(false); setNewFolderName(""); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [folderOpen]); - - if (!previewManga) return null; - - // Always show title/cover from previewManga immediately; upgrade to fetched manga when ready - const displayManga = manga ?? previewManga; - const totalCount = chapters.length; - const readCount = chapters.filter((c) => c.isRead).length; - const unreadCount = totalCount - readCount; - const downloadedCount = chapters.filter((c) => c.isDownloaded).length; - const bookmarkCount = chapters.filter((c) => c.isBookmarked).length; - const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false; - - // Scanlators — deduplicated, non-empty - const scanlators = [...new Set( - chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim()) - )]; - - // Publication date range from chapter upload dates - const uploadDates = chapters - .map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null) - .filter((d): d is number => d !== null && !isNaN(d)); - const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null; - const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null; - - function formatDate(d: Date) { - return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); - } - - const statusLabel = displayManga.status - ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() - : null; - - const continueChapter = (() => { - if (!chapters.length) return null; - const asc = [...chapters]; - const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); - if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` }; - const firstUnread = asc.find((c) => !c.isRead); - if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` }; - return { ch: asc[0], label: "Read again" }; - })(); - - async function toggleLibrary() { - if (!manga) return; - setTogglingLib(true); - const next = !manga.inLibrary; - await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error); - const updated = { ...manga, inLibrary: next }; - setManga(updated); - // Update cache so subsequent opens reflect new state - cache.clear(CACHE_KEYS.MANGA(manga.id)); - cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated)); - cache.clear(CACHE_KEYS.LIBRARY); - setTogglingLib(false); - addToast({ 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; - setQueueingAll(true); - await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error); - addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` }); - setQueueingAll(false); - } - - function openSeriesDetail() { - setActiveManga(displayManga); - setNavPage("library"); - close(); - } - - function handleFolderCreate() { - const name = newFolderName.trim(); - if (!name || !previewManga) return; - const newId = addFolder(name); - assignMangaToFolder(newId, previewManga.id); - setNewFolderName(""); - setCreatingFolder(false); - } - - const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id)); - - return ( -
{ if (e.target === backdropRef.current) close(); }} - > -
- - {/* ── Cover column ── */} -
-
- {displayManga.title} - {loadingDetail && ( -
- -
- )} -
- -
- - - - - {/* Folder picker */} -
- - - {folderOpen && ( -
- {folders.length === 0 && !creatingFolder && ( -

No folders yet

- )} - {folders.map((f) => { - const isIn = f.mangaIds.includes(previewManga.id); - return ( - - ); - })} -
- {creatingFolder ? ( -
- setNewFolderName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleFolderCreate(); - if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); } - }} - /> - -
- ) : ( - - )} -
- )} -
-
-
- - {/* ── Content column ── */} -
- - {/* Header — title visible immediately from previewManga */} -
-
-

{displayManga.title}

- {loadingDetail - ?
- : (displayManga.author || displayManga.artist) - ?

- {[displayManga.author, displayManga.artist] - .filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")} -

- : null} -
- -
- - {/* Scrollable body */} -
- - {/* Error banner */} - {fetchError && ( -
{fetchError}
- )} - - {/* ── Badges ── */} - {loadingDetail ? ( -
-
-
-
- ) : ( -
- {statusLabel && ( - {statusLabel} - )} - {displayManga.source && ( - - {displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""} - - )} - {inLibrary && In Library} - {!loadingChapters && unreadCount > 0 && ( - {unreadCount} unread - )} - {!loadingChapters && bookmarkCount > 0 && ( - {bookmarkCount} bookmarked - )} -
- )} - - {/* ── Chapter section — visually separated box ── */} -
- {loadingChapters ? ( -
- - Loading chapters… -
- ) : totalCount > 0 ? ( - <> -
- - {totalCount} {totalCount === 1 ? "chapter" : "chapters"} - {readCount > 0 && ` · ${readCount} read`} - {unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`} - {downloadedCount > 0 && ` · ${downloadedCount} dl`} - - {unreadCount > 0 && ( - - )} -
- {readCount > 0 && ( -
-
-
- )} - {continueChapter && ( - - )} - - ) : !loadingDetail ? ( - - No chapters in local library - - ) : null} -
- - {/* ── Description — clearly separated from chapter block ── */} - {loadingDetail ? ( -
-
-
-
-
- ) : displayManga.description ? ( -
-

- {displayManga.description} -

- {displayManga.description.length > 220 && ( - - )} -
- ) : null} - - {/* ── Genre tags ── */} - {!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && ( -
- {displayManga.genre.map((g) => ( - - ))} -
- )} - - {/* ── Metadata table ── */} - {!loadingDetail && ( -
- {displayManga.author && ( -
- Author - {displayManga.author} -
- )} - {displayManga.artist && displayManga.artist !== displayManga.author && ( -
- Artist - {displayManga.artist} -
- )} - {statusLabel && ( -
- Status - {statusLabel} -
- )} - {displayManga.source && ( -
- Source - {displayManga.source.displayName} -
- )} - {!loadingChapters && scanlators.length > 0 && ( -
- {scanlators.length === 1 ? "Scanlator" : "Scanlators"} - {scanlators.join(", ")} -
- )} - {!loadingChapters && firstUpload && lastUpload && ( -
- Published - - {firstUpload.getTime() === lastUpload.getTime() - ? formatDate(firstUpload) - : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`} - -
- )} - {!loadingChapters && downloadedCount > 0 && ( -
- Downloaded - {downloadedCount} / {totalCount} chapters -
- )} - {!loadingChapters && bookmarkCount > 0 && ( -
- Bookmarks - {bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""} -
- )} - {displayManga.realUrl && ( -
- Link - - Open - -
- )} -
- )} - -
-
- -
-
- ); -} \ No newline at end of file diff --git a/src/components/extensions/ExtensionList.module.css b/src/components/extensions/ExtensionList.module.css deleted file mode 100644 index 0755073..0000000 --- a/src/components/extensions/ExtensionList.module.css +++ /dev/null @@ -1,278 +0,0 @@ -.root { - display: flex; flex-direction: column; height: 100%; - overflow: hidden; animation: fadeIn 0.14s ease both; -} -.header { - display: flex; align-items: center; justify-content: space-between; - padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0; -} -.heading { - font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); - color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; -} -.headerActions { display: flex; gap: var(--sp-1); } -.iconBtn { - display: flex; align-items: center; justify-content: center; - width: 28px; height: 28px; border-radius: var(--radius-md); - color: var(--text-muted); transition: color var(--t-base), background var(--t-base); -} -.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } -.iconBtn:disabled { opacity: 0.4; } -.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); } -.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); } - -.externalPanel { - display: flex; flex-direction: column; gap: var(--sp-2); - padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; - animation: fadeIn 0.1s ease both; -} -.externalHeader { - display: flex; align-items: center; justify-content: space-between; -} -.externalTitle { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-muted); letter-spacing: var(--tracking-wide); -} -.externalRow { - display: flex; gap: var(--sp-2); -} -.externalInput { - flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); - border-radius: var(--radius-md); padding: 6px var(--sp-3); - color: var(--text-primary); font-size: var(--text-sm); outline: none; - transition: border-color var(--t-base); -} -.externalInput:focus { border-color: var(--border-focus); } -.externalInput:disabled { opacity: 0.5; } -.externalInputError { border-color: var(--color-error) !important; } -.externalError { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--color-error); letter-spacing: var(--tracking-wide); - padding: 0 2px; -} -.installBtn { - display: flex; align-items: center; gap: var(--sp-1); - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - padding: 6px 14px; border-radius: var(--radius-md); - background: var(--accent-muted); color: var(--accent-fg); - border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; - transition: filter var(--t-base), opacity var(--t-base); - white-space: nowrap; -} -.installBtn:hover:not(:disabled) { filter: brightness(1.1); } -.installBtn:disabled { opacity: 0.5; cursor: default; } -.installBtnSuccess { - background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f); - color: #fff; -} - -.controls { - display: flex; align-items: center; justify-content: space-between; - padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0; -} -.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); } -.tabActive { background: var(--accent-muted); color: var(--accent-fg); } -.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); } - -.searchWrap { position: relative; display: flex; align-items: center; } -.searchIcon { 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: 160px; outline: none; - transition: border-color var(--t-base); -} -.search::placeholder { color: var(--text-faint); } -.search:focus { border-color: var(--border-strong); } - -.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; } - -.group { display: flex; flex-direction: column; } - -.row { - display: flex; align-items: center; gap: var(--sp-3); - padding: 8px var(--sp-3); border-radius: var(--radius-md); - border: 1px solid transparent; - transition: background var(--t-fast), border-color var(--t-fast); -} -.row:hover { background: var(--bg-raised); border-color: var(--border-dim); } - -.icon { - width: 32px; height: 32px; border-radius: var(--radius-md); - object-fit: cover; flex-shrink: 0; background: var(--bg-raised); -} -.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } -.name { - font-size: var(--text-base); font-weight: var(--weight-medium); - color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.meta { - display: 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-wide); -} - -.langTag { - background: var(--bg-overlay); border: 1px solid var(--border-dim); - border-radius: var(--radius-sm); padding: 1px 5px; - font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--text-muted); letter-spacing: var(--tracking-wider); -} -.nsfwTag { - background: transparent; border: 1px solid var(--color-error); - border-radius: var(--radius-sm); padding: 1px 5px; - font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--color-error); letter-spacing: var(--tracking-wider); -} -.updateBadge { - font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); - background: var(--accent-muted); color: var(--accent-fg); - border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); - padding: 2px 6px; flex-shrink: 0; -} -.updateBadgeSmall { - font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--accent-fg); flex-shrink: 0; -} - -.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; } -.actionBtn { - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - padding: 4px 10px; border-radius: var(--radius-md); - background: var(--accent-muted); color: var(--accent-fg); - border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; - transition: filter var(--t-base); -} -.actionBtn:hover { filter: brightness(1.1); } -.actionBtnDim { - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - padding: 4px 10px; border-radius: var(--radius-md); - background: none; color: var(--text-faint); - border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base); -} -.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); } - -.expandBtn { - display: flex; align-items: center; gap: 3px; - padding: 4px 6px; border-radius: var(--radius-sm); - color: var(--text-faint); flex-shrink: 0; - transition: color var(--t-base), background var(--t-base); -} -.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); } -.expandCount { - font-family: var(--font-ui); font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); -} - -.variants { - display: flex; flex-direction: column; gap: 1px; - margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); - padding-left: var(--sp-3); - border-left: 1px solid var(--border-dim); - animation: fadeIn 0.1s ease both; -} -.variantRow { - display: flex; align-items: center; gap: var(--sp-2); - padding: 5px var(--sp-2); border-radius: var(--radius-md); - transition: background var(--t-fast); -} -.variantRow:hover { background: var(--bg-raised); } -.variantName { - flex: 1; font-size: var(--text-sm); color: var(--text-muted); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.variantVersion { - font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; -} -.variantActions { flex-shrink: 0; } - -.empty { - display: flex; align-items: center; justify-content: center; - flex: 1; color: var(--text-faint); - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); -} - -/* ── Panel shared styles ── */ -.externalPanel { - display: flex; flex-direction: column; gap: var(--sp-2); - padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; - animation: fadeIn 0.1s ease both; -} -.panelHeader { - display: flex; align-items: center; justify-content: space-between; -} -.panelTitle { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-muted); letter-spacing: var(--tracking-wide); -} -.panelError { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--color-error); letter-spacing: var(--tracking-wide); - padding: 0 2px; -} -.externalRow { display: flex; gap: var(--sp-2); } -.externalInput { - flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); - border-radius: var(--radius-md); padding: 6px var(--sp-3); - color: var(--text-primary); font-size: var(--text-sm); outline: none; - transition: border-color var(--t-base); -} -.externalInput:focus { border-color: var(--border-focus); } -.externalInput:disabled { opacity: 0.5; } -.externalInputError { border-color: var(--color-error) !important; } -.installBtn { - display: flex; align-items: center; gap: var(--sp-1); - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - padding: 6px 14px; border-radius: var(--radius-md); - background: var(--accent-muted); color: var(--accent-fg); - border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; - transition: filter var(--t-base), opacity var(--t-base); - white-space: nowrap; -} -.installBtn:hover:not(:disabled) { filter: brightness(1.1); } -.installBtn:disabled { opacity: 0.5; cursor: default; } -.installBtnSuccess { - background: color-mix(in srgb, var(--accent-fg) 20%, transparent); - border-color: var(--accent-fg); color: var(--accent-fg); -} - -/* ── Repo list ── */ -.repoLoading { - display: flex; align-items: center; justify-content: center; - padding: var(--sp-3); -} -.repoEmpty { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-faint); letter-spacing: var(--tracking-wide); - padding: var(--sp-1) 2px; -} -.repoList { - display: flex; flex-direction: column; gap: 2px; -} -.repoRow { - display: flex; align-items: center; gap: var(--sp-2); - padding: 5px var(--sp-2); border-radius: var(--radius-md); - background: var(--bg-raised); border: 1px solid var(--border-dim); -} -.repoUrl { - flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs); - color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - letter-spacing: 0; -} -.repoRemoveBtn { - display: flex; align-items: center; justify-content: center; - width: 20px; height: 20px; border-radius: var(--radius-sm); - color: var(--text-faint); flex-shrink: 0; - transition: color var(--t-base), background var(--t-base); -} -.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); } -.repoRemoveBtn:disabled { opacity: 0.4; } \ No newline at end of file diff --git a/src/components/extensions/ExtensionList.tsx b/src/components/extensions/ExtensionList.tsx deleted file mode 100644 index 588ff41..0000000 --- a/src/components/extensions/ExtensionList.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import { useEffect, useState, useMemo } from "react"; -import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { - GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, - GET_SETTINGS, SET_EXTENSION_REPOS, -} from "../../lib/queries"; -import { useStore } from "../../store"; -import type { Extension } from "../../lib/types"; -import s from "./ExtensionList.module.css"; - -type Filter = "installed" | "available" | "updates" | "all"; -type Panel = null | "apk" | "repos"; - -function baseName(name: string): string { - return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); -} - -interface ExtGroup { - base: string; - primary: Extension; - variants: Extension[]; -} - -export default function ExtensionList() { - const [extensions, setExtensions] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [filter, setFilter] = useState("installed"); - const [search, setSearch] = useState(""); - const [working, setWorking] = useState>(new Set()); - const [expanded, setExpanded] = useState>(new Set()); - const [panel, setPanel] = useState(null); - - // APK install state - const [externalUrl, setExternalUrl] = useState(""); - const [installing, setInstalling] = useState(false); - const [installError, setInstallError] = useState(null); - const [installSuccess, setInstallSuccess] = useState(false); - - // Repo management state - const [repos, setRepos] = useState([]); - const [reposLoading, setReposLoading] = useState(false); - const [newRepoUrl, setNewRepoUrl] = useState(""); - const [repoError, setRepoError] = useState(null); - const [savingRepos, setSavingRepos] = useState(false); - - const preferredLang = useStore((s) => s.settings.preferredExtensionLang); - - async function load() { - return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS) - .then((d) => setExtensions(d.extensions.nodes)) - .catch(console.error); - } - - async function fetchFromRepo() { - setRefreshing(true); - return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS) - .then((d) => setExtensions(d.fetchExtensions.extensions)) - .catch(console.error) - .finally(() => setRefreshing(false)); - } - - async function loadRepos() { - setReposLoading(true); - try { - const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); - setRepos(d.settings.extensionRepos ?? []); - } catch (e) { - console.error(e); - } finally { - setReposLoading(false); - } - } - - async function saveRepos(updated: string[]) { - setSavingRepos(true); - try { - const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>( - SET_EXTENSION_REPOS, { repos: updated } - ); - setRepos(d.setSettings.settings.extensionRepos); - } catch (e: unknown) { - setRepoError(e instanceof Error ? e.message : "Failed to save"); - } finally { - setSavingRepos(false); - } - } - - function addRepo() { - const url = newRepoUrl.trim(); - if (!url) return; - if (!url.startsWith("http://") && !url.startsWith("https://")) { - setRepoError("URL must start with http:// or https://"); - return; - } - if (repos.includes(url)) { - setRepoError("Repo already added"); - return; - } - setRepoError(null); - setNewRepoUrl(""); - saveRepos([...repos, url]); - } - - function removeRepo(url: string) { - saveRepos(repos.filter((r) => r !== url)); - } - - const mutate = async (fn: () => Promise, pkgName: string) => { - setWorking((p) => new Set(p).add(pkgName)); - await fn().catch(console.error); - await load(); - setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; }); - }; - - async function installExternal() { - const url = externalUrl.trim(); - if (!url) return; - if (!url.startsWith("http://") && !url.startsWith("https://")) { - setInstallError("URL must start with http:// or https://"); - return; - } - if (!url.endsWith(".apk")) { - setInstallError("URL must point to an .apk file"); - return; - } - setInstalling(true); - setInstallError(null); - setInstallSuccess(false); - try { - await gql(INSTALL_EXTERNAL_EXTENSION, { url }); - setInstallSuccess(true); - setExternalUrl(""); - await load(); - setTimeout(() => { - setPanel(null); - setInstallSuccess(false); - }, 1500); - } catch (e: unknown) { - setInstallError(e instanceof Error ? e.message : "Install failed"); - } finally { - setInstalling(false); - } - } - - function openPanel(p: Panel) { - if (panel === p) { - setPanel(null); - return; - } - setPanel(p); - setInstallError(null); - setInstallSuccess(false); - setExternalUrl(""); - setRepoError(null); - setNewRepoUrl(""); - if (p === "repos") loadRepos(); - } - - useEffect(() => { - fetchFromRepo().finally(() => setLoading(false)); - }, []); - - const filtered = extensions.filter((e) => { - const q = search.toLowerCase(); - const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q); - const matchFilter = - filter === "installed" ? e.isInstalled : - filter === "available" ? !e.isInstalled : - filter === "updates" ? e.hasUpdate : true; - return matchSearch && matchFilter; - }); - - const groups = useMemo(() => { - const map = new Map(); - for (const ext of filtered) { - const key = baseName(ext.name); - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(ext); - } - return Array.from(map.entries()).map(([base, all]) => { - const primary = - all.find((v) => v.lang === preferredLang) ?? - all.find((v) => v.lang === "en") ?? - all[0]; - const variants = all.filter((v) => v.pkgName !== primary.pkgName); - return { base, primary, variants }; - }); - }, [filtered, preferredLang]); - - const updateCount = extensions.filter((e) => e.hasUpdate).length; - - const FILTERS: { id: Filter; label: string }[] = [ - { id: "installed", label: "Installed" }, - { id: "available", label: "Available" }, - { id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" }, - { id: "all", label: "All" }, - ]; - - function toggleExpand(base: string) { - setExpanded((p) => { - const n = new Set(p); - n.has(base) ? n.delete(base) : n.add(base); - return n; - }); - } - - function renderActions(ext: Extension) { - if (working.has(ext.pkgName)) - return ; - if (ext.hasUpdate) return ( -
- - -
- ); - if (ext.isInstalled) - return ; - return ; - } - - return ( -
-
-

Extensions

-
- - - -
-
- - {/* ── APK install panel ── */} - {panel === "apk" && ( -
-
- Install from APK URL - -
-
- { setExternalUrl(e.target.value); setInstallError(null); }} - onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()} - autoFocus - disabled={installing} - /> - -
- {installError &&
{installError}
} -
- )} - - {/* ── Repo management panel ── */} - {panel === "repos" && ( -
-
- Extension Repositories - -
- - {reposLoading ? ( -
- -
- ) : ( - <> - {repos.length === 0 ? ( -
No repos configured.
- ) : ( -
- {repos.map((url) => ( -
- {url} - -
- ))} -
- )} - -
- { setNewRepoUrl(e.target.value); setRepoError(null); }} - onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()} - disabled={savingRepos} - /> - -
- {repoError &&
{repoError}
} - - )} -
- )} - -
-
- {FILTERS.map((f) => ( - - ))} -
-
- - setSearch(e.target.value)} /> -
-
- - {loading ? ( -
- -
- ) : groups.length === 0 ? ( -
No extensions found.
- ) : ( -
- {groups.map(({ base, primary, variants }) => { - const isExpanded = expanded.has(base); - const hasVariants = variants.length > 0; - return ( -
-
- {primary.name} { (e.target as HTMLImageElement).style.display = "none"; }} /> -
- {base} - - {primary.lang.toUpperCase()} - {" "}v{primary.versionName} - -
- {primary.hasUpdate && Update} - {renderActions(primary)} - {hasVariants && ( - - )} -
- {isExpanded && hasVariants && ( -
- {variants.map((v) => ( -
- {v.lang.toUpperCase()} - {v.name} - v{v.versionName} - {v.hasUpdate && } -
{renderActions(v)}
-
- ))} -
- )} -
- ); - })} -
- )} -
- ); -} \ No newline at end of file diff --git a/src/components/history/History.svelte b/src/components/history/History.svelte new file mode 100644 index 0000000..d713392 --- /dev/null +++ b/src/components/history/History.svelte @@ -0,0 +1 @@ +
History.svelte
diff --git a/src/components/layout/Layout.module.css b/src/components/layout/Layout.module.css deleted file mode 100644 index 54f9f16..0000000 --- a/src/components/layout/Layout.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.root { - display: flex; - height: 100%; - background: var(--bg-base); - overflow: hidden; -} - -.main { - flex: 1; - overflow: hidden; - background: var(--bg-surface); - /* GPU layer for main content area */ - transform: translateZ(0); - contain: layout style; -} \ No newline at end of file diff --git a/src/components/layout/Layout.svelte b/src/components/layout/Layout.svelte new file mode 100644 index 0000000..40f9582 --- /dev/null +++ b/src/components/layout/Layout.svelte @@ -0,0 +1,44 @@ + + +
+ +
+ {#if $activeManga} + + {:else if $navPage === "library"} + + {:else if $navPage === "search"} + + {:else if $navPage === "history"} + + {:else if $navPage === "explore" || $navPage === "sources"} + + {:else if $navPage === "downloads"} + + {:else if $navPage === "extensions"} + + {:else} + + {/if} +
+
+ + diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx deleted file mode 100644 index e018d09..0000000 --- a/src/components/layout/Layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useStore } from "../../store"; -import Sidebar from "./Sidebar"; -import Library from "../pages/Library"; -import SeriesDetail from "../pages/SeriesDetail"; -import History from "../pages/History"; -import Search from "../pages/Search"; -import Explore from "../explore/Explore"; -import DownloadQueue from "../downloads/DownloadQueue"; -import ExtensionList from "../extensions/ExtensionList"; -import s from "./Layout.module.css"; - -export default function Layout() { - const navPage = useStore((s) => s.navPage); - const activeManga = useStore((s) => s.activeManga); - - function renderContent() { - if (activeManga) return ; - switch (navPage) { - case "library": return ; - case "search": return ; - case "history": return ; - case "sources": return ; - case "explore": return ; - case "downloads": return ; - case "extensions": return ; - default: return ; - } - } - - return ( -
- -
{renderContent()}
-
- ); -} \ No newline at end of file diff --git a/src/components/layout/Sidebar.module.css b/src/components/layout/Sidebar.module.css deleted file mode 100644 index 4b5cca6..0000000 --- a/src/components/layout/Sidebar.module.css +++ /dev/null @@ -1,107 +0,0 @@ -.root { - width: var(--sidebar-width); - flex-shrink: 0; - background: var(--bg-void); - display: flex; - flex-direction: column; - align-items: center; - padding: var(--sp-4) 0; - gap: 0; -} - -.logo { - width: 80px; - height: 80px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: var(--sp-3); - overflow: visible; - /* Explicit reset — prevents browser from injecting a default button background */ - background: none; - border: none; - outline: none; - cursor: pointer; - border-radius: var(--radius-lg); - transition: opacity var(--t-base), transform var(--t-base); - padding: 0; - -webkit-appearance: none; - appearance: none; -} -.logo:hover { opacity: 0.8; transform: scale(0.96); } -.logo:active { transform: scale(0.92); } -/* Kill the focus ring that can render as a coloured glow on some GTK themes */ -.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } - -.logoIcon { - width: 80px; - height: 80px; - background-color: var(--accent); - mask-image: url("../../assets/moku-icon.svg"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - -webkit-mask-image: url("../../assets/moku-icon.svg"); - -webkit-mask-repeat: no-repeat; - -webkit-mask-position: center; - -webkit-mask-size: contain; - filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35)); - pointer-events: none; -} - -.nav { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: var(--sp-1); - width: 100%; - padding: 0 var(--sp-2); -} - -.tab { - width: 36px; height: 36px; - display: flex; align-items: center; justify-content: center; - border-radius: var(--radius-md); - color: var(--text-faint); - /* Explicit resets — the green overlay was browser default button styles bleeding through */ - background: none; - border: none; - outline: none; - cursor: pointer; - padding: 0; - -webkit-appearance: none; - appearance: none; - transition: color var(--t-base), background var(--t-base); -} -.tab:hover { color: var(--text-muted); background: var(--bg-raised); } -.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); } - -.tabActive { color: var(--accent-fg); background: var(--accent-muted); } -/* Prevent hover state from overriding active colour */ -.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); } - -.bottom { - display: flex; flex-direction: column; align-items: center; - width: 100%; padding: var(--sp-3) var(--sp-2) 0; - border-top: 1px solid var(--border-dim); - margin-top: var(--sp-3); -} - -.settingsBtn { - width: 36px; height: 36px; - display: flex; align-items: center; justify-content: center; - border-radius: var(--radius-md); - color: var(--text-faint); - /* Same explicit resets */ - background: none; - border: none; - outline: none; - cursor: pointer; - padding: 0; - -webkit-appearance: none; - appearance: none; - transition: color var(--t-base), background var(--t-base), transform var(--t-slow); -} -.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } -.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } \ No newline at end of file diff --git a/src/components/layout/Sidebar.svelte b/src/components/layout/Sidebar.svelte new file mode 100644 index 0000000..92719de --- /dev/null +++ b/src/components/layout/Sidebar.svelte @@ -0,0 +1,122 @@ + + + + + diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx deleted file mode 100644 index 6da1256..0000000 --- a/src/components/layout/Sidebar.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - Books, DownloadSimple, PuzzlePiece, Compass, - GearSix, ClockCounterClockwise, MagnifyingGlass, -} from "@phosphor-icons/react"; -import { useStore, type NavPage } from "../../store"; -import s from "./Sidebar.module.css"; - -const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [ - { id: "library", icon: , label: "Library" }, - { id: "search", icon: , label: "Search" }, - { id: "history", icon: , label: "History" }, - { id: "explore", icon: , label: "Explore" }, - { id: "downloads", icon: , label: "Downloads" }, - { id: "extensions", icon: , label: "Extensions" }, -]; - -export default function Sidebar() { - const navPage = useStore((state) => state.navPage); - const setNavPage = useStore((state) => state.setNavPage); - const setActiveSource = useStore((state) => state.setActiveSource); - const setActiveManga = useStore((state) => state.setActiveManga); - const setLibraryFilter = useStore((state) => state.setLibraryFilter); - const setGenreFilter = useStore((state) => state.setGenreFilter); - const openSettings = useStore((state) => state.openSettings); - - function navigate(id: NavPage) { - setNavPage(id); - setActiveManga(null); - setGenreFilter(""); - if (id !== "explore") setActiveSource(null); - } - - function goHome() { - setNavPage("library"); - setActiveSource(null); - setActiveManga(null); - setLibraryFilter("library"); - } - - return ( - - ); -} \ No newline at end of file diff --git a/src/components/layout/SplashScreen.svelte b/src/components/layout/SplashScreen.svelte new file mode 100644 index 0000000..0212deb --- /dev/null +++ b/src/components/layout/SplashScreen.svelte @@ -0,0 +1,12 @@ + +
SplashScreen stub
diff --git a/src/components/layout/SplashScreen.tsx b/src/components/layout/SplashScreen.tsx deleted file mode 100644 index cd4ca96..0000000 --- a/src/components/layout/SplashScreen.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import logoUrl from "../../assets/moku-icon.svg"; -import { getCurrentWindow } from "@tauri-apps/api/window"; - -export type SplashMode = "loading" | "idle"; -export const EXIT_MS = 320; - -interface Props { - mode: SplashMode; - ringFull?: boolean; - failed?: boolean; - showCards?: boolean; - showFps?: boolean; - onReady?: () => void; - onRetry?: () => void; - onDismiss?: () => void; -} - -// ── Hash ────────────────────────────────────────────────────────────────────── -function hash(n: number): number { - let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b); - x = Math.imul(x ^ (x >>> 16), 0x45d9f3b); - return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff; -} - -// ── Card definition ─────────────────────────────────────────────────────────── -interface CardDef { - layer: 0 | 1 | 2; - cx: number; - w: number; - h: number; - lines: number; - alpha: number; - speed: number; - cycleSec: number; - phase: number; - travel: number; - yStart: number; - angleStart: number; - tilt: number; -} - -interface CardTrig { cosA: number; sinA: number; tiltRad: number; } - -const LAYER_CFG = [ - { wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 }, - { wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 }, - { wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 }, -] as const; - -const BUF = 80; -const COLS = 14; - -function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } { - const cards: CardDef[] = []; - const laneW = vw / COLS; - for (let layer = 0; layer < 3; layer++) { - const cfg = LAYER_CFG[layer]; - for (let col = 0; col < COLS; col++) { - const seed = col * 31 + layer * 97 + 7; - const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin); - const h = w * 1.44; - const maxNudge = (laneW - w) / 2 - 2; - const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge); - const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin); - const travel = vh + h + BUF; - cards.push({ - layer: layer as 0 | 1 | 2, - cx, w, h, - lines: 1 + Math.floor(hash(seed + 7) * 3), - alpha: cfg.alpha, - speed, - cycleSec: travel / speed, - phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1, - travel, - yStart: vh + h / 2 + BUF / 2, - angleStart: hash(seed + 3) * 50 - 25, - tilt: (hash(seed + 4) * 2 - 1) * 18, - }); - } - } - const trigs: CardTrig[] = cards.map(c => ({ - cosA: Math.cos(c.angleStart * (Math.PI / 180)), - sinA: Math.sin(c.angleStart * (Math.PI / 180)), - tiltRad: c.tilt * (Math.PI / 180), - })); - return { cards, trigs }; -} - -// ── Rounded rect ────────────────────────────────────────────────────────────── -function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); - ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); - ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); - ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); - ctx.closePath(); -} - -// ── Stamp builder ───────────────────────────────────────────────────────────── -const STAMP_PAD = 6; - -function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement { - const logW = Math.ceil(c.w + STAMP_PAD * 2); - const logH = Math.ceil(c.h + STAMP_PAD * 2); - const oc = document.createElement("canvas"); - oc.width = Math.round(logW * dpr); - oc.height = Math.round(logH * dpr); - const ctx = oc.getContext("2d")!; - ctx.scale(dpr, dpr); - - const x0 = STAMP_PAD; - const y0 = STAMP_PAD; - const coverH = (c.w * 0.72) * 1.05; - const lineY0 = y0 + 3 + coverH + 5; - - ctx.fillStyle = "rgba(0,0,0,0.5)"; - rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill(); - - ctx.fillStyle = "rgba(255,255,255,0.07)"; - rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill(); - - ctx.strokeStyle = "rgba(255,255,255,0.75)"; - ctx.lineWidth = 1.2; - rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke(); - - ctx.fillStyle = "rgba(255,255,255,0.15)"; - rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill(); - - ctx.fillStyle = "rgba(255,255,255,0.08)"; - rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill(); - - for (let li = 0; li < c.lines; li++) { - ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)"; - ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2); - } - - return oc; -} - -// ── Vignette builder ────────────────────────────────────────────────────────── -function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement { - const oc = document.createElement("canvas"); - oc.width = Math.round(vw * dpr); - oc.height = Math.round(vh * dpr); - const ctx = oc.getContext("2d")!; - ctx.scale(dpr, dpr); - const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65); - g.addColorStop(0.15, "rgba(0,0,0,0)"); - g.addColorStop(1, "rgba(0,0,0,0.82)"); - ctx.fillStyle = g; - ctx.fillRect(0, 0, vw, vh); - return oc; -} - -// ── Draw frame ──────────────────────────────────────────────────────────────── -function drawFrame( - ctx: CanvasRenderingContext2D, - t: number, - cw: number, - ch: number, - dpr: number, - cards: CardDef[], - trigs: CardTrig[], - stamps: HTMLCanvasElement[], - vignette: HTMLCanvasElement, -) { - ctx.clearRect(0, 0, cw, ch); - - for (let i = 0; i < cards.length; i++) { - const c = cards[i]; - const p = ((t / c.cycleSec) + c.phase) % 1; - - const alpha = p < 0.07 - ? (p / 0.07) * c.alpha - : p > 0.86 - ? ((1 - p) / 0.14) * c.alpha - : c.alpha; - - if (alpha < 0.005) continue; - - const cy = c.yStart - p * c.travel; - const tg = trigs[i]; - const delta = tg.tiltRad * p; - const cosDelta = Math.cos(delta); - const sinDelta = Math.sin(delta); - const cos = tg.cosA * cosDelta - tg.sinA * sinDelta; - const sin = tg.sinA * cosDelta + tg.cosA * sinDelta; - - ctx.globalAlpha = alpha; - ctx.setTransform( - cos * dpr, sin * dpr, - -sin * dpr, cos * dpr, - c.cx * dpr, cy * dpr, - ); - // Draw stamp at its natural logical size. - // The stamp was baked at (logical * dpr) physical pixels. - // setTransform already applied dpr scaling, so drawing at logical size - // means the stamp maps 1:1 to physical pixels — zero resampling, zero blur. - const sw = stamps[i].width / dpr; - const sh = stamps[i].height / dpr; - ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh); - } - - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.globalAlpha = 1; - ctx.drawImage(vignette, 0, 0, cw, ch); -} - -// ── Ring ────────────────────────────────────────────────────────────────────── -function Ring({ progress }: { progress: number }) { - const r = 44, sw = 2, pad = 8; - const size = (r + pad) * 2, c = r + pad; - const circ = 2 * Math.PI * r; - const arc = circ * Math.min(Math.max(progress, 0.025), 0.999); - return ( - - - - - ); -} - -// ── FPS counter ─────────────────────────────────────────────────────────────── -function FpsCounter() { - const divRef = useRef(null); - const times = useRef([]); - - useEffect(() => { - let raf = 0; - function tick(now: number) { - const arr = times.current; - arr.push(now); - if (arr.length > 60) arr.shift(); - if (arr.length > 1 && divRef.current) { - const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000)); - divRef.current.textContent = `${fps} fps`; - divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171"; - } - raf = requestAnimationFrame(tick); - } - raf = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf); - }, []); - - return ( -
-- fps
- ); -} - - -// ── CardCanvas ──────────────────────────────────────────────────────────────── -// -// Strategy: best of both worlds. -// -// LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale) -// Cards fill the actual window shape correctly at any size. -// -// QUALITY → physical pixels (Tauri innerSize + scaleFactor) -// Canvas buffer = physical pixels, stamps baked at the true OS DPR. -// No WebKitGTK lies, no late compositor hints, always pixel-perfect. -// -// On every resize both are re-derived together so fullscreen, half-split, -// monitor switch — all produce crisp, correctly-proportioned cards. -// -function CardCanvas({ showFps }: { showFps: boolean }) { - const ref = useRef(null); - - useEffect(() => { - const canvas = ref.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false }); - if (!ctx) return; - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - - const win = getCurrentWindow(); - - // ── Live render state ──────────────────────────────────────────────────── - // The frame loop only ever reads from `live`. syncSize builds a complete - // replacement object off-thread then swaps it in one atomic assignment — - // no frame ever sees a half-rebuilt state. - interface RenderState { - cards: ReturnType["cards"]; - trigs: ReturnType["trigs"]; - stamps: HTMLCanvasElement[]; - vignette: HTMLCanvasElement; - CW: number; CH: number; scale: number; - } - let live: RenderState | null = null; - - // Track what we last built so we skip no-op resize events. - let lastLogW = 0, lastLogH = 0, lastScale = 0; - // Debounce: if a new resize arrives while one is in-flight, we only - // want the most recent result. A simple generation counter handles this. - let buildGen = 0; - - async function syncSize() { - const gen = ++buildGen; - - const [phys, scale] = await Promise.all([ - win.innerSize(), - win.scaleFactor(), - ]); - - // Another resize fired while we were awaiting — our result is stale. - if (gen !== buildGen) return; - - const physW = phys.width; - const physH = phys.height; - const logW = physW / scale; - const logH = physH / scale; - - if (logW === lastLogW && logH === lastLogH && scale === lastScale) return; - lastLogW = logW; lastLogH = logH; lastScale = scale; - - // Build everything into a local staging object — nothing visible changes yet. - const built = buildCards(logW, logH); - const stamps = built.cards.map(c => buildStamp(c, scale)); - const vig = buildVignette(logW, logH, scale); - - // One atomic swap — the frame loop immediately sees the complete new state. - // Canvas dimensions are updated here too so they're always in sync with - // the render state that uses them. - canvas!.width = physW; - canvas!.height = physH; - live = { - cards: built.cards, trigs: built.trigs, - stamps, vignette: vig, - CW: physW, CH: physH, scale, - }; - - console.log( - `[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`, - `physical ${physW}×${physH} @${scale.toFixed(3)}×`, - ); - } - - const ro = new ResizeObserver(() => syncSize()); - ro.observe(canvas); - syncSize(); - - let raf = 0, t0 = -1; - function frame(now: number) { - raf = requestAnimationFrame(frame); - if (!live) return; - if (t0 < 0) t0 = now; - const { cards, trigs, stamps, vignette, CW, CH, scale } = live; - drawFrame(ctx!, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette); - } - raf = requestAnimationFrame(frame); - - return () => { - cancelAnimationFrame(raf); - ro.disconnect(); - }; - }, []); - - return ( - <> - - {showFps && } - - ); -} - -// ── Static CSS ──────────────────────────────────────────────────────────────── -const STATIC_CSS = ` -@keyframes spIn { from{opacity:0;transform:scale(1.015)} to{opacity:1;transform:scale(1)} } -@keyframes spOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(0.96)} } -@keyframes logoBreathe { - 0%,100%{transform:scale(1);filter:drop-shadow(0 0 0px rgba(255,255,255,0))} - 50% {transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12))} -} -@keyframes hintFade { 0%,100%{opacity:0.35} 50%{opacity:0.7} } -`; - -// ── Main ────────────────────────────────────────────────────────────────────── -export default function SplashScreen({ - mode, ringFull = false, failed = false, - showCards = true, showFps = false, - onReady, onRetry, onDismiss, -}: Props) { - const [dots, setDots] = useState(""); - const [ringProg, setRingProg] = useState(0.025); - const [exiting, setExiting] = useState(false); - const exitLock = useRef(false); - - function triggerExit(cb?: () => void) { - if (exitLock.current) return; - exitLock.current = true; - setExiting(true); - setTimeout(() => cb?.(), EXIT_MS); - } - - useEffect(() => { - if (!ringFull) return; - setRingProg(1); - const t = setTimeout(() => triggerExit(onReady), 650); - return () => clearTimeout(t); - }, [ringFull]); - - useEffect(() => { - const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420); - return () => clearInterval(id); - }, []); - - useEffect(() => { - if (mode !== "idle" || !onDismiss) return; - function handler() { triggerExit(onDismiss); } - // Delay registering listeners by one frame so the event that triggered - // idle (mousemove/mousedown) doesn't immediately dismiss the splash. - const t = setTimeout(() => { - window.addEventListener("keydown", handler, { once: true }); - window.addEventListener("mousedown", handler, { once: true }); - window.addEventListener("touchstart", handler, { once: true }); - }, 200); - return () => { - clearTimeout(t); - window.removeEventListener("keydown", handler); - window.removeEventListener("mousedown", handler); - window.removeEventListener("touchstart", handler); - }; - }, [mode, onDismiss]); - - const isIdle = mode === "idle"; - - return ( -
- - - {showCards && } - - {isIdle ? ( -
-
-
- Moku -
-

press any key to continue

-
- ) : ( - <> -
- {!failed && } - Moku -
-

moku

-
- {failed ? ( - <> -

- Could not reach Suwayomi -

-

- Make sure tachidesk-server is on your PATH -

- - - ) : ( -

- {ringFull ? "Ready" : `Initializing server${dots}`} -

- )} -
- - )} -
- ); -} \ No newline at end of file diff --git a/src/components/layout/TitleBar.module.css b/src/components/layout/TitleBar.module.css deleted file mode 100644 index dffe802..0000000 --- a/src/components/layout/TitleBar.module.css +++ /dev/null @@ -1,55 +0,0 @@ -.bar { - display: flex; - align-items: center; - justify-content: space-between; - height: 32px; - padding: 0 var(--sp-3) 0 var(--sp-4); - background: var(--bg-void); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; - user-select: none; - /* Drag region covers the whole bar */ - -webkit-app-region: drag; -} - -.title { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - -webkit-app-region: drag; -} - -.controls { - display: flex; - align-items: center; - gap: 2px; - /* Controls must NOT be draggable */ - -webkit-app-region: no-drag; -} - -.btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-sm); - color: var(--text-faint); - transition: color var(--t-base), background var(--t-base); - border: none; - background: none; - cursor: pointer; - -webkit-app-region: no-drag; -} - -.btn:hover { - color: var(--text-muted); - background: var(--bg-raised); -} - -.btnClose:hover { - color: #fff; - background: #c0392b; -} \ No newline at end of file diff --git a/src/components/layout/TitleBar.svelte b/src/components/layout/TitleBar.svelte new file mode 100644 index 0000000..863dafa --- /dev/null +++ b/src/components/layout/TitleBar.svelte @@ -0,0 +1,67 @@ + + +
+ Moku +
+ + + +
+
+ + diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx deleted file mode 100644 index 7aba7c0..0000000 --- a/src/components/layout/TitleBar.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; -import s from "./TitleBar.module.css"; - -const win = getCurrentWindow(); - -export default function TitleBar() { - return ( -
- Moku -
- - - -
-
- ); -} \ No newline at end of file diff --git a/src/components/layout/Toaster.module.css b/src/components/layout/Toaster.module.css deleted file mode 100644 index 71ff13e..0000000 --- a/src/components/layout/Toaster.module.css +++ /dev/null @@ -1,85 +0,0 @@ -.toaster { - position: fixed; - bottom: var(--sp-5); - right: var(--sp-5); - z-index: 9999; - display: flex; - flex-direction: column; - gap: var(--sp-2); - pointer-events: none; - max-width: 320px; -} - -.toast { - display: flex; - align-items: flex-start; - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-3); - border-radius: var(--radius-lg); - border: 1px solid var(--border-base); - background: var(--bg-raised); - box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08); - pointer-events: all; - animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both; - min-width: 220px; -} - -@keyframes toastIn { - from { opacity: 0; transform: translateX(24px) scale(0.96); } - to { opacity: 1; transform: translateX(0) scale(1); } -} - -/* Kind variants */ -.toast_success { border-color: var(--accent-dim); } -.toast_success .toastIcon { color: var(--accent-fg); } - -.toast_error { border-color: var(--color-error); } -.toast_error .toastIcon { color: var(--color-error); } - -.toast_download .toastIcon { color: var(--accent-fg); } -.toast_info .toastIcon { color: var(--text-muted); } - -.toastIcon { - flex-shrink: 0; - margin-top: 2px; - color: var(--text-faint); -} - -.toastBody { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.toastTitle { - font-size: var(--text-sm); - color: var(--text-secondary); - font-weight: var(--weight-medium); - line-height: 1.3; -} - -.toastSub { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.toastClose { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: var(--radius-sm); - color: var(--text-faint); - flex-shrink: 0; - margin-top: 1px; - transition: color var(--t-base), background var(--t-base); -} -.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); } \ No newline at end of file diff --git a/src/components/layout/Toaster.svelte b/src/components/layout/Toaster.svelte new file mode 100644 index 0000000..4cddf6b --- /dev/null +++ b/src/components/layout/Toaster.svelte @@ -0,0 +1,91 @@ + + +{#if $toasts.length} +
+ {#each $toasts as t (t.id)} + + {/each} +
+{/if} + + diff --git a/src/components/layout/Toaster.tsx b/src/components/layout/Toaster.tsx deleted file mode 100644 index d88b228..0000000 --- a/src/components/layout/Toaster.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useRef } from "react"; -import { createPortal } from "react-dom"; -import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react"; -import { useStore } from "../../store"; -import s from "./Toaster.module.css"; - -export type ToastKind = "success" | "error" | "info" | "download"; - -export interface Toast { - id: string; - kind: ToastKind; - title: string; - body?: string; - duration?: number; // ms, 0 = persistent -} - -// ── icons per kind ────────────────────────────────────────────────────────── - -function ToastIcon({ kind }: { kind: ToastKind }) { - const size = 15; - const w = "light" as const; - if (kind === "success") return ; - if (kind === "error") return ; - if (kind === "download") return ; - return ; -} - -// ── individual toast ───────────────────────────────────────────────────────── - -function ToastItem({ toast }: { toast: Toast }) { - const dismissToast = useStore((s) => s.dismissToast); - const timerRef = useRef | null>(null); - - const duration = toast.duration ?? 3500; - - useEffect(() => { - if (duration === 0) return; - timerRef.current = setTimeout(() => dismissToast(toast.id), duration); - return () => { if (timerRef.current) clearTimeout(timerRef.current); }; - }, [toast.id, duration]); - - return ( -
- -
-

{toast.title}

- {toast.body &&

{toast.body}

} -
- -
- ); -} - -// ── toaster container ──────────────────────────────────────────────────────── - -export default function Toaster() { - const toasts = useStore((s) => s.toasts); - - if (!toasts.length) return null; - - return createPortal( -
- {toasts.map((t) => )} -
, - document.body - ); -} \ No newline at end of file diff --git a/src/components/pages/Explore.svelte b/src/components/pages/Explore.svelte new file mode 100644 index 0000000..1288caa --- /dev/null +++ b/src/components/pages/Explore.svelte @@ -0,0 +1 @@ +
Explore.svelte
diff --git a/src/components/pages/Extensions.svelte b/src/components/pages/Extensions.svelte new file mode 100644 index 0000000..d57e009 --- /dev/null +++ b/src/components/pages/Extensions.svelte @@ -0,0 +1 @@ +
Extensions.svelte
diff --git a/src/components/pages/History.module.css b/src/components/pages/History.module.css deleted file mode 100644 index 02f24a8..0000000 --- a/src/components/pages/History.module.css +++ /dev/null @@ -1,152 +0,0 @@ -.root { - display: flex; flex-direction: column; height: 100%; - overflow: hidden; animation: fadeIn 0.14s ease both; -} -.header { - display: flex; align-items: center; justify-content: space-between; - padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0; - border-bottom: 1px solid var(--border-dim); -} -.heading { - font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); - color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; -} -.headerRight { display: flex; align-items: center; gap: var(--sp-2); } - -.searchWrap { position: relative; display: flex; align-items: center; } -.searchIcon { 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 28px 5px 26px; - 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); } -.searchClear { - position: absolute; right: 7px; - color: var(--text-faint); font-size: 14px; line-height: 1; - background: none; border: none; cursor: pointer; padding: 2px; - transition: color var(--t-base); -} -.searchClear:hover { color: var(--text-muted); } - -.clearBtn { - display: flex; align-items: center; justify-content: center; - width: 28px; height: 28px; border-radius: var(--radius-md); - color: var(--text-faint); transition: color var(--t-base), background var(--t-base); -} -.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); } - -.statsBar { - display: flex; - align-items: center; - gap: var(--sp-3); - padding: var(--sp-2) var(--sp-6); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; - background: var(--bg-raised); -} - -.statItem { - display: flex; - align-items: baseline; - gap: 5px; -} - -.statVal { - font-family: var(--font-ui); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--accent-fg); - letter-spacing: var(--tracking-tight); -} - -.statLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.statDivider { - width: 1px; - height: 12px; - background: var(--border-dim); - flex-shrink: 0; -} - -.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); } - -.group { margin-bottom: var(--sp-5); } -.groupLabel { - font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); - letter-spacing: var(--tracking-wider); text-transform: uppercase; - margin-bottom: var(--sp-2); padding: 0 var(--sp-1); -} - -.row { - display: flex; align-items: center; gap: var(--sp-3); - width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md); - border: 1px solid transparent; background: none; text-align: left; cursor: pointer; - transition: background var(--t-fast), border-color var(--t-fast); -} -.row:hover { background: var(--bg-raised); border-color: var(--border-dim); } -.row:hover .playIcon { opacity: 1; } - -/* Thumb with session count badge */ -.thumbWrap { position: relative; flex-shrink: 0; } -.thumb { - width: 36px; height: 52px; border-radius: var(--radius-sm); - object-fit: cover; display: block; background: var(--bg-raised); - border: 1px solid var(--border-dim); -} -.sessionBadge { - position: absolute; bottom: -4px; right: -6px; - background: var(--accent-muted); border: 1px solid var(--accent-dim); - color: var(--accent-fg); - font-family: var(--font-ui); font-size: 9px; font-weight: 600; - letter-spacing: 0.02em; - padding: 1px 4px; border-radius: 6px; - line-height: 1.4; - pointer-events: none; -} - -.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; } -.mangaTitle { - font-size: var(--text-base); font-weight: var(--weight-medium); - color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.chapterName { - font-size: var(--text-sm); color: var(--text-muted); - display: flex; align-items: center; gap: var(--sp-1); min-width: 0; -} -.chapterRange { - display: flex; align-items: center; gap: 5px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - color: var(--text-muted); font-size: var(--text-sm); -} -.rangeSep { - color: var(--text-faint); font-size: 10px; flex-shrink: 0; -} -.pageBadge { - font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; -} -.time { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-faint); letter-spacing: var(--tracking-wide); - flex-shrink: 0; white-space: nowrap; -} -.playIcon { - color: var(--text-faint); flex-shrink: 0; - opacity: 0; transition: opacity var(--t-base); -} - -.empty { - flex: 1; display: flex; flex-direction: column; - align-items: center; justify-content: center; gap: var(--sp-2); -} -.emptyIcon { color: var(--text-faint); } -.emptyText { font-size: var(--text-base); color: var(--text-muted); } -.emptyHint { font-size: var(--text-sm); color: var(--text-faint); } \ No newline at end of file diff --git a/src/components/pages/History.tsx b/src/components/pages/History.tsx deleted file mode 100644 index a970016..0000000 --- a/src/components/pages/History.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useMemo, useState } from "react"; -import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react"; -import { thumbUrl } from "../../lib/client"; -import { useStore, type HistoryEntry } from "../../store"; -import s from "./History.module.css"; - -// ── Time helpers ────────────────────────────────────────────────────────────── - -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" }); -} - -function dayLabel(ts: number): string { - const d = new Date(ts); - const now = new Date(); - if (d.toDateString() === now.toDateString()) return "Today"; - const yesterday = new Date(now); - yesterday.setDate(now.getDate() - 1); - if (d.toDateString() === yesterday.toDateString()) return "Yesterday"; - return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); -} - -// Estimate reading time: ~8 seconds per page, counted from chapter entries -// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown) -function formatReadTime(minutes: number): string { - if (minutes < 1) return "< 1 min"; - if (minutes < 60) return `${minutes} min`; - const h = Math.floor(minutes / 60); - const m = minutes % 60; - if (m === 0) return `${h}h`; - return `${h}h ${m}m`; -} - -// ── Session grouping ────────────────────────────────────────────────────────── -const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min - -export interface ReadingSession { - mangaId: number; - mangaTitle: string; - thumbnailUrl: string; - latestChapterId: number; - latestChapterName: string; - latestPageNumber: number; - firstChapterName: string; - chapterCount: number; - readAt: number; -} - -function buildSessions(entries: HistoryEntry[]): ReadingSession[] { - if (!entries.length) return []; - const sessions: ReadingSession[] = []; - let i = 0; - while (i < entries.length) { - const anchor = entries[i]; - const group: HistoryEntry[] = [anchor]; - let j = i + 1; - while (j < entries.length) { - const next = entries[j]; - if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { - group.push(next); - j++; - } else { - break; - } - } - const latest = group[0]; - const oldest = group[group.length - 1]; - sessions.push({ - mangaId: latest.mangaId, - mangaTitle: latest.mangaTitle, - thumbnailUrl: latest.thumbnailUrl, - latestChapterId: latest.chapterId, - latestChapterName: latest.chapterName, - latestPageNumber: latest.pageNumber, - firstChapterName: oldest.chapterName, - chapterCount: group.length, - readAt: latest.readAt, - }); - i = j; - } - return sessions; -} - -function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] { - const groups = new Map(); - for (const sess of sessions) { - const label = dayLabel(sess.readAt); - if (!groups.has(label)) groups.set(label, []); - groups.get(label)!.push(sess); - } - return Array.from(groups.entries()).map(([label, items]) => ({ label, items })); -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export default function History() { - const history = useStore((s) => s.history); - const clearHistory = useStore((s) => s.clearHistory); - const setActiveManga = useStore((s) => s.setActiveManga); - const openReader = useStore((s) => s.openReader); - const activeChapterList = useStore((s) => s.activeChapterList); - const [search, setSearch] = useState(""); - - const filtered = useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return history; - return history.filter( - (e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q) - ); - }, [history, search]); - - const sessions = useMemo(() => buildSessions(filtered), [filtered]); - const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]); - - // ── Stats ───────────────────────────────────────────────────────────────── - const stats = useMemo(() => { - if (!history.length) return null; - // Unique chapters read - const uniqueChapters = new Set(history.map((e) => e.chapterId)).size; - // Unique manga read - const uniqueManga = new Set(history.map((e) => e.mangaId)).size; - // Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter - const estimatedMinutes = Math.round(uniqueChapters * 4.5); - return { uniqueChapters, uniqueManga, estimatedMinutes }; - }, [history]); - - function resumeReading(session: ReadingSession) { - // If the chapter list is available in store (user already visited this manga), - // open the reader directly for a snappier experience - const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId); - if (chapterInList && activeChapterList.length > 0) { - openReader(chapterInList, activeChapterList); - } else { - // Fall back to opening SeriesDetail — it will show the continue button - setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any); - } - } - - return ( -
-
-

History

-
-
- - setSearch(e.target.value)} /> - {search && ( - - )} -
- {history.length > 0 && ( - - )} -
-
- - {stats && ( -
- - {stats.uniqueChapters} - chapters read - - - - {stats.uniqueManga} - series - - - - {formatReadTime(stats.estimatedMinutes)} - est. read time - -
- )} - - {history.length === 0 ? ( -
- -

No reading history yet

-

Chapters you read will appear here

-
- ) : sessions.length === 0 ? ( -
- -

No results for "{search}"

-
- ) : ( -
- {groups.map(({ label, items }) => ( -
-

{label}

- {items.map((session) => ( - - ))} -
- ))} -
- )} -
- ); -} \ No newline at end of file diff --git a/src/components/pages/Library.module.css b/src/components/pages/Library.module.css deleted file mode 100644 index c39e04d..0000000 --- a/src/components/pages/Library.module.css +++ /dev/null @@ -1,282 +0,0 @@ -.root { - padding: var(--sp-6); - overflow-y: auto; - height: 100%; - animation: fadeIn 0.14s ease both; - /* GPU acceleration for smooth scrolling */ - will-change: scroll-position; - -webkit-overflow-scrolling: touch; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--sp-5); - gap: var(--sp-4); - flex-wrap: wrap; -} - -.headerLeft { - display: flex; - align-items: center; - gap: var(--sp-4); - flex-wrap: wrap; -} - -.heading { - font-family: var(--font-ui); - font-size: var(--text-xs); - font-weight: var(--weight-normal); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - flex-shrink: 0; -} - -/* Filter tabs */ -.tabs { - display: flex; - 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: none; - background: none; - color: var(--text-faint); - cursor: pointer; - transition: background var(--t-base), color var(--t-base); - white-space: nowrap; -} - -.tab:hover { color: var(--text-muted); } - -.tabActive { - background: var(--accent-muted); - color: var(--accent-fg); - border: 1px solid var(--accent-dim); -} - -.tabActive:hover { color: var(--accent-fg); } - -.tabCount { - font-size: var(--text-2xs); - color: inherit; - opacity: 0.6; -} - -/* Search */ -.searchWrap { - position: relative; - display: flex; - align-items: center; -} - -.searchIcon { - 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); } - -/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */ -.virtualRow { - display: flex; - gap: var(--sp-4); - padding: 0 var(--sp-6); - align-items: start; -} - -/* Individual card fills its flex slot */ -.card { - flex: 1 1 130px; - min-width: 0; - max-width: 200px; - background: none; - border: none; - padding: 0; - cursor: pointer; - text-align: left; -} - -.ghostCard { - flex: 1 1 130px; - min-width: 0; - max-width: 200px; - pointer-events: none; - visibility: hidden; -} - -.card:hover .cover { filter: brightness(1.06); } -.card:hover .title { color: var(--text-primary); } - -.coverWrap { - position: relative; - aspect-ratio: 2 / 3; - overflow: hidden; - border-radius: var(--radius-md); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - /* GPU-accelerated compositing */ - transform: translateZ(0); -} - -.cover { - width: 100%; - height: 100%; - object-fit: cover; - transition: filter var(--t-base); - /* Hint to compositor */ - will-change: filter; -} - -.downloadedBadge { - position: absolute; - bottom: var(--sp-1); - right: var(--sp-1); - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: bold; - background: var(--accent-dim); - color: var(--accent-fg); - border-radius: var(--radius-sm); - border: 1px solid var(--accent-muted); -} - -.unreadBadge { - position: absolute; - top: var(--sp-1); - left: var(--sp-1); - min-width: 18px; - height: 18px; - padding: 0 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: bold; - background: var(--bg-void); - color: var(--text-primary); - border-radius: var(--radius-sm); - border: 1px solid var(--border-strong); -} - -.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); -} - -/* Skeleton grid still uses CSS grid since it's fixed 12 items */ -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); - gap: var(--sp-4); - padding: var(--sp-4) var(--sp-6) 0; -} - -/* Skeleton */ -.cardSkeleton { padding: 0; } - -.coverSkeletonWrap { - aspect-ratio: 2 / 3; - border-radius: var(--radius-md); -} - -.titleSkeleton { - height: 12px; - margin-top: var(--sp-2); - width: 80%; -} - -/* Ghost cards fill trailing grid space without taking interaction */ -.ghostCard { - padding: 0; - pointer-events: none; - visibility: hidden; - aspect-ratio: 2 / 3; -} - -.center { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 60%; - color: var(--text-muted); - font-size: var(--text-sm); - gap: var(--sp-2); - text-align: center; - line-height: var(--leading-base); -} - -.errorMsg { color: var(--color-error); font-size: var(--text-base); } -.errorDetail { color: var(--text-faint); font-size: var(--text-sm); } -/* ── Tag filter ── */ -.tagPanel { - display: flex; flex-wrap: wrap; gap: var(--sp-1); - padding: 0 var(--sp-6) var(--sp-3); - flex-shrink: 0; -} - -.tagChip { - 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: none; color: var(--text-faint); cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); } -.tagChipActive { - background: var(--accent-muted); border-color: var(--accent-dim); - color: var(--accent-fg); -} -.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); } - -.tagClear { - display: flex; align-items: center; gap: 4px; - 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(--color-error); - background: none; color: var(--color-error); cursor: pointer; - transition: background var(--t-base); -} -.tagClear:hover { background: var(--color-error-bg); } \ No newline at end of file diff --git a/src/components/pages/Library.svelte b/src/components/pages/Library.svelte new file mode 100644 index 0000000..b11304a --- /dev/null +++ b/src/components/pages/Library.svelte @@ -0,0 +1 @@ +
Library.svelte
diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx deleted file mode 100644 index 84c4cd3..0000000 --- a/src/components/pages/Library.tsx +++ /dev/null @@ -1,444 +0,0 @@ -import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react"; -import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { gql, thumbUrl } from "../../lib/client"; -import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries"; -import { cache, CACHE_KEYS } from "../../lib/cache"; -import { useStore } from "../../store"; -import type { Manga, Chapter } from "../../lib/types"; -import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; -import s from "./Library.module.css"; - -const CARD_MIN_W = 130; -const CARD_GAP = 16; -const ROW_HEIGHT = 260; - -function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) { - const [loaded, setLoaded] = useState(false); - return ( - {alt} setLoaded(true)} - /> - ); -} - -const MangaCard = memo(function MangaCard({ - manga, onClick, onContextMenu, cropCovers, -}: { - manga: Manga; - onClick: () => void; - onContextMenu: (e: React.MouseEvent) => void; - cropCovers: boolean; -}) { - return ( - - ); -}); - -function fetchLibrary() { - return cache.get(CACHE_KEYS.LIBRARY, () => - gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes) - ); -} - -export default function Library() { - const [allManga, setAllManga] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [retryCount, setRetryCount] = useState(0); - const [search, setSearch] = useState(""); - const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); - const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null); - const scrollRef = useRef(null); - - const setActiveManga = useStore((state) => state.setActiveManga); - const libraryFilter = useStore((state) => state.libraryFilter); - const setLibraryFilter = useStore((state) => state.setLibraryFilter); - const settings = useStore((state) => state.settings); - const libraryTagFilter = useStore((state) => state.libraryTagFilter); - const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter); - const setGenreFilter = useStore((state) => state.setGenreFilter); - const folders = useStore((state) => state.settings.folders); - const addFolder = useStore((state) => state.addFolder); - const assignMangaToFolder = useStore((state) => state.assignMangaToFolder); - const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder); - const activeChapter = useStore((state) => state.activeChapter); - - - const prevChapterRef = useRef(null); - useEffect(() => { - const wasOpen = prevChapterRef.current !== null; - prevChapterRef.current = activeChapter?.id ?? null; - if (!wasOpen || activeChapter) return; - cache.clear(CACHE_KEYS.LIBRARY); - }, [activeChapter]); - - const loadData = useCallback((showLoading = false) => { - if (showLoading) setLoading(true); - // Clear a previously failed cache entry so we actually retry the network call - if (!cache.has(CACHE_KEYS.LIBRARY)) { - // cache miss — fresh fetch, nothing to clear - } - fetchLibrary() - .then((nodes) => { setAllManga(nodes); setError(null); }) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); - }, []); - - // Initial load — delayed on first mount so the server has time to start. - // retryCount bumps force a re-run; manual retries clear the cache first. - useEffect(() => { - setLoading(true); - setError(null); - - if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); - loadData(false); - - // Re-fetch when library cache is invalidated by other pages - const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false)); - return unsub; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [retryCount]); - - useEffect(() => { - scrollRef.current?.scrollTo({ top: 0 }); - }, [libraryFilter, search]); - - useEffect(() => { - const activeFolder = folders.find((f) => f.id === libraryFilter); - if (activeFolder && !activeFolder.showTab) setLibraryFilter("library"); - }, [folders]); - - const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded"; - - const filtered = useMemo(() => { - let items = allManga; - if (libraryFilter === "library") { - items = items.filter((m) => m.inLibrary); - } else if (libraryFilter === "downloaded") { - items = items.filter((m) => (m.downloadCount ?? 0) > 0); - } else if (!isBuiltinFilter) { - const folder = folders.find((f) => f.id === libraryFilter); - if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id)); - } - if (libraryTagFilter.length > 0) - items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag))); - if (search.trim()) { - const q = search.toLowerCase(); - items = items.filter((m) => m.title.toLowerCase().includes(q)); - } - return items; - }, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]); - - // ── Virtualizer setup ────────────────────────────────────────────────────── - const [containerWidth, setContainerWidth] = useState(800); - - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const ro = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))); - - const rows = useMemo(() => { - const result: Manga[][] = []; - for (let i = 0; i < filtered.length; i += cols) - result.push(filtered.slice(i, i + cols)); - return result; - }, [filtered, cols]); - - const virtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => ROW_HEIGHT, - overscan: 3, - }); - - const handleCardClick = useCallback( - (m: Manga) => () => setActiveManga(m), - [setActiveManga] - ); - - async function removeFromLibrary(manga: Manga) { - await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error); - // Optimistic update first, then invalidate cache - setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m)); - cache.clear(CACHE_KEYS.LIBRARY); - } - - async function deleteAllDownloads(manga: Manga) { - try { - const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id }); - const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded); - const ids = downloadedChapters.map((c) => c.id); - if (!ids.length) return; - await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }); - await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id }))); - setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m)); - } catch (e) { console.error(e); } - } - - function openCtx(e: React.MouseEvent, m: Manga) { - e.preventDefault(); - const x = Math.min(e.clientX, window.innerWidth - 208); - const y = Math.min(e.clientY, window.innerHeight - 168); - setCtx({ x, y, manga: m }); - } - - function buildCtxItems(m: Manga): ContextMenuEntry[] { - const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => { - const inFolder = f.mangaIds.includes(m.id); - return { - label: inFolder ? `✓ ${f.name}` : f.name, - icon: , - onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id), - }; - }); - - return [ - { - label: "Open", - icon: , - onClick: () => setActiveManga(m), - }, - { separator: true }, - { - label: m.inLibrary ? "Remove from library" : "Add to library", - icon: , - danger: m.inLibrary, - onClick: () => m.inLibrary - ? removeFromLibrary(m) - : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) - .then(() => { - setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)); - cache.clear(CACHE_KEYS.LIBRARY); - }) - .catch(console.error), - }, - { - label: "Delete all downloads", - icon: , - danger: true, - disabled: !(m.downloadCount && m.downloadCount > 0), - onClick: () => deleteAllDownloads(m), - }, - ...(folders.length > 0 ? [ - { separator: true } as ContextMenuEntry, - ...mangaFolderEntries, - ] : []), - { separator: true }, - { - label: "New folder", - icon: , - onClick: () => { - const name = prompt("Folder name:"); - if (name?.trim()) { - const id = addFolder(name.trim()); - assignMangaToFolder(id, m.id); - } - }, - }, - ]; - } - - function buildEmptyCtxItems(): ContextMenuEntry[] { - return [ - { - label: "New folder", - icon: , - onClick: () => { - const name = prompt("Folder name:"); - if (name?.trim()) addFolder(name.trim()); - }, - }, - ]; - } - - const allTags = useMemo(() => { - const tagSet = new Set(); - allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g))); - return Array.from(tagSet).sort(); - }, [allManga]); - - const counts = useMemo(() => { - const result: Record = { - all: allManga.length, - library: allManga.filter((m) => m.inLibrary).length, - downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length, - }; - folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; }); - return result; - }, [allManga, folders]); - - if (error) return ( -
-

Could not reach Suwayomi

-

Make sure the server is running, then retry.

- -
- ); - - return ( -
{ - if ((e.target as HTMLElement).closest("button")) return; - e.preventDefault(); - setEmptyCtx({ x: e.clientX, y: e.clientY }); - }} - > -
-
-

Library

-
- {(["library", "downloaded", "all"] as const).map((f) => ( - - ))} - {folders.filter((f) => f.showTab).map((folder) => ( - - ))} -
-
-
- - setSearch(e.target.value)} - /> -
-
- - {allTags.length > 0 && ( -
- {libraryTagFilter.length > 0 && ( - - )} - {allTags.map((tag) => { - const active = libraryTagFilter.includes(tag); - return ( - - ); - })} -
- )} - - {loading ? ( -
- {Array.from({ length: 12 }).map((_, i) => ( -
-
-
-
- ))} -
- ) : filtered.length === 0 ? ( -
- {libraryFilter === "library" - ? "No manga saved to library, browse sources to add some." - : libraryFilter === "downloaded" - ? "No downloaded manga." - : !isBuiltinFilter - ? "No manga in this folder yet. Right-click manga to assign them." - : "No manga found."} -
- ) : ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const rowManga = rows[virtualRow.index]; - return ( -
- {rowManga.map((m) => ( - openCtx(e, m)} - cropCovers={settings.libraryCropCovers} - /> - ))} - {virtualRow.index === rows.length - 1 && - Array.from({ length: cols - rowManga.length }).map((_, i) => ( -
- ))} -
- ); - })} -
- )} - - {ctx && ( - setCtx(null)} /> - )} - {emptyCtx && ( - setEmptyCtx(null)} /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/pages/MigrateModal.module.css b/src/components/pages/MigrateModal.module.css deleted file mode 100644 index f43050a..0000000 --- a/src/components/pages/MigrateModal.module.css +++ /dev/null @@ -1,628 +0,0 @@ -.overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 200; - animation: fadeIn 0.1s ease both; -} - -.modal { - background: var(--bg-base); - border: 1px solid var(--border-base); - border-radius: var(--radius-xl); - width: 520px; - max-height: 80vh; - display: flex; - flex-direction: column; - overflow: hidden; - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); -} - -.modalHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - padding: var(--sp-4) var(--sp-5); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.modalTitle { - display: flex; - flex-direction: column; - gap: 2px; -} - -.modalTitleLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -.modalTitleManga { - font-size: var(--text-base); - font-weight: var(--weight-medium); - color: var(--text-primary); - letter-spacing: var(--tracking-tight); -} - -.closeBtn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: var(--radius-md); - color: var(--text-faint); - transition: color var(--t-base), background var(--t-base); - flex-shrink: 0; -} -.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } - -/* ── Steps ── */ -.steps { - display: flex; - align-items: center; - gap: var(--sp-1); - padding: var(--sp-3) var(--sp-5); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.step { - display: flex; - align-items: center; - gap: var(--sp-2); - opacity: 0.4; - transition: opacity var(--t-base); -} - -.stepActive { opacity: 1; } -.stepDone { opacity: 0.6; } - -.stepDot { - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--bg-raised); - border: 1px solid var(--border-base); - display: flex; - align-items: center; - justify-content: center; - font-family: var(--font-ui); - font-size: 10px; - color: var(--text-faint); - flex-shrink: 0; -} - -.stepActive .stepDot { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} - -.stepLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--text-muted); -} - -.stepActive .stepLabel { color: var(--text-secondary); } - -.steps .step + .step::before { - content: "›"; - color: var(--text-faint); - margin-right: var(--sp-1); - font-size: var(--text-sm); -} - -/* ── Body ── */ -.body { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.centered { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: var(--sp-8); -} - -.hint { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -/* ── Source list ── */ -.sourceList { - flex: 1; - overflow-y: auto; - padding: var(--sp-2); - display: flex; - flex-direction: column; - gap: 1px; -} - -.sourceRow { - display: flex; - align-items: center; - gap: var(--sp-3); - padding: 9px var(--sp-3); - border-radius: var(--radius-md); - border: 1px solid transparent; - background: none; - text-align: left; - width: 100%; - cursor: pointer; - transition: background var(--t-fast), border-color var(--t-fast); -} -.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); } -.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); } - -.sourceIcon { - width: 28px; - height: 28px; - border-radius: var(--radius-md); - object-fit: cover; - flex-shrink: 0; - background: var(--bg-raised); -} - -.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } - -.sourceName { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.sourceMeta { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.sourceArrow { - color: var(--text-faint); - opacity: 0; - transition: opacity var(--t-base); -} -.sourceRow:hover .sourceArrow { opacity: 1; } - -/* ── Search step ── */ -.searchStep { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; - gap: var(--sp-3); - padding: var(--sp-3) var(--sp-4); -} - -.searchRow { - display: flex; - align-items: center; - gap: var(--sp-2); - flex-shrink: 0; -} - -.searchBar { - flex: 1; - display: flex; - align-items: center; - gap: var(--sp-2); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - padding: 0 var(--sp-3) 0 var(--sp-2); - transition: border-color var(--t-base); -} -.searchBar:focus-within { border-color: var(--border-strong); } - -.searchIcon { color: var(--text-faint); flex-shrink: 0; } - -.searchInput { - flex: 1; - background: none; - border: none; - outline: none; - color: var(--text-primary); - font-size: var(--text-sm); - padding: 7px 0; -} -.searchInput::placeholder { color: var(--text-faint); } - -.searchBtn { - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - padding: 6px 12px; - border-radius: var(--radius-md); - background: var(--accent-muted); - color: var(--accent-fg); - border: 1px solid var(--accent-dim); - cursor: pointer; - flex-shrink: 0; - display: flex; - align-items: center; - gap: var(--sp-1); - transition: filter var(--t-base); -} -.searchBtn:hover:not(:disabled) { filter: brightness(1.1); } -.searchBtn:disabled { opacity: 0.4; cursor: default; } - -.backBtn { - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - padding: 6px 10px; - border-radius: var(--radius-md); - background: none; - color: var(--text-muted); - border: 1px solid var(--border-dim); - cursor: pointer; - flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } -.backBtn:disabled { opacity: 0.4; cursor: default; } - -.results { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 1px; -} - -.resultRow { - display: flex; - align-items: center; - gap: var(--sp-3); - padding: 7px var(--sp-2); - border-radius: var(--radius-md); - border: none; - background: none; - text-align: left; - width: 100%; - cursor: pointer; - transition: background var(--t-fast); -} -.resultRow:hover:not(:disabled) { background: var(--bg-raised); } -.resultRow:disabled { opacity: 0.5; cursor: default; } - -.resultCoverWrap { - width: 36px; - height: 54px; - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--bg-raised); - border: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.resultCover { width: 100%; height: 100%; object-fit: cover; } - -.resultTitle { - font-size: var(--text-sm); - color: var(--text-secondary); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Skeletons */ -.skResult { - display: flex; - align-items: center; - gap: var(--sp-3); - padding: 7px var(--sp-2); -} - -.skCover { - width: 36px; - height: 54px; - border-radius: var(--radius-sm); - flex-shrink: 0; -} - -.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); } -.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); } - -/* ── Confirm step ── */ -.confirmStep { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: var(--sp-4); - padding: var(--sp-4) var(--sp-5); -} - -.confirmRow { - display: flex; - align-items: center; - justify-content: center; - gap: var(--sp-4); -} - -.confirmManga { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--sp-2); - flex: 1; - max-width: 160px; -} - -.confirmCoverWrap { - width: 100%; - aspect-ratio: 2/3; - border-radius: var(--radius-md); - overflow: hidden; - background: var(--bg-raised); - border: 1px solid var(--border-dim); -} - -.confirmCover { width: 100%; height: 100%; object-fit: cover; } - -.confirmTitle { - font-size: var(--text-xs); - color: var(--text-secondary); - text-align: center; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: var(--leading-snug); -} - -.confirmSource { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - text-align: center; -} - -.confirmArrow { color: var(--text-faint); flex-shrink: 0; } - -.confirmStats { - display: flex; - flex-direction: column; - gap: var(--sp-2); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - padding: var(--sp-3) var(--sp-4); -} - -.statRow { - display: flex; - justify-content: space-between; - align-items: center; -} - -.statLabel { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-muted); - letter-spacing: var(--tracking-wide); -} - -.statVal { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-secondary); - letter-spacing: var(--tracking-wide); -} - -.confirmNote { - font-size: var(--text-xs); - color: var(--text-faint); - line-height: var(--leading-base); -} - -.confirmActions { - display: flex; - justify-content: flex-end; - gap: var(--sp-2); - flex-shrink: 0; -} - -.migrateBtn { - display: flex; - align-items: center; - gap: var(--sp-2); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - padding: 7px 16px; - border-radius: var(--radius-md); - background: var(--accent-dim); - border: 1px solid var(--accent); - color: var(--accent-fg); - cursor: pointer; - transition: background var(--t-base), border-color var(--t-base); -} -.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); } -.migrateBtn:disabled { opacity: 0.5; cursor: default; } - -.error { - display: flex; - align-items: center; - gap: var(--sp-2); - font-size: var(--text-xs); - color: var(--color-error); - padding: var(--sp-2) var(--sp-3); - background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08); - border-radius: var(--radius-md); - border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2); -} -/* ── Source context pill (step 2 header) ── */ -.searchContext { - display: flex; - align-items: center; - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-3); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - flex-shrink: 0; -} - -.searchContextIcon { - width: 18px; - height: 18px; - border-radius: var(--radius-sm); - object-fit: cover; - flex-shrink: 0; -} - -.searchContextName { - flex: 1; - font-size: var(--text-sm); - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.searchContextChange { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--accent-fg); - background: none; - border: none; - cursor: pointer; - padding: 0; - flex-shrink: 0; - transition: opacity var(--t-base); -} -.searchContextChange:hover { opacity: 0.75; } - -/* ── Result row: updated layout with similarity ── */ -.resultInfo { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; - overflow: hidden; - min-width: 0; -} - -.resultMeta { - display: flex; - align-items: center; - gap: var(--sp-2); -} - -.bestMatchBadge { - display: inline-flex; - align-items: center; - gap: 3px; - font-family: var(--font-ui); - font-size: 9px; - letter-spacing: var(--tracking-wide); - text-transform: uppercase; - color: var(--accent-fg); - background: var(--accent-muted); - border: 1px solid var(--accent-dim); - padding: 1px 5px; - border-radius: var(--radius-sm); - flex-shrink: 0; -} - -.simBar { - width: 48px; - height: 3px; - background: var(--bg-overlay); - border-radius: var(--radius-full); - overflow: hidden; - flex-shrink: 0; -} - -.simFill { - display: block; - height: 100%; - background: var(--accent); - border-radius: var(--radius-full); - transition: width 0.2s ease; -} - -.simLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - white-space: nowrap; -} - -/* ── Confirm step additions ── */ -.confirmDivider { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.confirmTag { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - text-transform: uppercase; - padding: 2px 7px; - border-radius: var(--radius-full); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - color: var(--text-faint); -} - -.confirmTagNew { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} - -.statGood { color: var(--color-success) !important; } -.statWarn { color: #d97706 !important; } -.statBad { color: var(--color-error) !important; } - -.chapterDiff { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: #d97706; - letter-spacing: var(--tracking-wide); - margin-left: var(--sp-2); -} - -.warnBox { - display: flex; - align-items: center; - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-3); - background: rgba(217, 119, 6, 0.08); - border: 1px solid rgba(217, 119, 6, 0.25); - border-radius: var(--radius-md); - font-size: var(--text-xs); - color: #d97706; - line-height: var(--leading-snug); -} \ No newline at end of file diff --git a/src/components/pages/MigrateModal.tsx b/src/components/pages/MigrateModal.tsx deleted file mode 100644 index 7382cd8..0000000 --- a/src/components/pages/MigrateModal.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; -import type { Manga, Source, Chapter } from "../../lib/types"; -import s from "./MigrateModal.module.css"; - -interface Props { - manga: Manga; - currentChapters: Chapter[]; - onClose: () => void; - onMigrated: (newManga: Manga) => void; -} - -type Step = "source" | "search" | "confirm"; - -interface Match { - manga: Manga; - chapters: Chapter[]; - readCount: number; - similarity: number; -} - -// Simple title similarity: normalise → word overlap / Jaccard -function titleSimilarity(a: string, b: string): number { - const norm = (s: string) => - s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); - const wordsA = new Set(norm(a)); - const wordsB = new Set(norm(b)); - if (wordsA.size === 0 || wordsB.size === 0) return 0; - const intersection = [...wordsA].filter((w) => wordsB.has(w)).length; - const union = new Set([...wordsA, ...wordsB]).size; - return intersection / union; -} - -export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) { - const [step, setStep] = useState("source"); - const [sources, setSources] = useState([]); - const [loadingSources, setLoadingSources] = useState(true); - const [selectedSource, setSelectedSource] = useState(null); - const [query, setQuery] = useState(manga.title); - const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]); - const [searching, setSearching] = useState(false); - const [selectedMatch, setSelectedMatch] = useState(null); - const [loadingMatchId, setLoadingMatchId] = useState(null); - const [migrating, setMigrating] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => setSources(d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id))) - .catch(console.error) - .finally(() => setLoadingSources(false)); - }, []); - - const searchSource = useCallback(async (src: Source, q: string) => { - if (!src || !q.trim()) return; - setSearching(true); - setResults([]); - setError(null); - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: src.id, type: "SEARCH", page: 1, query: q.trim(), - }); - const scored = d.fetchSourceManga.mangas.map((m) => ({ - manga: m, - similarity: titleSimilarity(manga.title, m.title), - })); - // Sort by similarity desc so best matches float to top - scored.sort((a, b) => b.similarity - a.similarity); - setResults(scored); - } catch (e: any) { - setError(e.message); - } finally { - setSearching(false); - } - }, [manga.title]); - - function pickSource(src: Source) { - setSelectedSource(src); - setStep("search"); - // Auto-search immediately with original title - searchSource(src, query); - } - - async function selectMatch(m: Manga, similarity: number) { - setLoadingMatchId(m.id); - setError(null); - try { - const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id }); - const chapters = d.fetchChapters.chapters; - const readCount = chapters.filter((c) => { - const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01); - return old?.isRead; - }).length; - setSelectedMatch({ manga: m, chapters, readCount, similarity }); - setStep("confirm"); - } catch (e: any) { - setError(e.message); - } finally { - setLoadingMatchId(null); - } - } - - async function migrate() { - if (!selectedMatch) return; - setMigrating(true); - setError(null); - try { - const { manga: newManga, chapters: newChapters } = selectedMatch; - const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c])); - - const toMarkRead: number[] = []; - const toMarkBookmarked: number[] = []; - const progressUpdates: { id: number; lastPageRead: number }[] = []; - - for (const nc of newChapters) { - const key = Math.round(nc.chapterNumber * 100); - const old = oldByNum.get(key); - if (!old) continue; - if (old.isRead) toMarkRead.push(nc.id); - if (old.isBookmarked) toMarkBookmarked.push(nc.id); - if ((old.lastPageRead ?? 0) > 0 && !old.isRead) - progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! }); - } - - if (toMarkRead.length) - await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true }); - if (toMarkBookmarked.length) - await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true }); - for (const { id, lastPageRead } of progressUpdates) - await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead }); - - await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true }); - await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }); - - onMigrated({ ...newManga, inLibrary: true }); - } catch (e: any) { - setError(e.message); - setMigrating(false); - } - } - - const readCount = currentChapters.filter((c) => c.isRead).length; - const totalCount = currentChapters.length; - - const chapterDiff = selectedMatch - ? selectedMatch.chapters.length - totalCount - : 0; - - const STEPS: Step[] = ["source", "search", "confirm"]; - const stepIdx = STEPS.indexOf(step); - - return ( -
e.target === e.currentTarget && onClose()}> -
- - {/* ── Header ── */} -
-
- Migrate source - {manga.title} -
- -
- - {/* ── Step indicators ── */} -
- {STEPS.map((st, i) => ( -
- - {i < stepIdx ? : i + 1} - - - {st === "source" ? "Pick source" - : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") - : "Confirm"} - -
- ))} -
- -
- - {/* ── Step 1: Pick source ── */} - {step === "source" && ( -
- {loadingSources ? ( -
- -
- ) : sources.length === 0 ? ( -
No other sources installed.
- ) : ( - sources.map((src) => ( - - )) - )} -
- )} - - {/* ── Step 2: Search & pick match ── */} - {step === "search" && ( -
- - {/* Source context pill */} - {selectedSource && ( -
- { (e.target as HTMLImageElement).style.display = "none"; }} /> - {selectedSource.displayName} - -
- )} - -
-
- - setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)} - placeholder="Search title…" - autoFocus /> -
- -
- - {error &&

{error}

} - -
- {searching && Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
- ))} - {!searching && results.map(({ manga: m, similarity }, idx) => ( - - ))} - {!searching && results.length === 0 && !error && ( -
- {query ? "No results — try a different title." : "Enter a title to search."} -
- )} -
-
- )} - - {/* ── Step 3: Confirm ── */} - {step === "confirm" && selectedMatch && ( -
-
-
-
- {manga.title} -
-

{manga.title}

-

{manga.source?.displayName ?? "Unknown"}

- Current -
- -
- -
- -
-
- {selectedMatch.manga.title} -
-

{selectedMatch.manga.title}

-

{selectedSource?.displayName ?? "Unknown"}

- New -
-
- -
-
- Title match - 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}> - {Math.round(selectedMatch.similarity * 100)}% - -
-
- Chapters on new source - - {selectedMatch.chapters.length} - {chapterDiff !== 0 && ( - {chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current - )} - -
-
- Read progress to carry over - {selectedMatch.readCount} / {readCount} chapters -
-
- - {chapterDiff < -5 && ( -
- - New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing. -
- )} - -

- The current entry will be removed from your library. Downloads are not transferred. -

- - {error &&

{error}

} - -
- - -
-
- )} -
-
-
- ); -} \ No newline at end of file diff --git a/src/components/pages/Reader.module.css b/src/components/pages/Reader.module.css deleted file mode 100644 index 33f9140..0000000 --- a/src/components/pages/Reader.module.css +++ /dev/null @@ -1,251 +0,0 @@ -.root { - position: fixed; inset: 0; - background: #000; - display: flex; flex-direction: column; - z-index: var(--z-reader); - transform: translateZ(0); will-change: transform; -} - -/* ── UI autohide ── */ -.uiHidden { - opacity: 0; - pointer-events: none; - transition: opacity 0.25s ease; -} -.topbar, .bottombar { - transition: opacity 0.25s ease; -} - -/* ── Topbar ── */ -.topbar { - display: flex; align-items: center; gap: var(--sp-1); - padding: 0 var(--sp-3); height: 40px; - background: var(--bg-void); border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; overflow: visible; - position: relative; z-index: 2; -} - -.iconBtn { - display: flex; align-items: center; justify-content: center; - width: 28px; height: 28px; border-radius: var(--radius-sm); - color: var(--text-muted); flex-shrink: 0; - transition: color var(--t-base), background var(--t-base); -} -.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } -.iconBtn:disabled { opacity: 0.2; cursor: default; } - -.chLabel { - flex: 1; display: flex; align-items: center; gap: var(--sp-2); - font-size: var(--text-sm); color: var(--text-muted); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); } -.chSep { color: var(--text-faint); } - -.pageLabel { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-muted); letter-spacing: var(--tracking-wide); - flex-shrink: 0; -} - -.topSep { - width: 1px; height: 16px; - background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); -} - -.modeBtn { - display: flex; align-items: center; gap: 4px; - padding: 4px var(--sp-2); border-radius: var(--radius-sm); - color: var(--text-muted); flex-shrink: 0; - font-family: var(--font-ui); font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - transition: color var(--t-base), background var(--t-base); -} -.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); } -.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); } -.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } -.modeBtnLabel { text-transform: capitalize; } - -/* ── Zoom ── */ -.zoomWrap { - position: relative; flex-shrink: 0; -} - -.zoomBtn { - font-family: var(--font-ui); font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); color: var(--text-faint); - padding: 4px var(--sp-2); border-radius: var(--radius-sm); - min-width: 36px; text-align: center; - transition: color var(--t-base), background var(--t-base); -} -.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); } - -.zoomPopover { - position: absolute; top: calc(100% + 6px); left: 50%; - transform: translateX(-50%); - background: var(--bg-raised); border: 1px solid var(--border-base); - border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); - display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); - box-shadow: 0 8px 24px rgba(0,0,0,0.5); - z-index: 100; min-width: 160px; - animation: scaleIn 0.1s ease both; transform-origin: top center; -} - -.zoomSlider { - -webkit-appearance: none; - appearance: none; - width: 140px; height: 3px; - background: var(--border-strong); - border-radius: 2px; outline: none; cursor: pointer; -} -.zoomSlider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; height: 12px; - border-radius: 50%; - background: var(--accent-fg); - cursor: pointer; -} -.zoomSlider::-moz-range-thumb { - width: 12px; height: 12px; - border-radius: 50%; border: none; - background: var(--accent-fg); - cursor: pointer; -} - -.zoomResetBtn { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-muted); letter-spacing: var(--tracking-wide); - padding: 2px var(--sp-2); border-radius: var(--radius-sm); - transition: color var(--t-base), background var(--t-base); -} -.zoomResetBtn:hover { color: var(--text-primary); background: var(--bg-overlay); } - -/* ── Viewer ── */ -.viewer { - flex: 1; overflow-y: auto; overflow-x: hidden; - display: flex; flex-direction: column; - align-items: center; justify-content: center; - -webkit-overflow-scrolling: touch; - position: relative; -} - -.viewerStrip { - justify-content: flex-start; - padding: var(--sp-4) 0; - overflow-anchor: auto; /* browser preserves scroll pos when nodes are added/removed above */ -} - -/* ── Images ── */ -.img { - display: block; user-select: none; - image-rendering: auto; -} -.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; } - -/* Fit modes. - height: auto on .img is the load-bearing rule: the img element is given - height={1000} as a layout hint while the image is loading (prevents reflow). - Once the image is fully painted the browser must resolve height from the - intrinsic dimensions, not the HTML attribute — `height: auto` enforces that. */ -.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; } -.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; } -.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; } -.fitOriginal { max-width: none; width: auto; height: auto; } - -/* Longstrip */ -.stripGap { margin-bottom: 8px; } - -/* ── Double page ── */ -.doubleWrap { - display: flex; align-items: flex-start; justify-content: center; - max-width: calc(var(--max-page-width) * 2); - width: 100%; -} -.pageHalf { flex: 1; min-width: 0; object-fit: contain; } -.gapLeft { margin-right: 2px; } -.gapRight { margin-left: 2px; } - -/* ── Bottom nav ── */ -.bottombar { - display: flex; align-items: center; justify-content: center; gap: var(--sp-4); - padding: var(--sp-3); border-top: 1px solid var(--border-dim); - background: var(--bg-void); flex-shrink: 0; -} - -.navBtn { - display: flex; align-items: center; justify-content: center; - width: 34px; height: 34px; border-radius: var(--radius-md); - border: 1px solid var(--border-strong); color: var(--text-muted); - transition: background var(--t-base), color var(--t-base); -} -.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); } -.navBtn:disabled { opacity: 0.25; cursor: default; } - -/* ── States ── */ -.center { - display: flex; flex-direction: column; align-items: center; justify-content: center; - position: fixed; inset: 0; background: #000; -} -.errorMsg { color: var(--color-error); font-size: var(--text-base); } - -/* ── Download modal ── */ -.dlBackdrop { - position: fixed; inset: 0; - z-index: calc(var(--z-reader) + 10); - display: flex; align-items: flex-start; justify-content: flex-end; - padding: 48px var(--sp-4) 0; -} - -.dlModal { - background: var(--bg-raised); border: 1px solid var(--border-base); - border-radius: var(--radius-xl); padding: var(--sp-3); - min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); - box-shadow: 0 8px 32px rgba(0,0,0,0.6); - animation: scaleIn 0.12s ease both; transform-origin: top right; -} - -.dlTitle { - font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--text-faint); letter-spacing: var(--tracking-wider); - text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); - border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); -} - -.dlOption { - display: flex; flex-direction: column; align-items: flex-start; gap: 2px; - width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); - font-size: var(--text-sm); color: var(--text-secondary); - background: none; border: none; cursor: pointer; text-align: left; - transition: background var(--t-fast), color var(--t-fast); -} -.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); } -.dlOption:disabled { opacity: 0.3; cursor: default; } - -.dlSub { font-size: var(--text-xs); color: var(--text-faint); } - -.dlRow { display: flex; align-items: center; gap: var(--sp-2); } - -.dlStepper { - display: flex; align-items: center; gap: 2px; - background: var(--bg-overlay); border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; -} - -.dlStepBtn { - display: flex; align-items: center; justify-content: center; - width: 22px; height: 28px; - font-size: var(--text-base); color: var(--text-muted); - background: none; border: none; cursor: pointer; line-height: 1; - transition: color var(--t-fast), background var(--t-fast); -} -.dlStepBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } -.dlStepBtn:disabled { opacity: 0.25; cursor: default; } - -.dlStepVal { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-secondary); min-width: 24px; text-align: center; - letter-spacing: var(--tracking-wide); -} -/* Viewer focus — suppress outline since we're handling keys ourselves */ -.viewer:focus { outline: none; } \ No newline at end of file diff --git a/src/components/pages/Reader.tsx b/src/components/pages/Reader.tsx deleted file mode 100644 index 931aa87..0000000 --- a/src/components/pages/Reader.tsx +++ /dev/null @@ -1,989 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react"; -import { - X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, - Square, Rows, Download, ArrowsLeftRight, - ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, -} from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { - FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, - ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, -} from "../../lib/queries"; -import { useStore, type FitMode } from "../../store"; -import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS, type Keybinds } from "../../lib/keybinds"; -import s from "./Reader.module.css"; - -// ── Page cache (module-level, survives re-renders) ──────────────────────────── -const pageCache = new Map(); -const inflight = new Map>(); -const cacheOrder: number[] = []; -const MAX_CACHED = 10; - -function cacheTouch(id: number) { - const i = cacheOrder.indexOf(id); - if (i !== -1) cacheOrder.splice(i, 1); - cacheOrder.push(id); -} - -function cacheEvict(keep: Set) { - while (pageCache.size > MAX_CACHED) { - const victim = cacheOrder.find((id) => !keep.has(id)); - if (!victim) break; - cacheOrder.splice(cacheOrder.indexOf(victim), 1); - pageCache.delete(victim); - } -} - -function fetchPages(chapterId: number, signal?: AbortSignal): Promise { - const cached = pageCache.get(chapterId); - if (cached) { cacheTouch(chapterId); return Promise.resolve(cached); } - - if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); - - if (!inflight.has(chapterId)) { - const p = gql<{ fetchChapterPages: { pages: string[] } }>( - FETCH_CHAPTER_PAGES, { chapterId }, - ).then((d) => { - const urls = d.fetchChapterPages.pages.map(thumbUrl); - pageCache.set(chapterId, urls); - cacheTouch(chapterId); - 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); - }); -} - -// ── Image helpers ───────────────────────────────────────────────────────────── -const aspectCache = new Map(); - -function preloadImage(url: string) { new Image().src = url; } - -function decodeImage(url: string): Promise { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); }; - img.onerror = () => resolve(); - img.src = url; - }); -} - -function measureAspect(url: string): Promise { - if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); - return new Promise((res) => { - const img = new Image(); - img.onload = () => { - // Guard against 0 dimensions (image not fully decoded yet) and NaN - const ratio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; - aspectCache.set(url, ratio); - res(ratio); - }; - img.onerror = () => res(0.67); - img.src = url; - }); -} - -// ── Download modal ──────────────────────────────────────────────────────────── -function DownloadModal({ - chapter, remaining, onClose, -}: { - chapter: { id: number; name: string; isDownloaded?: boolean }; - remaining: { id: number; isDownloaded?: boolean }[]; - onClose: () => void; -}) { - const addToast = useStore((s) => s.addToast); - const [nextN, setNextN] = useState(5); - const [busy, setBusy] = useState(false); - const queueable = remaining.filter((c) => !c.isDownloaded); - const alreadyDl = !!chapter.isDownloaded; - - const run = async (fn: () => Promise, toastBody: string) => { - setBusy(true); - try { - await fn(); - addToast({ kind: "download", title: "Download queued", body: toastBody }); - } catch (e) { - addToast({ kind: "error", title: "Queue failed", body: e instanceof Error ? e.message : String(e) }); - } - setBusy(false); - onClose(); - }; - - return ( -
-
e.stopPropagation()}> -

Download

- -
- -
e.stopPropagation()}> - - {nextN} - -
-
- -
-
- ); -} - -// ── Zoom popover ────────────────────────────────────────────────────────────── -function ZoomPopover({ value, onChange, onReset, onClose }: { - value: number; onChange: (v: number) => void; onReset: () => void; onClose: () => void; -}) { - const ref = useRef(null); - useEffect(() => { - const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; - document.addEventListener("mousedown", h); - return () => document.removeEventListener("mousedown", h); - }, [onClose]); - return ( -
- onChange(Number(e.target.value))} /> - -
- ); -} - -// ── Types ───────────────────────────────────────────────────────────────────── -interface StripChapter { - chapterId: number; - chapterName: string; - urls: string[]; - startGlobalIdx: number; -} - -// ── Reader ──────────────────────────────────────────────────────────────────── -export default function Reader() { - const containerRef = useRef(null); - const sentinelRef = useRef(null); - const hideTimerRef = useRef | null>(null); - - const settingsRef = useRef(null); - const chapterListRef = useRef([]); - const loadingIdRef = useRef(null); - const markedReadRef = useRef>(new Set()); - const appendedRef = useRef>(new Set()); - const appendingRef = useRef(false); - const abortRef = useRef(null); - const visibleChapterRef = useRef(null); - const stripChaptersRef = useRef([]); - const pageUrlsRef = useRef([]); - const activeChapterRef = useRef(null); - const markReadOnNextRef = useRef(true); - // Captured before a head-trim; useLayoutEffect restores scroll synchronously - const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [dlOpen, setDlOpen] = useState(false); - const [zoomOpen, setZoomOpen] = useState(false); - const [uiVisible, setUiVisible] = useState(true); - const [pageReady, setPageReady] = useState(false); - const [pageGroups, setPageGroups] = useState([]); - const [stripChapters, setStripChapters] = useState([]); - const [visibleChapterId, setVisibleChapterId] = useState(null); - - stripChaptersRef.current = stripChapters; - - // Restore scroll position synchronously after a head-trim, before paint. - // This is the only reliable way to prevent the visible jump — rAF fires - // one frame too late and the user sees the incorrect position briefly. - useLayoutEffect(() => { - const anchor = scrollAnchorRef.current; - if (!anchor || !containerRef.current) return; - scrollAnchorRef.current = null; - const gained = containerRef.current.scrollHeight - anchor.scrollHeight; - // gained is negative when nodes were removed (scrollHeight shrank). - // Subtract the same amount from scrollTop so visible content stays put. - if (gained < 0) { - containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained); - } - }, [stripChapters]); - - const { - activeManga, activeChapter, activeChapterList, - pageUrls, pageNumber, settings, - setPageUrls, setPageNumber, closeReader, openReader, openSettings, - updateSettings, addHistory, - } = useStore(); - - const rtl = settings.readingDirection === "rtl"; - const fit = settings.fitMode ?? "width"; - const style = settings.pageStyle ?? "single"; - const maxW = settings.maxPageWidth ?? 900; - const autoNext = settings.autoNextChapter ?? false; - const markReadOnNext = settings.markReadOnNext ?? true; - - settingsRef.current = settings; - chapterListRef.current = activeChapterList; - pageUrlsRef.current = pageUrls; - activeChapterRef.current = activeChapter; - markReadOnNextRef.current = markReadOnNext; - - // Mark the current chapter read when the user manually skips to another chapter. - // Uses refs only — safe to call from any callback without stale-closure issues. - // markReadOnNext gates this; autoNextChapter does NOT block it because a manual - // chapter-skip is always intentional regardless of the auto-advance setting. - const maybeMarkCurrentRead = useCallback(() => { - const ch = activeChapterRef.current; - if (!ch) return; - if (!markReadOnNextRef.current) return; - if (markedReadRef.current.has(ch.id)) return; - markedReadRef.current.add(ch.id); - gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true }).catch((e) => { - markedReadRef.current.delete(ch.id); - console.error("MARK_CHAPTER_READ (manual next) failed:", e); - }); - }, []); - - // ── UI autohide ────────────────────────────────────────────────────────────── - const showUi = useCallback(() => { - setUiVisible(true); - if (hideTimerRef.current) clearTimeout(hideTimerRef.current); - hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000); - }, []); - - useEffect(() => { - showUi(); - return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }; - }, []); - - useEffect(() => { containerRef.current?.focus({ preventScroll: true }); }, [activeChapter?.id]); - - // ── Load chapter ───────────────────────────────────────────────────────────── - useEffect(() => { - if (!activeChapter) { - abortRef.current?.abort(); - appendedRef.current = new Set(); - appendingRef.current = false; - markedReadRef.current = new Set(); - setStripChapters([]); - setVisibleChapterId(null); - visibleChapterRef.current = null; - return; - } - - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - const targetId = activeChapter.id; - loadingIdRef.current = targetId; - appendedRef.current = new Set([targetId]); - appendingRef.current = false; - markedReadRef.current = new Set(); - // Clear stale aspect ratios — server URLs can return different images - // after a re-fetch, and a stale cached ratio renders as a black/collapsed img. - aspectCache.clear(); - setLoading(true); - setError(null); - setPageGroups([]); - setPageReady(false); - setStripChapters([]); - setVisibleChapterId(null); - visibleChapterRef.current = null; - - fetchPages(targetId, ctrl.signal) - .then(async (urls) => { - if (ctrl.signal.aborted) return; - // Don't block the render on decoding — set URLs immediately so the - // browser can start painting the first image without waiting for the - // full decode. The img element's own decoding="async" handles the rest. - setPageUrls(urls); - setPageReady(true); - if (style === "longstrip" && autoNext) { - const firstChunk: StripChapter = { - chapterId: targetId, - chapterName: activeChapter.name, - urls, - startGlobalIdx: 0, - }; - setStripChapters([firstChunk]); - setVisibleChapterId(targetId); - visibleChapterRef.current = targetId; - } - setLoading(false); - }) - .catch((e) => { - if (ctrl.signal.aborted) return; - setError(e instanceof Error ? e.message : String(e)); - setLoading(false); - }); - }, [activeChapter?.id]); - - // ── Append next chapter to the strip ──────────────────────────────────────── - const appendNextChapter = useCallback(() => { - if (appendingRef.current) return; - - const strip = stripChaptersRef.current; - const lastChunk = strip[strip.length - 1]; - if (!lastChunk) return; - - const list = chapterListRef.current; - const lastIdx = list.findIndex((c) => c.id === lastChunk.chapterId); - if (lastIdx < 0 || lastIdx >= list.length - 1) return; - - const nextEntry = list[lastIdx + 1]; - if (!nextEntry || appendedRef.current.has(nextEntry.id)) return; - - appendedRef.current.add(nextEntry.id); - appendingRef.current = true; - - fetchPages(nextEntry.id) - .then((urls) => { - // Kick off aspect measurement in background — don't block appending on it - urls.forEach((url) => measureAspect(url).catch(() => {})); - // Ensure the first several images are already in the browser cache - // by the time React renders them — eliminates the blank-image flash - // that occurs when a freshly appended chapter hasn't been prefetched. - urls.slice(0, 6).forEach(preloadImage); - return urls; - }) - .then((urls) => { - setStripChapters((cur) => { - if (cur.some((c) => c.chapterId === nextEntry.id)) return cur; - - const last = cur[cur.length - 1]; - const newStart = last ? last.startGlobalIdx + last.urls.length : 0; - const updated = [...cur, { - chapterId: nextEntry.id, - chapterName: nextEntry.name, - urls, - startGlobalIdx: newStart, - }]; - - if (updated.length > 3) { - // Snapshot scroll position BEFORE React removes the nodes. - // useLayoutEffect will restore it synchronously after the DOM - // mutation, preventing any visible jump. - if (containerRef.current) { - scrollAnchorRef.current = { - scrollTop: containerRef.current.scrollTop, - scrollHeight: containerRef.current.scrollHeight, - }; - } - return updated.slice(-3); - } - - return updated; - }); - appendingRef.current = false; - }) - .catch((err) => { - console.error("appendNextChapter failed:", err); - appendedRef.current.delete(nextEntry.id); - appendingRef.current = false; - }); - }, []); - - // ── Longstrip: scroll-driven page + chapter tracking + mark-as-read ────────── - useEffect(() => { - const el = containerRef.current; - if (!el || style !== "longstrip") return; - - const READ_LINE_PCT = 0.20; - - const onScroll = () => { - const containerTop = el.getBoundingClientRect().top; - const readLineY = containerTop + el.clientHeight * READ_LINE_PCT; - const imgs = el.querySelectorAll("img[data-local-page]"); - - let activeLocalPage: number | null = null; - let activeChId: number | null = null; - - for (const img of imgs) { - const rect = img.getBoundingClientRect(); - if (rect.top <= readLineY) { - activeLocalPage = Number(img.dataset.localPage); - activeChId = Number(img.dataset.chapter); - } else { - break; - } - } - - if (activeLocalPage === null && imgs.length > 0) { - activeLocalPage = Number(imgs[0].dataset.localPage); - activeChId = Number(imgs[0].dataset.chapter); - } - - if (activeLocalPage !== null) setPageNumber(activeLocalPage); - - if (activeChId && activeChId !== visibleChapterRef.current) { - visibleChapterRef.current = activeChId; - setVisibleChapterId(activeChId); - } - - if (settingsRef.current?.autoMarkRead && activeLocalPage !== null && activeChId) { - const strip = stripChaptersRef.current; - const chunk = strip.find((c) => c.chapterId === activeChId); - const total = chunk ? chunk.urls.length : pageUrlsRef.current.length; - if (total > 0 && activeLocalPage >= total - 1) { - const ch = activeChId; - if (!markedReadRef.current.has(ch)) { - markedReadRef.current.add(ch); - gql(MARK_CHAPTER_READ, { id: ch, isRead: true }).catch((e) => { - markedReadRef.current.delete(ch); - console.error("MARK_CHAPTER_READ failed for chapter", ch, e); - }); - } - } - } - }; - - el.addEventListener("scroll", onScroll, { passive: true }); - onScroll(); - return () => el.removeEventListener("scroll", onScroll); - }, [style]); - - // ── Longstrip: sentinel triggers append ────────────────────────────────────── - // activeChapter?.id in deps ensures the observer reinstalls fresh on every - // manga switch — without it, switching manga reuses the stale observer which - // has already fired and won't re-fire for the new chapter's sentinel position. - useEffect(() => { - const sentinel = sentinelRef.current; - const el = containerRef.current; - if (!sentinel || !el || style !== "longstrip" || !autoNext) return; - if (stripChapters.length === 0) return; - - // Trigger append when the user has scrolled through 80% of the current - // strip — early enough that the next chapter is ready before they reach - // the end. A fixed-pixel rootMargin can't express "80% of scrollHeight" - // so we use a scroll listener for the threshold check, and keep the - // IntersectionObserver only as a fallback for the absolute bottom. - const onScroll80 = () => { - const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight; - if (pct >= 0.8) appendNextChapter(); - }; - el.addEventListener("scroll", onScroll80, { passive: true }); - - // IntersectionObserver as hard backstop at the very bottom - const obs = new IntersectionObserver(([entry]) => { - if (!entry.isIntersecting) return; - appendNextChapter(); - }, { root: el, rootMargin: "0px", threshold: 0 }); - - obs.observe(sentinel); - - // Double-rAF ensures real image heights are committed before we measure. - // Fires the 80% check once on mount so short/cached chapters that never - // produce a scroll event still trigger an append. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (!containerRef.current) return; - const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight; - if (pct >= 0.8) appendNextChapter(); - }); - }); - - return () => { obs.disconnect(); el.removeEventListener("scroll", onScroll80); }; - }, [style, autoNext, stripChapters.length, activeChapter?.id, appendNextChapter]); - // ^^^^^^^^^^^^^^^^^ reinstall on manga switch - - // ── Mark last chapter read when reaching the very bottom ───────────────────── - useEffect(() => { - const el = containerRef.current; - if (!el || style !== "longstrip") return; - - const onScroll = () => { - if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return; - const last = stripChaptersRef.current[stripChaptersRef.current.length - 1]; - if (!last) return; - if (settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) { - markedReadRef.current.add(last.chapterId); - gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error); - } - }; - - el.addEventListener("scroll", onScroll, { passive: true }); - return () => el.removeEventListener("scroll", onScroll); - }, [style]); - - // Rebuild strip when autoNext is toggled while longstrip is active - useEffect(() => { - if (style !== "longstrip" || !pageUrls.length || !activeChapter) return; - appendedRef.current = new Set([activeChapter.id]); - appendingRef.current = false; - if (autoNext) { - setStripChapters([{ - chapterId: activeChapter.id, - chapterName: activeChapter.name, - urls: pageUrls, - startGlobalIdx: 0, - }]); - setVisibleChapterId(activeChapter.id); - visibleChapterRef.current = activeChapter.id; - } else { - setStripChapters([]); - setVisibleChapterId(null); - visibleChapterRef.current = null; - } - if (containerRef.current) containerRef.current.scrollTop = 0; - }, [autoNext, style]); - - // Reset scroll on non-longstrip page change - useEffect(() => { - if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; - }, [pageNumber, style]); - - // Always scroll to top when a new chapter opens — even if pageNumber stays at 1 - // (navigating chapter→chapter while already on page 1 won't trigger the effect above). - useEffect(() => { - if (containerRef.current) containerRef.current.scrollTop = 0; - }, [activeChapter?.id]); - - // ── Preload adjacent pages ─────────────────────────────────────────────────── - useEffect(() => { - const ahead = settings.preloadPages ?? 3; - for (let i = 1; i <= ahead; i++) { - const url = pageUrls[pageNumber - 1 + i]; - if (url) decodeImage(url); - } - const behind = pageUrls[pageNumber - 2]; - if (behind) preloadImage(behind); - }, [pageNumber, pageUrls, settings.preloadPages]); - - // ── Derived display values ─────────────────────────────────────────────────── - const lastPage = pageUrls.length; - - const displayChapter = useMemo(() => { - if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter; - return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter; - }, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]); - - // ── Adjacent chapters + cache eviction ────────────────────────────────────── - const adjacent = useMemo(() => { - const ref = displayChapter ?? activeChapter; - if (!ref || !activeChapterList.length) - return { prev: null, next: null, remaining: [] }; - const idx = activeChapterList.findIndex((c) => c.id === ref.id); - return { - prev: idx > 0 ? activeChapterList[idx - 1] : null, - next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null, - remaining: activeChapterList.slice(idx + 1), - }; - }, [displayChapter, activeChapter, activeChapterList]); - - // ── Prefetch next 3 chapters into pageCache so strip appends are instant ──── - // Fires whenever the active chapter changes. Fetches page URL lists for the - // next 3 chapters in the background so appendNextChapter always gets a cache - // hit instead of waiting on a network round-trip. - useEffect(() => { - if (!activeChapter || !activeChapterList.length) return; - const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id); - if (idx < 0) return; - - const PREFETCH_AHEAD = 3; - const toPin: number[] = [activeChapter.id]; - - for (let i = 1; i <= PREFETCH_AHEAD; i++) { - const entry = activeChapterList[idx + i]; - if (!entry) break; - toPin.push(entry.id); - fetchPages(entry.id) - .then((urls) => { - // Preload the first several images of every prefetched chapter, - // not just the immediate next one — chapters 2–3 ahead would - // otherwise start loading cold when appended, causing blank flashes. - // Fewer images for farther-ahead chapters to avoid wasting bandwidth. - const preloadCount = i === 1 ? 8 : i === 2 ? 4 : 2; - urls.slice(0, preloadCount).forEach(preloadImage); - }) - .catch(() => {}); - } - - // Pin one chapter behind too so going back is fast - if (idx > 0) { - const prev = activeChapterList[idx - 1]; - toPin.push(prev.id); - fetchPages(prev.id).catch(() => {}); - } - - cacheEvict(new Set(toPin)); - }, [activeChapter?.id, activeChapterList]); - - const visibleChunkLastPage = useMemo(() => { - if (style !== "longstrip" || !autoNext) return lastPage; - const chId = visibleChapterId ?? activeChapter?.id; - const chunk = stripChapters.find((c) => c.chapterId === chId); - return chunk?.urls.length ?? lastPage; - }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]); - - const visibleChunkPage = pageNumber; - - // ── Auto-mark read + history (non-longstrip) ───────────────────────────────── - useEffect(() => { - if (!activeChapter || !lastPage) return; - if (activeManga) { - addHistory({ - mangaId: activeManga.id, mangaTitle: activeManga.title, - thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id, - chapterName: activeChapter.name, pageNumber, readAt: Date.now(), - }); - } - if (style === "longstrip") return; - if (settings.autoMarkRead && pageNumber === lastPage) { - if (!markedReadRef.current.has(activeChapter.id)) { - markedReadRef.current.add(activeChapter.id); - gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error); - } - } - }, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead, style]); - - // ── Double-page grouping ───────────────────────────────────────────────────── - useEffect(() => { - if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; } - let cancelled = false; - const snap = pageUrls; - Promise.all(snap.map(measureAspect)).then((aspects) => { - if (cancelled || snap !== pageUrls) return; - const offset = settings.offsetDoubleSpreads; - const groups: number[][] = [[1]]; - if (offset) groups.push([2]); - let i = offset ? 3 : 2; - while (i <= snap.length) { - const a = aspects[i - 1]; - const nextA = aspects[i] ?? 0; - if (a > 1.2 || i === snap.length || nextA > 1.2) { - groups.push([i++]); - } else { - groups.push(rtl ? [i + 1, i] : [i, i + 1]); - i += 2; - } - } - setPageGroups(groups); - }); - return () => { cancelled = true; }; - }, [pageUrls, style, settings.offsetDoubleSpreads, rtl]); - - // ── Navigation ─────────────────────────────────────────────────────────────── - const advanceGroup = useCallback((forward: boolean) => { - if (!pageGroups.length) return; - const gi = pageGroups.findIndex((g) => g.includes(pageNumber)); - if (forward) { - if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]); - else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); } - else closeReader(); - } else { - if (gi > 0) setPageNumber(pageGroups[gi - 1][0]); - else if (adjacent.prev) openReader(adjacent.prev, activeChapterList); - } - }, [pageGroups, pageNumber, adjacent, activeChapterList]); - - const goForward = useCallback(() => { - if (loading) return; - // Longstrip: bottom arrows always switch chapters, not pages - if (style === "longstrip") { - if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } - return; - } - if (style === "double" && pageGroups.length) { advanceGroup(true); return; } - if (!pageUrls.length) return; - if (pageNumber < lastPage) { - decodeImage(pageUrls[pageNumber]).then(() => setPageNumber(pageNumber + 1)); - } else if (adjacent.next) { - maybeMarkCurrentRead(); - setPageNumber(1); openReader(adjacent.next, activeChapterList); - } else { - closeReader(); - } - }, [loading, style, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup, maybeMarkCurrentRead]); - - const goBack = useCallback(() => { - if (loading) return; - // Longstrip: bottom arrows always switch chapters, not pages - if (style === "longstrip") { - if (adjacent.prev) openReader(adjacent.prev, activeChapterList); - return; - } - if (style === "double" && pageGroups.length) { advanceGroup(false); return; } - if (!pageUrls.length) return; - if (pageNumber > 1) { - decodeImage(pageUrls[pageNumber - 2]).then(() => setPageNumber(pageNumber - 1)); - } else if (adjacent.prev) { - openReader(adjacent.prev, activeChapterList); - } - }, [loading, style, pageNumber, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup]); - - const goNext = rtl ? goBack : goForward; - const goPrev = rtl ? goForward : goBack; - - function cycleStyle() { - const opts = ["single", "longstrip"] as const; - const cur = style === "double" ? "single" : style; - updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] }); - } - - function cycleFit() { - const opts: FitMode[] = ["width", "height", "screen", "original"]; - updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] }); - } - - // ── Ctrl+scroll → zoom ─────────────────────────────────────────────────────── - useEffect(() => { - const onWheel = (e: WheelEvent) => { - if (!e.ctrlKey) return; - e.preventDefault(); - updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) }); - }; - window.addEventListener("wheel", onWheel, { passive: false }); - return () => window.removeEventListener("wheel", onWheel); - }, [maxW]); - - // ── Keybinds ───────────────────────────────────────────────────────────────── - const goForwardRef = useRef(goForward); - const goBackRef = useRef(goBack); - const cycleStyleRef = useRef(cycleStyle); - useEffect(() => { goForwardRef.current = goForward; }, [goForward]); - useEffect(() => { goBackRef.current = goBack; }, [goBack]); - useEffect(() => { cycleStyleRef.current = cycleStyle; }); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if ((e.target as HTMLElement).tagName === "INPUT") return; - const kb: Keybinds = settingsRef.current?.keybinds ?? DEFAULT_KEYBINDS; - const maxW = settingsRef.current?.maxPageWidth ?? 900; - const rtl = settingsRef.current?.readingDirection === "rtl"; - - if (e.key === "Escape") { - e.preventDefault(); - if (zoomOpen) { setZoomOpen(false); return; } - if (dlOpen) { setDlOpen(false); return; } - closeReader(); return; - } - if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) }); return; } - if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, maxW - 100) }); return; } - if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; } - - if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } - else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForwardRef.current(); } - else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBackRef.current(); } - else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); } - else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); } - else if (matchesKeybind(e, kb.chapterRight)) { - e.preventDefault(); - const list = chapterListRef.current; - const idx = list.findIndex((c) => c.id === loadingIdRef.current); - const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null; - if (next) { maybeMarkCurrentRead(); openReader(next, list); } - } - else if (matchesKeybind(e, kb.chapterLeft)) { - e.preventDefault(); - const list = chapterListRef.current; - const idx = list.findIndex((c) => c.id === loadingIdRef.current); - const prev = idx > 0 ? list[idx - 1] : null; - if (prev) openReader(prev, list); - } - else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyleRef.current(); } - else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); } - else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); } - else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [zoomOpen, dlOpen, lastPage, maybeMarkCurrentRead]); - - // ── Render ─────────────────────────────────────────────────────────────────── - function handleTap(e: React.MouseEvent) { - if (style === "longstrip") return; - const x = e.clientX / window.innerWidth; - if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); } - else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); } - } - - const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties; - const imgCls = [ - s.img, - fit === "width" && s.fitWidth, - fit === "height" && s.fitHeight, - fit === "screen" && s.fitScreen, - fit === "original" && s.fitOriginal, - settings.optimizeContrast && s.optimizeContrast, - ].filter(Boolean).join(" "); - const fitIcon = - fit === "width" ? : - fit === "height" ? : - fit === "screen" ? : - ; - const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]; - const styleIcon = style === "single" ? : ; - - const stripToRender: StripChapter[] = style === "longstrip" - ? (autoNext && stripChapters.length > 0 - ? stripChapters - : [{ chapterId: activeChapter?.id ?? 0, chapterName: activeChapter?.name ?? "", urls: pageUrls, startGlobalIdx: 0 }]) - : []; - - return ( -
{ - if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); - }}> - {/* ── Topbar ── */} -
- - - - {activeManga?.title} - / - {displayChapter?.name} - - {visibleChunkPage} / {visibleChunkLastPage || "…"} - -
- -
- - {zoomOpen && ( - updateSettings({ maxPageWidth: v })} - onReset={() => updateSettings({ maxPageWidth: 900 })} - onClose={() => setZoomOpen(false)} /> - )} -
- - - {style !== "single" && ( - - )} - {style === "longstrip" && ( - - )} - {!autoNext && ( - - )} - -
- - {/* ── Viewer ── */} -
{ if (e.ctrlKey) e.preventDefault(); }} - onKeyDown={(e) => { - if (e.key === " " && style === "longstrip") { - e.preventDefault(); - containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" }); - } - }} - > - {loading && ( -
- -
- )} - {error && ( -
-

{error}

-
- )} - {style === "longstrip" ? ( - <> - {stripToRender.map((chunk) => - chunk.urls.map((url, i) => { - const localPage = i + 1; - return ( - {`${chunk.chapterName} - ); - }) - )} -
- - ) : (pageReady && ( - {`Page - ))} -
- - {/* ── Bottom nav ── */} -
- - -
- - {dlOpen && activeChapter && ( - setDlOpen(false)} /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/pages/Search.module.css b/src/components/pages/Search.module.css deleted file mode 100644 index 31a0620..0000000 --- a/src/components/pages/Search.module.css +++ /dev/null @@ -1,789 +0,0 @@ -/* ── Root ──────────────────────────────────────────────────────────────────── */ - -.root { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - animation: fadeIn 0.14s ease both; -} - -/* ── Header ────────────────────────────────────────────────────────────────── */ - -.header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-5) var(--sp-6) var(--sp-3); - flex-shrink: 0; - border-bottom: 1px solid var(--border-dim); -} - -.heading { - font-family: var(--font-ui); - font-size: var(--text-xs); - font-weight: var(--weight-normal); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -/* ── Tabs ──────────────────────────────────────────────────────────────────── */ - -.tabs { - display: flex; - 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); - background: none; - color: var(--text-faint); - cursor: pointer; - transition: background var(--t-base), color var(--t-base); - white-space: nowrap; -} -.tab:hover { color: var(--text-muted); } - -.tabActive { - background: var(--accent-muted); - color: var(--accent-fg); - border: 1px solid var(--accent-dim); -} -.tabActive:hover { color: var(--accent-fg); } - -/* ── Keyword bar ───────────────────────────────────────────────────────────── */ - -.keywordBar { - padding: var(--sp-3) var(--sp-4); - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: var(--sp-2); -} - -.searchBar { - display: flex; - align-items: center; - gap: var(--sp-2); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - padding: 0 var(--sp-3) 0 var(--sp-2); - transition: border-color var(--t-base); -} -.searchBar:focus-within { border-color: var(--border-strong); } - -.searchIcon { color: var(--text-faint); flex-shrink: 0; } - -.searchInput { - flex: 1; - background: none; - border: none; - outline: none; - color: var(--text-primary); - font-size: var(--text-sm); - padding: 7px 0; -} -.searchInput::placeholder { color: var(--text-faint); } - -.clearBtn { - color: var(--text-faint); - font-size: 14px; - line-height: 1; - background: none; - border: none; - cursor: pointer; - padding: 2px; - transition: color var(--t-base); -} -.clearBtn:hover { color: var(--text-muted); } - -.advancedBtn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-sm); - color: var(--text-faint); - flex-shrink: 0; - transition: color var(--t-base), background var(--t-base); -} -.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); } -.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); } -.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } - -.searchBtn { - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - padding: 6px 12px; - border-radius: var(--radius-md); - background: var(--accent-muted); - color: var(--accent-fg); - border: 1px solid var(--accent-dim); - cursor: pointer; - flex-shrink: 0; - display: flex; - align-items: center; - gap: var(--sp-1); - transition: filter var(--t-base); -} -.searchBtn:hover:not(:disabled) { filter: brightness(1.1); } -.searchBtn:disabled { opacity: 0.4; cursor: default; } - -/* ── Advanced filter panel ─────────────────────────────────────────────────── */ - -.advancedPanel { - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - padding: var(--sp-3); - display: flex; - flex-direction: column; - gap: var(--sp-2); - animation: fadeIn 0.1s ease both; -} - -.advancedHeader { - display: flex; - align-items: center; - justify-content: space-between; -} - -.advancedTitle { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -.advancedActions { display: flex; gap: var(--sp-1); } - -.advancedLink { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--accent-fg); - background: none; - border: none; - padding: 0; - cursor: pointer; - opacity: 0.7; - transition: opacity var(--t-base); -} -.advancedLink:hover { opacity: 1; } - -.langGrid { - display: flex; - flex-wrap: wrap; - gap: var(--sp-1); -} - -.langChip { - 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: none; - color: var(--text-faint); - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); } - -.langChipActive { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} -.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); } - -.advancedDivider { - height: 1px; - background: var(--border-dim); - margin: 2px 0; -} - -.advancedCheck { - display: flex; - align-items: center; - gap: var(--sp-2); - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-muted); - cursor: pointer; -} - -.checkbox { accent-color: var(--accent-fg); cursor: pointer; } - -.advancedFooter { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.advancedLinkStandalone { - display: inline-flex; - align-items: center; - gap: 5px; - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--accent-fg); - background: none; - border: none; - padding: 0; - cursor: pointer; - opacity: 0.7; - transition: opacity var(--t-base); -} -.advancedLinkStandalone:hover { opacity: 1; } - -/* ── Empty states ──────────────────────────────────────────────────────────── */ - -.empty { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--sp-2); -} - -.emptyIcon { color: var(--text-faint); } -.emptyText { font-size: var(--text-base); color: var(--text-muted); } -.emptyHint { font-size: var(--text-sm); color: var(--text-faint); } - -/* ── Keyword results ───────────────────────────────────────────────────────── */ - -.results { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; -} - -.sourceSection { - padding: var(--sp-1) var(--sp-4) var(--sp-3); - border-bottom: 1px solid var(--border-dim); -} -.sourceSection:last-child { border-bottom: none; } - -.sourceHeader { - display: flex; - align-items: center; - gap: var(--sp-2); - padding: var(--sp-2) 0; -} - -.sourceIcon { - width: 18px; - height: 18px; - border-radius: var(--radius-sm); - object-fit: cover; - flex-shrink: 0; - background: var(--bg-raised); -} - -.sourceName { - font-size: var(--text-base); - font-weight: var(--weight-medium); - color: var(--text-secondary); -} - -.sourceLang { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-sm); - padding: 1px 5px; -} - -.resultCount { - margin-left: auto; - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.sourceError { - font-size: var(--text-xs); - color: var(--color-error); - padding: var(--sp-1) 0; - margin: 0; -} - -/* Horizontal scroll row */ -.sourceRow { - display: flex; - gap: var(--sp-3); - overflow-x: auto; - padding-bottom: var(--sp-1); - scrollbar-width: none; -} -.sourceRow::-webkit-scrollbar { display: none; } - -/* ── Manga card ────────────────────────────────────────────────────────────── */ - -.card { - display: flex; - flex-direction: column; - gap: var(--sp-2); - cursor: pointer; - flex-shrink: 0; - width: 110px; - text-align: left; - background: none; - border: none; - padding: 0; -} -.card:hover .cover { filter: brightness(1.06); } -.card:hover .cardTitle { color: var(--text-primary); } - -.coverWrap { - position: relative; - width: 100%; - aspect-ratio: 2 / 3; - border-radius: var(--radius-md); - overflow: hidden; - background: var(--bg-raised); - border: 1px solid var(--border-dim); - transform: translateZ(0); -} - -.cover { - width: 100%; - height: 100%; - object-fit: cover; - transition: filter var(--t-base); -} - -.inLibBadge { - position: absolute; - bottom: var(--sp-1); - right: var(--sp-1); - background: var(--accent-dim); - color: var(--accent-fg); - font-family: var(--font-ui); - font-size: 9px; - font-weight: var(--weight-medium); - letter-spacing: var(--tracking-wide); - padding: 1px 5px; - border-radius: var(--radius-sm); - border: 1px solid var(--accent-muted); -} - -.cardTitle { - 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); -} - -/* ── Skeleton ──────────────────────────────────────────────────────────────── */ - -.skCard { - display: flex; - flex-direction: column; - gap: var(--sp-2); - flex-shrink: 0; - width: 110px; -} - -.tagGrid .card { width: 100%; } -.tagGrid .skCard { width: 100%; } - -.skeleton { border-radius: var(--radius-sm); } - -.skCover { - aspect-ratio: 2 / 3; - width: 100%; - border-radius: var(--radius-md); -} - -.skTitle { height: 10px; width: 80%; } - -/* ── Split root (Tag + Source tabs) ────────────────────────────────────────── */ - -.splitRoot { - flex: 1; - display: flex; - overflow: hidden; -} - -/* ── Split sidebar ─────────────────────────────────────────────────────────── */ - -.splitSidebar { - width: 180px; - flex-shrink: 0; - border-right: 1px solid var(--border-dim); - overflow: hidden; - display: flex; - flex-direction: column; -} - -.splitSearchWrap { - display: flex; - align-items: center; - gap: var(--sp-1); - padding: var(--sp-2) var(--sp-3); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; } - -.splitSearchInput { - flex: 1; - background: none; - border: none; - outline: none; - font-size: var(--text-xs); - color: var(--text-primary); - font-family: var(--font-ui); - min-width: 0; -} -.splitSearchInput::placeholder { color: var(--text-faint); } - -.splitSearchClear { - color: var(--text-faint); - font-size: 13px; - line-height: 1; - background: none; - border: none; - cursor: pointer; - padding: 2px; - transition: color var(--t-base); -} -.splitSearchClear:hover { color: var(--text-muted); } - -.splitList { - flex: 1; - overflow-y: auto; - padding: var(--sp-1); - scrollbar-width: thin; - scrollbar-color: var(--border-dim) transparent; -} - -.splitItem { - display: flex; - align-items: center; - gap: var(--sp-2); - width: 100%; - padding: 7px var(--sp-3); - border-radius: var(--radius-md); - border: 1px solid transparent; - background: none; - text-align: left; - cursor: pointer; - transition: background var(--t-fast), border-color var(--t-fast); -} -.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); } - -.splitItemActive { - background: var(--accent-muted); - border-color: var(--accent-dim); -} -.splitItemActive:hover { background: var(--accent-muted); } - -.splitItemLabel { - font-size: var(--text-xs); - color: var(--text-muted); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); } - -.splitItemSource { gap: var(--sp-2); } - -.splitEmpty { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - padding: var(--sp-3); - margin: 0; -} - -.splitLoading { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: var(--sp-6); -} - -/* ── Split content ─────────────────────────────────────────────────────────── */ - -.splitContent { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.splitContentHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-3) var(--sp-4); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; - gap: var(--sp-2); -} - -.splitSourceTitle { - display: flex; - align-items: center; - gap: var(--sp-2); - flex: 1; - min-width: 0; -} - -.splitContentTitle { - font-size: var(--text-base); - font-weight: var(--weight-medium); - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - letter-spacing: var(--tracking-tight); -} - -.splitResultCount { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - flex-shrink: 0; -} - -.splitSourceIcon { - width: 18px; - height: 18px; - border-radius: var(--radius-sm); - object-fit: cover; - flex-shrink: 0; - background: var(--bg-raised); -} - -/* ── Tag active bar ────────────────────────────────────────────────────────── */ - -.tagActiveBar { - display: flex; - align-items: flex-start; - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-4); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; - flex-wrap: wrap; -} - -.tagPillRow { - display: flex; - flex-wrap: wrap; - gap: var(--sp-1); - flex: 1; - min-width: 0; -} - -.tagPill { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 7px; - background: var(--accent-muted); - border: 1px solid var(--accent-dim); - border-radius: var(--radius-sm); - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--accent-fg); -} - -.tagPillRemove { - color: var(--accent-fg); - opacity: 0.6; - font-size: 13px; - line-height: 1; - background: none; - border: none; - cursor: pointer; - padding: 0; - transition: opacity var(--t-base); -} -.tagPillRemove:hover { opacity: 1; } - -.tagBarRight { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; -} - -.tagModeToggle { - display: flex; - border: 1px solid var(--border-dim); - border-radius: var(--radius-md); - overflow: hidden; -} - -.tagModeBtn { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--text-faint); - background: none; - border: none; - border-right: 1px solid var(--border-dim); - cursor: pointer; - transition: color var(--t-base), background var(--t-base); -} -.tagModeBtn:last-child { border-right: none; } -.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } -.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); } -.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } - -.tagClearAll { - display: flex; - align-items: center; - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--text-faint); - padding: 4px 8px; - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - background: none; - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.tagClearAll:hover { - color: var(--color-error); - border-color: color-mix(in srgb, var(--color-error) 40%, transparent); - background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); -} - -.tagCheckMark { - font-size: var(--text-xs); - color: var(--accent-fg); - margin-left: auto; -} - -/* ── Grid results ──────────────────────────────────────────────────────────── */ - -.tagGrid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); - gap: var(--sp-4); - padding: var(--sp-4); - overflow-y: auto; - flex: 1; - align-content: start; -} - -/* ── Show more / load more ─────────────────────────────────────────────────── */ - -.showMoreCell { - grid-column: 1 / -1; - display: flex; - justify-content: center; - gap: var(--sp-2); - padding: var(--sp-2) 0; -} - -.showMoreBtn { - display: inline-flex; - align-items: center; - gap: var(--sp-1); - padding: 5px 12px; - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - background: none; - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - color: var(--text-muted); - cursor: pointer; - transition: background var(--t-base), color var(--t-base), border-color var(--t-base); -} -.showMoreBtn:hover:not(:disabled) { - background: var(--bg-raised); - color: var(--text-secondary); - border-color: var(--border-strong); -} -.showMoreBtn:disabled { opacity: 0.4; cursor: default; } - -.loadMoreRow { - display: flex; - justify-content: center; - padding: var(--sp-3) var(--sp-4); - flex-shrink: 0; - border-top: 1px solid var(--border-dim); -} - -/* ── Source tab: lang filter + browse bar ──────────────────────────────────── */ - -.langFilterRow { - display: flex; - flex-wrap: wrap; - gap: var(--sp-1); - padding: var(--sp-2) var(--sp-3); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.sourceBrowseBar { - display: flex; - align-items: center; - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-4); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; -} - -/* ── NSFW badge ────────────────────────────────────────────────────────────── */ - -.nsfwBadge { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - color: var(--color-error); - background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); - border: 1px solid rgba(180, 60, 60, 0.25); - border-radius: var(--radius-sm); - padding: 1px 5px; - margin-left: auto; - flex-shrink: 0; -} \ No newline at end of file diff --git a/src/components/pages/Search.tsx b/src/components/pages/Search.tsx deleted file mode 100644 index 5d783a0..0000000 --- a/src/components/pages/Search.tsx +++ /dev/null @@ -1,1018 +0,0 @@ -import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react"; -import { - MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List, Globe, -} from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; -import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; -import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils"; -import { useStore } from "../../store"; -import type { Manga, Source } from "../../lib/types"; -import s from "./Search.module.css"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -type SearchTab = "keyword" | "tag" | "source"; -type TagMode = "AND" | "OR"; - -interface SourceResult { - source: Source; - mangas: Manga[]; - loading: boolean; - error: string | null; -} - -// ── Constants ───────────────────────────────────────────────────────────────── - -const CONCURRENCY = 4; -const RESULTS_PER_SOURCE = 8; -const TAG_PAGE_SIZE = 48; -const MAX_TAG_SOURCES = 10; - -const COMMON_GENRES = [ - "Action","Adventure","Comedy","Drama","Fantasy","Romance", - "Sci-Fi","Slice of Life","Horror","Mystery","Thriller","Sports", - "Supernatural","Mecha","Historical","Psychological","School Life", - "Shounen","Seinen","Josei","Shoujo","Isekai","Martial Arts", - "Magic","Music","Cooking","Medical","Military","Harem","Ecchi", -]; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -async function runConcurrent( - items: T[], - fn: (item: T) => Promise, - signal: AbortSignal, -): Promise { - 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)); -} - -function matchesAllTags(m: Manga, tags: string[]): boolean { - const genres = (m.genre ?? []).map((g) => g.toLowerCase()); - return tags.every((t) => genres.includes(t.toLowerCase())); -} - -// ── Shared card components ──────────────────────────────────────────────────── - -const CoverImg = memo(function CoverImg({ - src, alt, className, -}: { src: string; alt: string; className?: string }) { - const [loaded, setLoaded] = useState(false); - return ( - {alt} setLoaded(true)} - style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }} - /> - ); -}); - -function MangaCard({ manga, onClick }: { manga: Manga; onClick: () => void }) { - return ( - - ); -} - -function GridSkeleton({ count = 18 }: { count?: number }) { - return ( -
- {Array.from({ length: count }).map((_, i) => ( -
-
-
-
- ))} -
- ); -} - -function RowSkeleton({ count = 4 }: { count?: number }) { - return ( -
- {Array.from({ length: count }).map((_, i) => ( -
-
-
-
- ))} -
- ); -} - -// ── Root ────────────────────────────────────────────────────────────────────── - -export default function Search() { - const [tab, setTab] = useState("keyword"); - - const preferredLang = useStore((st) => st.settings.preferredExtensionLang); - const searchPrefill = useStore((st) => st.searchPrefill ?? ""); - const setSearchPrefill = useStore((st) => st.setSearchPrefill); - const setPreviewManga = useStore((st) => st.setPreviewManga); - - const [allSources, setAllSources] = useState([]); - const [loadingSources, setLoadingSources] = useState(false); - const pendingPrefill = useRef(""); - - useEffect(() => { - if (!searchPrefill) return; - pendingPrefill.current = searchPrefill; - setTab("keyword"); - setSearchPrefill(""); - }, [searchPrefill, setSearchPrefill]); - - useEffect(() => { - setLoadingSources(true); - cache.get( - CACHE_KEYS.SOURCES, - () => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => d.sources.nodes.filter((src) => src.id !== "0")), - Infinity, - ) - .then(setAllSources) - .catch(console.error) - .finally(() => setLoadingSources(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const availableLangs = useMemo( - () => Array.from(new Set(allSources.map((src) => src.lang))).sort(), - [allSources], - ); - const hasMultipleLangs = availableLangs.length > 1; - - return ( -
-
-

Search

-
- - - -
-
- - {tab === "keyword" && ( - - )} - {tab === "tag" && ( - - )} - {tab === "source" && ( - - )} -
- ); -} - -// ── Keyword tab ─────────────────────────────────────────────────────────────── - -function KeywordTab({ - allSources, loadingSources, availableLangs, hasMultipleLangs, - preferredLang, pendingPrefill, onMangaClick, -}: { - allSources: Source[]; - loadingSources: boolean; - availableLangs: string[]; - hasMultipleLangs: boolean; - preferredLang: string; - pendingPrefill: React.MutableRefObject; - onMangaClick: (m: Manga) => void; -}) { - const [query, setQuery] = useState(""); - const [submitted, setSubmitted] = useState(""); - const [results, setResults] = useState([]); - const [showAdvanced, setShowAdvanced] = useState(false); - const [selectedLangs, setSelectedLangs] = useState>(new Set()); - const [includeNsfw, setIncludeNsfw] = useState(false); - - const abortRef = useRef(null); - const inputRef = useRef(null); - const allSourcesRef = useRef([]); - const selectedLangsRef = useRef>(new Set()); - const includeNsfwRef = useRef(false); - - useEffect(() => { allSourcesRef.current = allSources; }, [allSources]); - useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]); - useEffect(() => { includeNsfwRef.current = includeNsfw; }, [includeNsfw]); - - useEffect(() => { - if (!allSources.length) return; - const available = new Set(allSources.map((src) => src.lang)); - setSelectedLangs( - available.has(preferredLang) - ? new Set([preferredLang]) - : new Set(availableLangs.slice(0, 1)), - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allSources]); - - // Consume prefill once sources are ready - useEffect(() => { - if (loadingSources || !pendingPrefill.current || submitted) return; - if (!allSourcesRef.current.length) return; - const q = pendingPrefill.current; - pendingPrefill.current = ""; - setQuery(q); - Promise.resolve().then(() => doSearch(q)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadingSources]); - - useEffect(() => () => { abortRef.current?.abort(); }, []); - - const getVisibleSources = useCallback((): Source[] => { - let filtered = allSourcesRef.current; - if (selectedLangsRef.current.size > 0) - filtered = filtered.filter((src) => selectedLangsRef.current.has(src.lang)); - if (!includeNsfwRef.current) - filtered = filtered.filter((src) => !src.isNsfw); - return filtered; - }, []); - - const doSearch = useCallback(async (q: string) => { - const trimmed = q.trim(); - if (!trimmed) return; - const visible = getVisibleSources(); - if (!visible.length) return; - - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - setSubmitted(trimmed); - setResults(visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }))); - - await runConcurrent(visible, async (src) => { - if (ctrl.signal.aborted) return; - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page: 1, query: trimmed }, - ctrl.signal, - ); - if (ctrl.signal.aborted) return; - setResults((prev) => prev.map((r) => - r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r, - )); - } catch (e: any) { - if (ctrl.signal.aborted || e?.name === "AbortError") return; - setResults((prev) => prev.map((r) => - r.source.id === src.id ? { ...r, loading: false, error: e.message ?? "Error" } : r, - )); - } - }, ctrl.signal); - }, [getVisibleSources]); - - function toggleLang(lang: string) { - setSelectedLangs((prev) => { - const next = new Set(prev); - if (next.has(lang)) { if (next.size === 1) return prev; next.delete(lang); } - else next.add(lang); - return next; - }); - } - - const visibleCount = getVisibleSources().length; - const hasResults = results.some((r) => r.mangas.length > 0); - const allDone = results.length > 0 && results.every((r) => !r.loading); - - return ( - <> -
-
- - setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && doSearch(query)} - /> - {query && ( - - )} - {hasMultipleLangs && ( - - )} - -
- - {hasMultipleLangs && showAdvanced && ( -
-
- Languages -
- - -
-
-
- {availableLangs.map((lang) => ( - - ))} -
-
- -
- Searching {visibleCount} source{visibleCount !== 1 ? "s" : ""} -
-
- )} -
- - {!submitted ? ( -
- -

Search across sources

-

- {hasMultipleLangs - ? `${visibleCount} source${visibleCount !== 1 ? "s" : ""} · ${selectedLangs.size} language${selectedLangs.size !== 1 ? "s" : ""}` - : `${visibleCount} source${visibleCount !== 1 ? "s" : ""}`} -

- {hasMultipleLangs && !showAdvanced && ( - - )} -
- ) : ( -
- {results.length === 0 && ( -
- -
- )} - {results - .filter((r) => r.mangas.length > 0 || r.loading || r.error) - .map(({ source, mangas, loading, error }) => ( -
-
- {source.displayName} { (e.target as HTMLImageElement).style.display = "none"; }} /> - {source.displayName} - {hasMultipleLangs && {source.lang.toUpperCase()}} - {loading - ? - : mangas.length > 0 && {mangas.length} results} -
- {error ? ( -

{error}

- ) : loading ? ( - - ) : mangas.length > 0 ? ( -
- {mangas.slice(0, RESULTS_PER_SOURCE).map((m) => ( - onMangaClick(m)} /> - ))} -
- ) : null} -
- ))} - {allDone && !hasResults && ( -
-

No results for "{submitted}"

-

Try a different spelling or fewer words

-
- )} -
- )} - - ); -} - -// ── Tag tab ─────────────────────────────────────────────────────────────────── - -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) { - nodes { - id title thumbnailUrl inLibrary genre status - source { id displayName } - } - pageInfo { hasNextPage } - totalCount - } - } -`; - -function buildGenreFilter(tags: string[], mode: TagMode): Record { - if (tags.length === 0) return {}; - if (mode === "AND") return { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }; - return { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) }; -} - -function TagTab({ - allSources, loadingSources, preferredLang, onMangaClick, -}: { - allSources: Source[]; - loadingSources: boolean; - preferredLang: string; - onMangaClick: (m: Manga) => void; -}) { - const [activeTags, setActiveTags] = useState([]); - const [tagMode, setTagMode] = useState("AND"); - const [tagFilter, setTagFilter] = useState(""); - - const [localResults, setLocalResults] = useState([]); - const [totalCount, setTotalCount] = useState(0); - const [loadingLocal, setLoadingLocal] = useState(false); - const [loadingMoreLocal, setLoadingMoreLocal] = useState(false); - const [localOffset, setLocalOffset] = useState(0); - const [localHasNext, setLocalHasNext] = useState(false); - const abortLocalRef = useRef(null); - - const [searchSources, setSearchSources] = useState(false); - const [sourceResults, setSourceResults] = useState([]); - const [loadingSourceSearch, setLoadingSourceSearch] = useState(false); - const [loadingMoreSource, setLoadingMoreSource] = useState(false); - const srcNextPageRef = useRef>(new Map()); - const abortSourceRef = useRef(null); - - useEffect(() => () => { - abortLocalRef.current?.abort(); - abortSourceRef.current?.abort(); - }, []); - - useEffect(() => { - if (activeTags.length === 0) { - setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0); - return; - } - abortLocalRef.current?.abort(); - const ctrl = new AbortController(); - abortLocalRef.current = ctrl; - setLocalResults([]); setTotalCount(0); setLocalOffset(0); setLocalHasNext(false); - setLoadingLocal(true); - - gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>( - MANGAS_BY_GENRE, - { filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: 0 }, - ctrl.signal, - ).then((d) => { - if (ctrl.signal.aborted) return; - setLocalResults(d.mangas.nodes); - setTotalCount(d.mangas.totalCount); - setLocalHasNext(d.mangas.pageInfo.hasNextPage); - setLocalOffset(TAG_PAGE_SIZE); - }).catch((e: any) => { - if (e?.name !== "AbortError") console.error(e); - }).finally(() => { - if (!ctrl.signal.aborted) setLoadingLocal(false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTags, tagMode]); - - useEffect(() => { - if (!searchSources || activeTags.length === 0 || loadingSources) return; - - abortSourceRef.current?.abort(); - const ctrl = new AbortController(); - abortSourceRef.current = ctrl; - - setSourceResults([]); - srcNextPageRef.current = new Map(); - setLoadingSourceSearch(true); - - const sources = dedupeSources(allSources, preferredLang).slice(0, MAX_TAG_SOURCES); - const primaryTag = activeTags[0]; - - for (const src of sources) srcNextPageRef.current.set(src.id, -1); - - runConcurrent(sources, async (src) => { - if (ctrl.signal.aborted) return; - - const ps = getPageSet(src.id, "SEARCH", activeTags); - const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags); - - const result = await cache - .get<{ mangas: Manga[]; hasNextPage: boolean }>( - pageKey, - () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page: 1, query: primaryTag }, - ctrl.signal, - ).then((d) => d.fetchSourceManga), - ) - .catch((e: any) => { - if (e?.name !== "AbortError") console.error(e); - return null; - }); - - if (!result || ctrl.signal.aborted) return; - - ps.add(1); - srcNextPageRef.current.set(src.id, result.hasNextPage ? 2 : -1); - - const matching = activeTags.length > 1 - ? result.mangas.filter((m) => matchesAllTags(m, activeTags)) - : result.mangas; - - if (matching.length > 0) { - setSourceResults((prev) => dedupeMangaById([...prev, ...matching])); - setLoadingSourceSearch(false); - } - }, ctrl.signal).finally(() => { - if (!ctrl.signal.aborted) setLoadingSourceSearch(false); - }); - - return () => { ctrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchSources, activeTags, allSources, loadingSources]); - - async function loadMoreLocal() { - if (loadingMoreLocal || !localHasNext) return; - setLoadingMoreLocal(true); - abortLocalRef.current?.abort(); - const ctrl = new AbortController(); - abortLocalRef.current = ctrl; - try { - const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>( - MANGAS_BY_GENRE, - { filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: localOffset }, - ctrl.signal, - ); - if (ctrl.signal.aborted) return; - setLocalResults((prev) => [...prev, ...d.mangas.nodes]); - setLocalHasNext(d.mangas.pageInfo.hasNextPage); - setLocalOffset((o) => o + TAG_PAGE_SIZE); - } catch (e: any) { - if (e?.name !== "AbortError") console.error(e); - } finally { - if (!ctrl.signal.aborted) setLoadingMoreLocal(false); - } - } - - const sourceHasMore = searchSources && - [...srcNextPageRef.current.values()].some((p) => p > 0); - - async function loadMoreSource() { - if (loadingMoreSource || !sourceHasMore) return; - setLoadingMoreSource(true); - abortSourceRef.current?.abort(); - const ctrl = new AbortController(); - abortSourceRef.current = ctrl; - - const sources = dedupeSources(allSources, preferredLang) - .slice(0, MAX_TAG_SOURCES) - .filter((src) => (srcNextPageRef.current.get(src.id) ?? -1) > 0); - const primaryTag = activeTags[0]; - - try { - await runConcurrent(sources, async (src) => { - const page = srcNextPageRef.current.get(src.id)!; - if (ctrl.signal.aborted) return; - - const ps = getPageSet(src.id, "SEARCH", activeTags); - const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, activeTags); - - const result = await cache - .get<{ mangas: Manga[]; hasNextPage: boolean }>( - pageKey, - () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page, query: primaryTag }, - ctrl.signal, - ).then((d) => d.fetchSourceManga), - ) - .catch((e: any) => { - if (e?.name !== "AbortError") srcNextPageRef.current.set(src.id, -1); - return null; - }); - - if (!result || ctrl.signal.aborted) return; - - ps.add(page); - srcNextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1); - - const matching = activeTags.length > 1 - ? result.mangas.filter((m) => matchesAllTags(m, activeTags)) - : result.mangas; - - if (matching.length > 0) - setSourceResults((prev) => dedupeMangaById([...prev, ...matching])); - }, ctrl.signal); - } finally { - if (!ctrl.signal.aborted) setLoadingMoreSource(false); - } - } - - function toggleTag(tag: string) { - srcNextPageRef.current = new Map(); - setSourceResults([]); - setActiveTags((prev) => - prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], - ); - } - - const filteredGenres = useMemo(() => { - const q = tagFilter.trim().toLowerCase(); - return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; - }, [tagFilter]); - - const hasActiveTags = activeTags.length > 0; - const localIds = useMemo(() => new Set(localResults.map((m) => m.id)), [localResults]); - const mergedResults = searchSources - ? [...localResults, ...sourceResults.filter((m) => !localIds.has(m.id))] - : localResults; - const totalVisible = localResults.length + (searchSources ? sourceResults.length : 0); - - return ( -
-
-
- - setTagFilter(e.target.value)} - /> - {tagFilter && ( - - )} -
-
- {filteredGenres.map((tag) => ( - - ))} - {filteredGenres.length === 0 &&

No matching tags

} -
-
- -
- {!hasActiveTags ? ( -
- -

Browse by tag

-

Select one or more genre tags to find matching manga.

-
- ) : ( - <> -
-
- {activeTags.map((tag) => ( - - {tag} - - - ))} -
-
- {activeTags.length > 1 && ( -
- - -
- )} - - -
-
- -
- - {activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`} - {searchSources && ( - - + sources - - )} - - {(loadingLocal || loadingSourceSearch) - ? - : - {totalVisible}{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results - - } -
- - {loadingLocal ? ( - - ) : mergedResults.length > 0 ? ( -
- {mergedResults.map((m) => ( - onMangaClick(m)} /> - ))} - - {loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
- ))} - - {(localHasNext || sourceHasMore) && ( -
- {localHasNext && ( - - )} - {sourceHasMore && ( - - )} -
- )} -
- ) : ( -
-

No results for {activeTags.join(` ${tagMode} `)}

-

- {searchSources - ? "Try OR mode or broader tags." - : "Try OR mode, enable Sources, or check that these manga are in your library."} -

-
- )} - - )} -
-
- ); -} - -// ── Source tab ──────────────────────────────────────────────────────────────── - -function SourceTab({ - allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick, -}: { - allSources: Source[]; - loadingSources: boolean; - availableLangs: string[]; - hasMultipleLangs: boolean; - onMangaClick: (m: Manga) => void; -}) { - const [selectedLang, setSelectedLang] = useState("all"); - const [activeSource, setActiveSource] = useState(null); - const [browseResults, setBrowseResults] = useState([]); - const [loadingBrowse, setLoadingBrowse] = useState(false); - const [browseQuery, setBrowseQuery] = useState(""); - const [submitted, setSubmitted] = useState(""); - const [hasNextPage, setHasNextPage] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - const abortRef = useRef(null); - - useEffect(() => () => { abortRef.current?.abort(); }, []); - - const visibleSources = useMemo( - () => selectedLang === "all" ? allSources : allSources.filter((src) => src.lang === selectedLang), - [allSources, selectedLang], - ); - - async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - if (page === 1) { setLoadingBrowse(true); setBrowseResults([]); } - - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type, page, query: q ?? null }, - ctrl.signal, - ); - if (ctrl.signal.aborted) return; - setBrowseResults((prev) => page === 1 ? d.fetchSourceManga.mangas : [...prev, ...d.fetchSourceManga.mangas]); - setHasNextPage(d.fetchSourceManga.hasNextPage); - setCurrentPage(page); - } catch (e: any) { - if (e?.name !== "AbortError") console.error(e); - } finally { - if (!ctrl.signal.aborted) setLoadingBrowse(false); - } - } - - function selectSource(src: Source) { - setActiveSource(src); - setBrowseQuery(""); - setSubmitted(""); - fetchBrowse(src, "POPULAR"); - } - - function handleSearch() { - if (!activeSource || !browseQuery.trim()) return; - setSubmitted(browseQuery.trim()); - fetchBrowse(activeSource, "SEARCH", browseQuery.trim()); - } - - function clearSearch() { - setBrowseQuery(""); - setSubmitted(""); - if (activeSource) fetchBrowse(activeSource, "POPULAR"); - } - - return ( -
-
- {hasMultipleLangs && ( -
- {["all", ...availableLangs].map((lang) => ( - - ))} -
- )} - {loadingSources ? ( -
- -
- ) : ( -
- {visibleSources.map((src) => ( - - ))} - {visibleSources.length === 0 &&

No sources for this language

} -
- )} -
- -
- {!activeSource ? ( -
- -

Browse a source

-

Select a source to see its popular titles, or search within it.

-
- ) : ( - <> -
-
- { (e.target as HTMLImageElement).style.display = "none"; }} /> - {activeSource.displayName} - {loadingBrowse - ? - : browseResults.length > 0 && {browseResults.length} results - } -
-
- -
-
- - setBrowseQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> - {submitted && ( - - )} -
- -
- - {loadingBrowse && browseResults.length === 0 ? ( - - ) : browseResults.length > 0 ? ( - <> -
- {browseResults.map((m) => onMangaClick(m)} />)} -
- {hasNextPage && ( -
- -
- )} - - ) : !loadingBrowse ? ( -
-

{submitted ? `No results for "${submitted}"` : "No results"}

-
- ) : null} - - )} -
-
- ); -} \ No newline at end of file diff --git a/src/components/pages/SeriesDetail.module.css b/src/components/pages/SeriesDetail.module.css deleted file mode 100644 index e56639a..0000000 --- a/src/components/pages/SeriesDetail.module.css +++ /dev/null @@ -1,1100 +0,0 @@ -.root { - display: flex; - height: 100%; - overflow: hidden; - animation: fadeIn 0.14s ease both; -} - -/* ── Sidebar ── */ -.sidebar { - width: 200px; - flex-shrink: 0; - padding: var(--sp-5); - border-right: 1px solid var(--border-dim); - overflow-y: auto; - display: flex; - flex-direction: column; - gap: var(--sp-4); - background: var(--bg-base); -} - -.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); -} -.back:hover { color: var(--text-secondary); } - -.coverWrap { - width: 100%; - aspect-ratio: 2 / 3; - border-radius: var(--radius-md); - overflow: hidden; - background: var(--bg-raised); - border: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.cover { width: 100%; height: 100%; object-fit: cover; } - -.metaSkeleton { display: flex; flex-direction: column; gap: var(--sp-2); } -.skLine { border-radius: var(--radius-sm); } - -.meta { display: flex; flex-direction: column; gap: var(--sp-3); } - -.title { - font-size: var(--text-base); - font-weight: var(--weight-medium); - color: var(--text-primary); - line-height: var(--leading-snug); - letter-spacing: var(--tracking-tight); -} - -.byline { - font-size: var(--text-xs); - color: var(--text-muted); - font-family: var(--font-ui); -} - -.statusBadge { - display: inline-block; - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - padding: 2px 7px; - border-radius: var(--radius-sm); - width: fit-content; -} - -.statusOngoing { - background: var(--accent-muted); - color: var(--accent-fg); - border: 1px solid var(--accent-dim); -} - -.statusEnded { - background: var(--bg-raised); - color: var(--text-faint); - border: 1px solid var(--border-dim); -} - -.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); } - -.genre { - font-size: var(--text-2xs); - font-family: var(--font-ui); - color: var(--text-faint); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-sm); - padding: 1px 6px; - letter-spacing: var(--tracking-wide); -} - -.genreClickable { - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.genreClickable:hover { - color: var(--accent-fg); - border-color: var(--accent-dim); - background: var(--accent-muted); -} - -.sourceLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - text-transform: uppercase; -} - -.description { - font-size: var(--text-xs); - color: var(--text-muted); - line-height: var(--leading-base); - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.descriptionExpanded { - -webkit-line-clamp: unset; - display: block; - overflow: visible; -} - -.descriptionWrap { - display: flex; - flex-direction: column; - gap: 2px; -} - -.descToggle { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--accent-fg); - letter-spacing: var(--tracking-wide); - background: none; - border: none; - padding: 0; - cursor: pointer; - text-align: left; - opacity: 0.7; - transition: opacity var(--t-base); -} -.descToggle:hover { opacity: 1; } - -.genreToggle { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - border-radius: var(--radius-sm); - padding: 1px 6px; - letter-spacing: var(--tracking-wide); - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base); -} -.genreToggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); } - -/* ── Progress ── */ -.progressSection { - display: flex; - flex-direction: column; - gap: var(--sp-1); -} - -.progressHeader { - display: flex; - justify-content: space-between; - align-items: center; -} - -.progressLabel { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.progressPct { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--accent-fg); - letter-spacing: var(--tracking-wide); -} - -.progressTrack { - height: 3px; - background: var(--border-base); - border-radius: var(--radius-full); - overflow: hidden; -} - -.progressFill { - height: 100%; - background: var(--accent); - border-radius: var(--radius-full); - transition: width 0.4s ease; -} - -/* ── Actions ── */ -.actions { - display: flex; - align-items: center; - gap: var(--sp-2); -} - -.libraryBtn { - display: flex; - align-items: center; - gap: var(--sp-2); - font-size: var(--text-xs); - font-family: var(--font-ui); - letter-spacing: var(--tracking-wide); - padding: 5px 10px; - border-radius: var(--radius-md); - border: 1px solid var(--border-strong); - color: var(--text-muted); - background: var(--bg-raised); - transition: border-color var(--t-base), color var(--t-base), background var(--t-base); - flex: 1; -} -.libraryBtn:hover { border-color: var(--accent); color: var(--accent-fg); } -.libraryBtn:disabled { opacity: 0.4; cursor: default; } -.libraryBtnActive { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} - -.externalLink { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - color: var(--text-faint); - flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base); -} -.externalLink:hover { color: var(--text-muted); border-color: var(--border-strong); } - -/* ── Start/Continue reading button ── */ -.readBtn { - display: flex; - align-items: center; - justify-content: center; - gap: var(--sp-2); - width: 100%; - padding: 8px var(--sp-3); - border-radius: var(--radius-md); - background: var(--accent-dim); - border: 1px solid var(--accent); - color: var(--accent-fg); - font-size: var(--text-xs); - font-family: var(--font-ui); - letter-spacing: var(--tracking-wide); - cursor: pointer; - transition: background var(--t-base), border-color var(--t-base); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.readBtn:hover { background: var(--accent-muted); border-color: var(--accent-bright); } - -.chapterCount { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - text-transform: uppercase; - padding-top: var(--sp-2); -} - -/* ── Sidebar mark-all quick actions ── */ -.markAllRow { - display: flex; - gap: var(--sp-2); -} - -.markAllBtn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 5px var(--sp-2); - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - background: none; - color: var(--text-faint); - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.markAllBtn:hover:not(:disabled) { - color: var(--text-secondary); - border-color: var(--border-strong); - background: var(--bg-raised); -} -.markAllBtn:disabled { opacity: 0.3; cursor: default; } - -/* ── Chapter list ── */ -.listWrap { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.listHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-3) var(--sp-4); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.sortBtn { - display: flex; - align-items: center; - gap: var(--sp-2); - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-muted); - letter-spacing: var(--tracking-wide); - padding: 4px 8px; - border-radius: var(--radius-md); - transition: background var(--t-base), color var(--t-base); -} -.sortBtn:hover { background: var(--bg-raised); color: var(--text-secondary); } - -.pagination { - display: flex; - align-items: center; - gap: var(--sp-2); -} - -.paginationBottom { - display: flex; - align-items: center; - justify-content: center; - gap: var(--sp-3); - padding: var(--sp-3) var(--sp-4); - border-top: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.pageBtn { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-muted); - letter-spacing: var(--tracking-wide); - padding: 3px 8px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-dim); - background: none; - cursor: pointer; - transition: background var(--t-base), color var(--t-base), border-color var(--t-base); -} -.pageBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); } -.pageBtn:disabled { opacity: 0.3; cursor: default; } - -.pageNum { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - min-width: 40px; - text-align: center; -} - -.list { - flex: 1; - overflow-y: auto; - padding: var(--sp-2) var(--sp-4); - display: flex; - flex-direction: column; - gap: 1px; -} - -.rowSkeleton { - display: flex; - flex-direction: column; - gap: var(--sp-2); - padding: 12px var(--sp-3); - border-radius: var(--radius-md); - background: var(--bg-raised); - margin-bottom: 1px; -} - -.row { - display: flex; - justify-content: space-between; - align-items: center; - background: none; - border: none; - border-radius: var(--radius-md); - padding: 10px var(--sp-3); - cursor: pointer; - text-align: left; - width: 100%; - color: var(--text-primary); - transition: background var(--t-fast); -} -.row:hover { background: var(--bg-raised); } -.rowRead .chName { color: var(--text-faint); } - -.chLeft { - display: flex; - flex-direction: column; - gap: 3px; - overflow: hidden; - flex: 1; - min-width: 0; -} - -.chName { - font-size: var(--text-base); - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: color var(--t-fast); -} -.row:hover .chName { color: var(--text-primary); } - -.chMeta { display: flex; align-items: center; gap: var(--sp-3); } - -.chMetaItem { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.chRight { - display: flex; - align-items: center; - gap: var(--sp-2); - flex-shrink: 0; - margin-left: var(--sp-3); -} - -.bookmarkIcon { color: var(--accent); } -.readIcon { color: var(--text-faint); } -.downloadedIcon { color: var(--accent-fg); } -.enqueuingIcon { color: var(--text-faint); } - -.dlBtn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: var(--radius-sm); - color: var(--text-faint); - transition: color var(--t-base), background var(--t-base); -} -.dlBtn:hover { color: var(--text-muted); background: var(--bg-overlay); } -/* ── Download section ── */ -.downloadSection { - position: relative; margin-top: var(--sp-2); -} - -.downloadToggle { - display: flex; align-items: center; gap: var(--sp-2); - width: 100%; padding: 7px var(--sp-3); - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - background: none; color: var(--text-muted); - font-size: var(--text-sm); cursor: pointer; - transition: background var(--t-base), color var(--t-base), border-color var(--t-base); -} -.downloadToggle:hover { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); } - -.downloadMenu { - margin-top: var(--sp-1); - background: var(--bg-raised); border: 1px solid var(--border-base); - border-radius: var(--radius-lg); padding: var(--sp-1); - display: flex; flex-direction: column; gap: 1px; - box-shadow: 0 4px 16px rgba(0,0,0,0.4); - animation: fadeIn 0.1s ease both; -} - -.dlItem { - display: flex; flex-direction: column; align-items: flex-start; gap: 2px; - width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); - font-size: var(--text-sm); color: var(--text-secondary); - background: none; border: none; cursor: pointer; text-align: left; - transition: background var(--t-fast), color var(--t-fast); -} -.dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); } - -.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); } -/* ── Details section ── */ -.detailsSection { - margin-top: var(--sp-2); - border-top: 1px solid var(--border-dim); - padding-top: var(--sp-2); -} - -.detailsToggle { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 4px var(--sp-1); - border-radius: var(--radius-md); - background: none; - border: none; - color: var(--text-faint); - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - cursor: pointer; - transition: color var(--t-base), background var(--t-base); -} -.detailsToggle:hover { color: var(--text-muted); background: var(--bg-raised); } - -.caretClosed { transition: transform var(--t-base); } -.caretOpen { transform: rotate(180deg); transition: transform var(--t-base); } - -.detailsBody { - display: flex; - flex-direction: column; - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-1); -} - -.detailRow { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: var(--sp-2); -} - -.detailKey { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - flex-shrink: 0; -} - -.detailVal { - font-size: var(--text-xs); - color: var(--text-muted); - text-align: right; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.detailMono { - font-family: monospace; - font-size: var(--text-2xs); - color: var(--text-faint); -} - -.migrateBtn { - display: flex; - align-items: center; - gap: var(--sp-2); - width: 100%; - padding: 6px var(--sp-2); - margin-top: var(--sp-1); - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - background: none; - color: var(--text-muted); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.migrateBtn:hover { - color: var(--text-secondary); - border-color: var(--border-strong); - background: var(--bg-raised); -} -/* ── List header right controls ── */ -.listHeaderRight { - display: flex; align-items: center; gap: var(--sp-2); -} - -/* ── Download dropdown (in list header) ── */ -.dlWrap { position: relative; } - -.dlToggleBtn { - display: flex; align-items: center; justify-content: center; - width: 28px; height: 28px; border-radius: var(--radius-md); - border: 1px solid var(--border-dim); color: var(--text-muted); - background: none; cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.dlToggleBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } - -.dlDropdown { - position: absolute; top: calc(100% + 4px); right: 0; - background: var(--bg-raised); border: 1px solid var(--border-base); - border-radius: var(--radius-lg); padding: var(--sp-1); - display: flex; flex-direction: column; gap: 1px; - min-width: 180px; - box-shadow: 0 4px 16px rgba(0,0,0,0.4); - animation: scaleIn 0.1s ease both; transform-origin: top right; - z-index: 50; -} - -/* ── Jump to chapter (in list header) ── */ -.jumpWrap { position: relative; } - -.jumpToggle { - padding: 4px 8px; - border-radius: var(--radius-sm); border: 1px solid var(--border-dim); - background: none; color: var(--text-faint); - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - cursor: pointer; white-space: nowrap; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.jumpToggle:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); } - -.jumpRow { display: flex; align-items: center; gap: 4px; } - -.jumpInput { - width: 72px; padding: 4px 8px; - background: var(--bg-raised); border: 1px solid var(--border-focus); - border-radius: var(--radius-sm); color: var(--text-secondary); - font-family: var(--font-ui); font-size: var(--text-xs); - outline: none; -} - -.jumpCancel { - display: flex; align-items: center; justify-content: center; - width: 22px; height: 22px; border-radius: var(--radius-sm); - color: var(--text-faint); font-size: 10px; background: none; - transition: color var(--t-base), background var(--t-base); -} -.jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); } -/* ── View mode toggle ── */ -.viewToggleBtn { - display: flex; align-items: center; justify-content: center; - width: 26px; height: 26px; - border-radius: var(--radius-sm); - border: 1px solid transparent; - color: var(--text-faint); - background: none; - cursor: pointer; - transition: color var(--t-base), background var(--t-base), border-color var(--t-base); -} -.viewToggleBtn:hover { color: var(--text-muted); background: var(--bg-raised); border-color: var(--border-dim); } -.viewToggleActive { color: var(--accent-fg) !important; background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; } - -/* ── Chapter grid ── */ -.grid { - flex: 1; - overflow-y: auto; - padding: var(--sp-3) var(--sp-4); - display: grid; - grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); - gap: 5px; - align-content: start; -} - -.gridCell { - position: relative; - aspect-ratio: 1; - border-radius: var(--radius-sm); - border: 1px solid var(--border-strong); - background: var(--bg-raised); - cursor: pointer; - display: flex; align-items: center; justify-content: center; - overflow: hidden; - transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast); -} -.gridCell:hover { - border-color: var(--accent); - background: var(--bg-overlay); - transform: scale(1.04); - z-index: 1; -} - -/* Unread — subtle, inviting */ -.gridCellNum { - font-family: var(--font-ui); - font-size: var(--text-2xs); - font-weight: var(--weight-medium); - letter-spacing: var(--tracking-tight); - color: var(--text-secondary); - line-height: 1; - position: relative; - z-index: 1; -} - -/* Read — dimmed, clearly consumed */ -.gridCellRead { - background: var(--bg-base); - border-color: var(--border-dim); -} -.gridCellRead .gridCellNum { - color: var(--text-faint); -} -.gridCellRead::after { - content: ""; - position: absolute; inset: 0; - background: linear-gradient(135deg, transparent 60%, rgba(var(--accent-rgb, 100 130 255) / 0.08) 100%); - pointer-events: none; -} - -/* In-progress progress fill bar (width set inline) */ -.gridCellProgress { - position: absolute; - bottom: 0; - left: 0; - height: 2px; - background: var(--accent); - border-radius: 0 0 var(--radius-sm) var(--radius-sm); - pointer-events: none; - z-index: 2; -} - -/* In-progress — accent highlight on bottom edge */ -.gridCellInProgress { - border-color: var(--accent-dim); - background: var(--bg-raised); -} -.gridCellInProgress .gridCellNum { - color: var(--accent-fg); -} -.gridCellInProgress::before { - content: ""; - position: absolute; bottom: 0; left: 0; right: 0; - height: 3px; - background: var(--accent); - border-radius: 0 0 var(--radius-sm) var(--radius-sm); -} - -/* Read indicator dot (top-right corner) */ -.gridCellDot { - position: absolute; top: 3px; right: 3px; - width: 4px; height: 4px; - border-radius: 50%; - background: var(--text-faint); -} - -/* Bookmark indicator dot */ -.gridCellBookmarked { border-color: var(--accent-dim); } -.gridCellBookmarkDot { - position: absolute; top: 3px; left: 3px; - width: 4px; height: 4px; - border-radius: 50%; - background: var(--accent); -} - -/* Spinner overlay for enqueueing */ -.gridCellSpinner { - position: absolute; inset: 0; - display: flex; align-items: center; justify-content: center; - background: rgba(0,0,0,0.3); - color: var(--text-faint); -} - -/* Skeleton for grid loading state */ -.gridCellSkeleton { - aspect-ratio: 1; - border-radius: var(--radius-sm); - background: var(--bg-raised); - border: 1px solid var(--border-dim); - display: flex; align-items: center; justify-content: center; - padding: var(--sp-2); -} - -/* ── Folder picker (icon button in list header) ──────────────────────── */ -.folderPickerWrap { - position: relative; -} - -/* Matches dlToggleBtn / viewToggleBtn style */ -.folderPickerBtn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - color: var(--text-muted); - background: none; - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.folderPickerBtn:hover { - color: var(--text-secondary); - border-color: var(--border-strong); - background: var(--bg-raised); -} -/* Active state when manga is assigned to at least one folder */ -.folderPickerBtnActive { - color: var(--accent-fg); - border-color: var(--accent-dim); - background: var(--accent-muted); -} -.folderPickerBtnActive:hover { - background: var(--accent-muted); - border-color: var(--accent); - color: var(--accent-fg); -} - -.folderPickerMenu { - position: absolute; - top: calc(100% + 4px); - right: 0; - min-width: 180px; - background: var(--bg-raised); - border: 1px solid var(--border-base); - border-radius: var(--radius-md); - padding: var(--sp-1); - z-index: 200; - box-shadow: 0 8px 24px rgba(0,0,0,0.5); - animation: scaleIn 0.1s ease both; - transform-origin: top right; -} - -.folderPickerEmpty { - padding: var(--sp-2) var(--sp-3); - font-size: var(--text-xs); - color: var(--text-faint); -} - -.folderPickerItem { - display: flex; - align-items: center; - gap: var(--sp-2); - width: 100%; - padding: 6px var(--sp-3); - border-radius: var(--radius-sm); - font-size: var(--text-xs); - color: var(--text-secondary); - background: none; - border: none; - cursor: pointer; - text-align: left; - transition: background var(--t-fast), color var(--t-fast); -} -.folderPickerItem:hover { background: var(--bg-overlay); } -.folderPickerItemActive { color: var(--accent-fg); } - -.folderPickerItemCheck { - width: 12px; - font-size: var(--text-xs); - color: var(--accent-fg); - flex-shrink: 0; -} - -.folderPickerDivider { - height: 1px; - background: var(--border-dim); - margin: var(--sp-1) var(--sp-2); -} - -.folderPickerCreate { - display: flex; - align-items: center; - gap: var(--sp-1); - padding: 4px var(--sp-2); -} - -.folderPickerInput { - flex: 1; - background: var(--bg-overlay); - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - padding: 4px 8px; - font-size: var(--text-xs); - color: var(--text-secondary); - outline: none; - min-width: 0; -} -.folderPickerInput:focus { border-color: var(--border-focus); } - -.folderPickerConfirm { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - padding: 4px 8px; - border-radius: var(--radius-sm); - border: 1px solid var(--accent-dim); - background: var(--accent-muted); - color: var(--accent-fg); - cursor: pointer; - flex-shrink: 0; -} -.folderPickerConfirm:disabled { opacity: 0.4; cursor: default; } - -.folderPickerCancel { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: var(--radius-sm); - border: 1px solid transparent; - background: none; - color: var(--text-faint); - cursor: pointer; - flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base); -} -.folderPickerCancel:hover { color: var(--text-muted); border-color: var(--border-dim); } - -.folderPickerNewBtn { - width: 100%; - padding: 6px var(--sp-3); - border-radius: var(--radius-sm); - font-size: var(--text-xs); - color: var(--text-faint); - background: none; - border: none; - cursor: pointer; - text-align: left; - transition: color var(--t-fast), background var(--t-fast); -} -.folderPickerNewBtn:hover { color: var(--text-secondary); background: var(--bg-overlay); } - -/* ── Delete all downloads button (in details section) ─────────────────── */ -.deleteAllBtn { - display: flex; - align-items: center; - gap: var(--sp-2); - width: 100%; - padding: 6px var(--sp-2); - border-radius: var(--radius-md); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - color: var(--text-faint); - background: none; - border: 1px solid var(--border-dim); - cursor: pointer; - text-align: left; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.deleteAllBtn:hover:not(:disabled) { - color: var(--color-error); - border-color: color-mix(in srgb, var(--color-error) 40%, transparent); - background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); -} -.deleteAllBtn:disabled { opacity: 0.4; cursor: default; } - -/* ── Danger item in dl dropdown ─────────────────────────────────────── */ -.dlItemDanger { - color: var(--color-error) !important; -} -.dlItemDanger:hover:not(:disabled) { - background: var(--color-error-bg) !important; -} -/* ── Download dropdown extended: Next-N quick buttons + range picker ─────── */ -.dlSectionLabel { - padding: 6px var(--sp-3) 4px; - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -.dlNextRow { - display: flex; - gap: 4px; - padding: 2px var(--sp-2) var(--sp-2); -} - -/* Clean pill-style buttons — label + count inline, no column stacking */ -.dlNextBtn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 5px; - padding: 5px 6px; - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - background: var(--bg-overlay); - color: var(--text-secondary); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - cursor: pointer; - transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); - white-space: nowrap; -} -.dlNextBtn:hover:not(:disabled) { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} -.dlNextBtn:hover:not(:disabled) .dlNextSub { - color: var(--accent-fg); - opacity: 0.7; -} -.dlNextBtn:disabled { opacity: 0.3; cursor: default; } - -/* The "(n new)" count badge — sits inline as a dimmed suffix */ -.dlNextSub { - font-size: var(--text-2xs); - color: var(--text-faint); - font-variant-numeric: tabular-nums; - transition: color var(--t-fast), opacity var(--t-fast); -} - -.dlDivider { - height: 1px; - background: var(--border-dim); - margin: var(--sp-1) var(--sp-2); -} - -/* Range row: swaps in at the same height as dlItem — no layout shift */ -.dlRangeRow { - display: flex; - align-items: center; - gap: 4px; - padding: 7px var(--sp-2) 7px var(--sp-2); - border-radius: var(--radius-md); - min-height: 0; -} - -.dlRangeBack { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-dim); - background: none; - color: var(--text-faint); - font-size: 14px; - line-height: 1; - cursor: pointer; - transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); -} -.dlRangeBack:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); } - -.dlRangeInput { - flex: 1; - min-width: 0; - padding: 4px 8px; - background: var(--bg-overlay); - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-family: var(--font-ui); - font-size: var(--text-xs); - outline: none; - text-align: center; - transition: border-color var(--t-base); -} -.dlRangeInput:focus { border-color: var(--border-focus); } -.dlRangeInput::placeholder { color: var(--text-faint); } - -.dlRangeSep { - color: var(--text-faint); - font-size: var(--text-xs); - flex-shrink: 0; -} - -.dlRangeGo { - padding: 4px 10px; - border-radius: var(--radius-sm); - border: 1px solid var(--accent-dim); - background: var(--accent-muted); - color: var(--accent-fg); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - cursor: pointer; - flex-shrink: 0; - transition: background var(--t-base); - white-space: nowrap; -} -.dlRangeGo:hover:not(:disabled) { background: var(--accent-dim); } -.dlRangeGo:disabled { opacity: 0.3; cursor: default; } \ No newline at end of file diff --git a/src/components/pages/SeriesDetail.svelte b/src/components/pages/SeriesDetail.svelte new file mode 100644 index 0000000..eb84ccc --- /dev/null +++ b/src/components/pages/SeriesDetail.svelte @@ -0,0 +1 @@ +
SeriesDetail.svelte
diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx deleted file mode 100644 index 3619f67..0000000 --- a/src/components/pages/SeriesDetail.tsx +++ /dev/null @@ -1,1168 +0,0 @@ -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; -import { - ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, - ArrowSquareOut, CircleNotch, Play, - SortAscending, SortDescending, CaretDown, ArrowsClockwise, - List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple, -} from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { - GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, - UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, - ENQUEUE_CHAPTERS_DOWNLOAD, -} from "../../lib/queries"; -import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache"; -import { useStore } from "../../store"; -import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; -import MigrateModal from "./MigrateModal"; -import type { Manga, Chapter } from "../../lib/types"; -import s from "./SeriesDetail.module.css"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function formatDate(ts: string | null | undefined): string { - if (!ts) return ""; - const n = Number(ts); - const d = new Date(n > 1e10 ? n : n * 1000); - return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); -} - -interface CtxState { - x: number; - y: number; - chapter: Chapter; - indexInSorted: number; -} - -const CHAPTERS_PER_PAGE = 25; - -// How long before we consider a manga detail / chapter list stale and silently re-fetch. -// This prevents hammering the server when rapidly opening/closing while still keeping -// data fresh enough for normal use. -const MANGA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — detail rarely changes mid-session -const CHAPTER_CACHE_TTL_MS = 2 * 60 * 1000; // 2 min — chapters update more often - -// ── TTL-aware memory stores (cleared on page refresh, not persisted) ────────── -// These supplement the session `cache` with timestamp tracking so we know when -// to silently re-validate in the background. -const mangaDetailStore = new Map(); -const chapterStore = new Map(); - -// ── Download dropdown ───────────────────────────────────────────────────────── - -interface DownloadDropdownProps { - sortedChapters: Chapter[]; - continueChapter: { chapter: Chapter; type: string } | null; - downloadedCount: number; - deletingAll: boolean; - onEnqueue: (ids: number[]) => void; - onDelete: () => void; - onClose: () => void; -} - -function DownloadDropdown({ - sortedChapters, continueChapter, downloadedCount, deletingAll, - onEnqueue, onDelete, onClose, -}: DownloadDropdownProps) { - const [rangeFrom, setRangeFrom] = useState(""); - const [rangeTo, setRangeTo] = useState(""); - const [showRange, setShowRange] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - document.addEventListener("mousedown", handler, true); - return () => document.removeEventListener("mousedown", handler, true); - }, [onClose]); - - const continueIdx = continueChapter - ? sortedChapters.indexOf(continueChapter.chapter) - : -1; - - function enqueueNext(n: number) { - if (continueIdx < 0) return; - const ids = sortedChapters - .slice(continueIdx, continueIdx + n) - .filter((c) => !c.isDownloaded) - .map((c) => c.id); - onEnqueue(ids); - } - - function enqueueRange() { - const from = parseFloat(rangeFrom); - const to = parseFloat(rangeTo); - if (isNaN(from) || isNaN(to)) return; - const lo = Math.min(from, to), hi = Math.max(from, to); - const ids = sortedChapters - .filter((c) => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded) - .map((c) => c.id); - if (ids.length) onEnqueue(ids); - } - - const unreadNotDl = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded); - const allNotDl = sortedChapters.filter((c) => !c.isDownloaded); - - return ( -
- {continueChapter && continueIdx >= 0 && ( - <> -

From Ch.{continueChapter.chapter.chapterNumber}

-
- {[5, 10, 25].map((n) => { - const avail = sortedChapters - .slice(continueIdx, continueIdx + n) - .filter((c) => !c.isDownloaded).length; - return ( - - ); - })} -
-
- - )} - - {!showRange ? ( - - ) : ( -
- - setRangeFrom(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && enqueueRange()} - autoFocus - /> - - setRangeTo(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && enqueueRange()} - /> - -
- )} - -
- - - - - {downloadedCount > 0 && ( - <> -
- - - )} -
- ); -} - -// ── Folder picker ───────────────────────────────────────────────────────────── - -function FolderPicker({ mangaId }: { mangaId: number }) { - const [open, setOpen] = useState(false); - const [newName, setNewName] = useState(""); - const [creating, setCreating] = useState(false); - const ref = useRef(null); - - const folders = useStore((st) => st.settings.folders); - const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); - const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder); - const addFolder = useStore((st) => st.addFolder); - - const assigned = folders.filter((f) => f.mangaIds.includes(mangaId)); - const hasAssigned = assigned.length > 0; - - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - setCreating(false); - setNewName(""); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [open]); - - function handleCreate() { - const name = newName.trim(); - if (!name) return; - const id = addFolder(name); - assignMangaToFolder(id, mangaId); - setNewName(""); - setCreating(false); - } - - return ( -
- - - {open && ( -
- {folders.length === 0 && !creating && ( -

No folders yet

- )} - {folders.map((folder) => { - const isIn = folder.mangaIds.includes(mangaId); - return ( - - ); - })} -
- {creating ? ( -
- setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleCreate(); - if (e.key === "Escape") { setCreating(false); setNewName(""); } - }} - /> - - -
- ) : ( - - )} -
- )} -
- ); -} - -// ── Main component ──────────────────────────────────────────────────────────── - -export default function SeriesDetail() { - const activeManga = useStore((state) => state.activeManga); - const setActiveManga = useStore((state) => state.setActiveManga); - const openReader = useStore((state) => state.openReader); - const activeChapter = useStore((state) => state.activeChapter); - const settings = useStore((state) => state.settings); - const updateSettings = useStore((state) => state.updateSettings); - const addToast = useStore((state) => state.addToast); - const setGenreFilter = useStore((state) => state.setGenreFilter); - const setNavPage = useStore((state) => state.setNavPage); - - const [manga, setManga] = useState(null); - const [chapters, setChapters] = useState([]); - const [loadingManga, setLoadingManga] = useState(false); - const [loadingChapters, setLoadingChapters] = useState(true); - const [enqueueing, setEnqueueing] = useState>(new Set()); - const [dlOpen, setDlOpen] = useState(false); - const [detailsOpen, setDetailsOpen] = useState(false); - const [migrateOpen, setMigrateOpen] = useState(false); - const [togglingLibrary, setTogglingLibrary] = useState(false); - const [chapterPage, setChapterPage] = useState(1); - const [ctx, setCtx] = useState(null); - const [jumpOpen, setJumpOpen] = useState(false); - const [jumpInput, setJumpInput] = useState(""); - const [viewMode, setViewMode] = useState<"list" | "grid">("list"); - const [deletingAll, setDeletingAll] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const [descExpanded, setDescExpanded] = useState(false); - const [genresExpanded, setGenresExpanded] = useState(false); - - // Track the abort controllers for in-flight requests so we can cancel on unmount/change - // Manga detail and chapters each get their own controller so they don't clobber each other - const mangaAbortRef = useRef(null); - const chapterAbortRef = useRef(null); - // Track the manga ID we're currently loading to discard stale results - const loadingForRef = useRef(null); - - const sortDir = settings.chapterSortDir; - - // ── Manga detail: serve from TTL cache, silently re-validate if stale ────── - useEffect(() => { - if (!activeManga) return; - - const mangaId = activeManga.id; - - // Cancel any in-flight manga detail request from a previous manga - mangaAbortRef.current?.abort(); - const ctrl = new AbortController(); - mangaAbortRef.current = ctrl; - loadingForRef.current = mangaId; - - const cached = mangaDetailStore.get(mangaId); - const now = Date.now(); - - if (cached) { - // Serve from memory immediately — no loading state, no flash - setManga(cached.data); - setLoadingManga(false); - - // If cache is fresh enough, skip the network entirely - if (now - cached.fetchedAt < MANGA_CACHE_TTL_MS) return; - - // Stale: re-validate silently in the background (no spinner) - gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal) - .then((data) => { - if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; - mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() }); - setManga(data.manga); - if (data.manga.source?.id) recordSourceAccess(data.manga.source.id); - }) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }); - - return; - } - - // Nothing cached — show skeleton and fetch - setLoadingManga(true); - gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal) - .then((data) => { - if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; - mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() }); - setManga(data.manga); - if (data.manga.source?.id) recordSourceAccess(data.manga.source.id); - }) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) - .finally(() => { - if (!ctrl.signal.aborted && loadingForRef.current === mangaId) setLoadingManga(false); - }); - - return () => { ctrl.abort(); mangaAbortRef.current = null; }; - }, [activeManga?.id]); - - // ── Chapter loading: cache-first, background refresh only when stale ──────── - const applyChapters = useCallback((nodes: Chapter[]) => { - const sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); - setChapters(sorted); - return sorted; - }, []); - - useEffect(() => { - if (!activeManga) return; - - const mangaId = activeManga.id; - setChapterPage(1); - - // Cancel any previous in-flight chapter requests - chapterAbortRef.current?.abort(); - const ctrl = new AbortController(); - chapterAbortRef.current = ctrl; - loadingForRef.current = mangaId; - - const cached = chapterStore.get(mangaId); - const now = Date.now(); - - if (cached) { - // Show cached data instantly - applyChapters(cached.data); - setLoadingChapters(false); - - // Fresh enough — don't touch the network at all - if (now - cached.fetchedAt < CHAPTER_CACHE_TTL_MS) return; - - // Stale — silently re-validate: fetch from source then re-read local DB - // We don't clear the chapter list while this happens (no flicker) - gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal) - .then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal)) - .then((data) => { - if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; - chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() }); - applyChapters(data.chapters.nodes); - }) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }); - - return; - } - - // Nothing cached — show skeleton, load local DB first (fast), then source - setChapters([]); - setLoadingChapters(true); - - gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal) - .then((data) => { - if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; - // Show local DB result immediately so the user isn't staring at a spinner - applyChapters(data.chapters.nodes); - setLoadingChapters(false); - - // Now silently fetch from the source to pick up any new chapters - return gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal) - .then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal)) - .then((fresh) => { - if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; - chapterStore.set(mangaId, { data: fresh.chapters.nodes, fetchedAt: Date.now() }); - applyChapters(fresh.chapters.nodes); - }); - }) - .catch((e) => { - if (ctrl.signal.aborted || e?.name === "AbortError") return; - console.error(e); - setLoadingChapters(false); - }); - - return () => { ctrl.abort(); chapterAbortRef.current = null; }; - }, [activeManga?.id, applyChapters]); - - // ── Derived state ────────────────────────────────────────────────────────── - - const sortedChapters = useMemo(() => - sortDir === "desc" ? [...chapters].reverse() : [...chapters], - [chapters, sortDir] - ); - - const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE); - const pageChapters = sortedChapters.slice( - (chapterPage - 1) * CHAPTERS_PER_PAGE, - chapterPage * CHAPTERS_PER_PAGE - ); - const readCount = chapters.filter((c) => c.isRead).length; - const totalCount = chapters.length; - const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0; - const downloadedCount = chapters.filter((c) => c.isDownloaded).length; - - const continueChapter = useMemo(() => { - if (!chapters.length) return null; - const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); - const anyRead = asc.some((c) => c.isRead); - const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); - if (inProgress) return { chapter: inProgress, type: "continue" as const }; - const firstUnread = asc.find((c) => !c.isRead); - if (firstUnread) return { chapter: firstUnread, type: anyRead ? "continue" : "start" as const }; - return { chapter: asc[0], type: "reread" as const }; - }, [chapters]); - - // ── Actions ──────────────────────────────────────────────────────────────── - - async function toggleLibrary() { - if (!manga) return; - setTogglingLibrary(true); - const next = !manga.inLibrary; - await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error); - const updated = { ...manga, inLibrary: next }; - setManga(updated); - // Update the detail cache so re-open reflects the new state - if (mangaDetailStore.has(manga.id)) { - const entry = mangaDetailStore.get(manga.id)!; - mangaDetailStore.set(manga.id, { ...entry, data: updated }); - } - cache.clear(CACHE_KEYS.LIBRARY); - setTogglingLibrary(false); - } - - const reloadChapters = useCallback((mangaId: number, signal?: AbortSignal) => { - return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, signal) - .then((data) => { - chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() }); - applyChapters(data.chapters.nodes); - }); - }, [applyChapters]); - - // Reload chapters whenever the reader is closed so read/unread state is always current. - useEffect(() => { - if (activeChapter || !activeManga) return; - reloadChapters(activeManga.id); - cache.clear(CACHE_KEYS.LIBRARY); - }, [activeChapter, activeManga, reloadChapters]); - - async function enqueue(chapter: Chapter, e: React.MouseEvent) { - e.stopPropagation(); - setEnqueueing((prev) => new Set(prev).add(chapter.id)); - await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error); - addToast({ kind: "download", title: "Download queued", body: chapter.name }); - setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; }); - if (activeManga) reloadChapters(activeManga.id); - } - - async function enqueueMultiple(chapterIds: number[]) { - if (!chapterIds.length) return; - await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error); - addToast({ - kind: "download", - title: "Download queued", - body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added to queue`, - }); - if (activeManga) reloadChapters(activeManga.id); - } - - async function markRead(chapterId: number, isRead: boolean) { - await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); - setChapters((prev) => { - const updated = prev.map((c) => c.id === chapterId ? { ...c, isRead } : c); - if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); - return updated; - }); - } - - async function markBulk(ids: number[], isRead: boolean) { - if (!ids.length) return; - await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error); - setChapters((prev) => { - const idSet = new Set(ids); - const updated = prev.map((c) => idSet.has(c.id) ? { ...c, isRead } : c); - if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); - return updated; - }); - } - - const markAllAboveRead = (i: number) => - markBulk(sortedChapters.slice(0, i + 1).filter((c) => !c.isRead).map((c) => c.id), true); - const markAllBelowRead = (i: number) => - markBulk(sortedChapters.slice(i).filter((c) => !c.isRead).map((c) => c.id), true); - const markAllAboveUnread = (i: number) => - markBulk(sortedChapters.slice(0, i + 1).filter((c) => c.isRead).map((c) => c.id), false); - const markAllBelowUnread = (i: number) => - markBulk(sortedChapters.slice(i).filter((c) => c.isRead).map((c) => c.id), false); - - async function deleteDownloaded(chapterId: number) { - await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error); - setChapters((prev) => { - const updated = prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c); - if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); - return updated; - }); - } - - async function deleteAllDownloads() { - const ids = chapters.filter((c) => c.isDownloaded).map((c) => c.id); - if (!ids.length) return; - setDeletingAll(true); - await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error); - setChapters((prev) => { - const updated = prev.map((c) => ({ ...c, isDownloaded: false })); - if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); - return updated; - }); - setDeletingAll(false); - } - - async function refreshChapters() { - if (!activeManga || refreshing) return; - setRefreshing(true); - // Force-invalidate the chapter cache for this manga so we get a fresh fetch - chapterStore.delete(activeManga.id); - await gql(FETCH_CHAPTERS, { mangaId: activeManga.id }) - .then(() => reloadChapters(activeManga.id)) - .then(() => addToast({ kind: "success", title: "Chapters refreshed" })) - .catch((e) => addToast({ kind: "error", title: "Refresh failed", body: e?.message ?? String(e) })) - .finally(() => setRefreshing(false)); - } - - function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) { - e.preventDefault(); - setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted }); - } - - function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] { - const aboveItems = sortedChapters.slice(0, indexInSorted + 1); - const belowItems = sortedChapters.slice(indexInSorted); - const unreadAbove = aboveItems.filter((c) => !c.isRead).length; - const unreadBelow = belowItems.filter((c) => !c.isRead).length; - const readAbove = aboveItems.filter((c) => c.isRead).length; - const readBelow = belowItems.filter((c) => c.isRead).length; - const lastIdx = sortedChapters.length - 1; - - return [ - { - label: ch.isRead ? "Mark as unread" : "Mark as read", - icon: ch.isRead ? : , - onClick: () => markRead(ch.id, !ch.isRead), - }, - { separator: true }, - { - label: "Mark above as read", - icon: , - onClick: () => markAllAboveRead(indexInSorted), - disabled: indexInSorted === 0 || unreadAbove === 0, - }, - { - label: "Mark above as unread", - icon: , - onClick: () => markAllAboveUnread(indexInSorted), - disabled: indexInSorted === 0 || readAbove === 0, - }, - { separator: true }, - { - label: "Mark below as read", - icon: , - onClick: () => markAllBelowRead(indexInSorted), - disabled: indexInSorted === lastIdx || unreadBelow === 0, - }, - { - label: "Mark below as unread", - icon: , - onClick: () => markAllBelowUnread(indexInSorted), - disabled: indexInSorted === lastIdx || readBelow === 0, - }, - { separator: true }, - { - label: ch.isDownloaded ? "Delete download" : "Download", - icon: ch.isDownloaded ? : , - onClick: () => ch.isDownloaded - ? deleteDownloaded(ch.id) - : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error), - danger: ch.isDownloaded, - }, - { separator: true }, - { - label: "Download next 5 from here", - icon: , - onClick: () => { - const ids = sortedChapters - .slice(indexInSorted, indexInSorted + 5) - .filter((c) => !c.isDownloaded) - .map((c) => c.id); - enqueueMultiple(ids); - }, - }, - { - label: "Download all from here", - icon: , - onClick: () => { - const ids = sortedChapters - .slice(indexInSorted) - .filter((c) => !c.isDownloaded) - .map((c) => c.id); - enqueueMultiple(ids); - }, - }, - ]; - } - - // ── Early exit ───────────────────────────────────────────────────────────── - - if (!activeManga) return null; - - const statusLabel = manga?.status - ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() - : null; - - // ── Render ───────────────────────────────────────────────────────────────── - - return ( -
e.preventDefault()}> - - {/* ── Sidebar ── */} -
- - -
- {activeManga.title} -
- - {loadingManga ? ( -
-
-
-
- ) : ( -
-

{manga?.title}

- - {(manga?.author || manga?.artist) && ( -

- {[manga.author, manga.artist] - .filter(Boolean) - .filter((v, i, a) => a.indexOf(v) === i) - .join(" · ")} -

- )} - - {statusLabel && ( - - {statusLabel} - - )} - - {manga?.genre && manga.genre.length > 0 && ( -
- {(genresExpanded ? manga.genre : manga.genre.slice(0, 5)).map((g) => ( - - ))} - {manga.genre.length > 5 && ( - - )} -
- )} - - {manga?.description && ( -
-

- {manga.description} -

- {manga.description.length > 120 && ( - - )} -
- )} -
- )} - - {/* Progress */} - {totalCount > 0 && ( -
-
- {readCount} / {totalCount} read - {Math.round(progressPct)}% -
-
-
-
-
- )} - -
- - - {manga?.realUrl && ( - - - - )} -
- - {continueChapter && ( - - )} - -

- {totalCount} {totalCount === 1 ? "chapter" : "chapters"} - {readCount > 0 && ` · ${readCount} read`} -

- - {/* Source info — collapsible details */} - {!loadingManga && manga?.source && ( -
- - {detailsOpen && ( -
-
- Source - {manga.source.displayName} -
- {manga.status && ( -
- Status - - {manga.status.charAt(0) + manga.status.slice(1).toLowerCase()} - -
- )} - {manga.author && ( -
- Author - {manga.author} -
- )} - {manga.artist && manga.artist !== manga.author && ( -
- Artist - {manga.artist} -
- )} - {totalCount > 0 && ( -
- Progress - {readCount} / {totalCount} read -
- )} - - {downloadedCount > 0 && ( - - )} -
- )} -
- )} - - {manga && !manga.source && ( - - )} -
- - {/* ── Chapter list ── */} -
-
-
- - - -
- -
- - {activeManga && } - - {/* Jump to chapter */} - {chapters.length > 1 && ( -
- {!jumpOpen ? ( - - ) : ( -
- setJumpInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { setJumpOpen(false); return; } - if (e.key === "Enter") { - const num = parseFloat(jumpInput); - if (!isNaN(num)) { - const target = sortedChapters.find((c) => c.chapterNumber === num) - ?? sortedChapters.reduce((best, c) => - Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best - , sortedChapters[0]); - if (target) openReader(target, sortedChapters); - } - setJumpOpen(false); - } - }} - /> - -
- )} -
- )} - - {/* Download menu */} - {chapters.length > 0 && ( -
- - {dlOpen && ( - { enqueueMultiple(ids); setDlOpen(false); }} - onDelete={() => { deleteAllDownloads(); setDlOpen(false); }} - onClose={() => setDlOpen(false)} - /> - )} -
- )} - - {totalPages > 1 && ( -
- - {chapterPage} / {totalPages} - -
- )} -
-
- -
- {loadingChapters && chapters.length === 0 ? ( - viewMode === "grid" ? ( - Array.from({ length: 24 }).map((_, i) => ( -
-
-
- )) - ) : ( - Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
- )) - ) - ) : viewMode === "grid" ? ( - sortedChapters.map((ch, idxInSorted) => { - const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0; - return ( - - ); - }) - ) : ( - pageChapters.map((ch) => { - const idxInSorted = sortedChapters.indexOf(ch); - return ( -
openReader(ch, sortedChapters)} - onKeyDown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)} - onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)} - > -
- {ch.name} -
- {ch.scanlator && {ch.scanlator}} - {ch.uploadDate && {formatDate(ch.uploadDate)}} - {ch.lastPageRead != null && ch.lastPageRead > 0 && !ch.isRead && ( - p.{ch.lastPageRead} - )} -
-
- -
- {ch.isBookmarked && ( - - )} - {ch.isRead && ( - - )} - {ch.isDownloaded ? ( - - ) : enqueueing.has(ch.id) ? ( - - ) : ( - - )} -
-
- ); - }) - )} -
- - {totalPages > 1 && ( -
- - {chapterPage} / {totalPages} - -
- )} -
- - {ctx && ( - setCtx(null)} - /> - )} - - {migrateOpen && manga && ( - setMigrateOpen(false)} - onMigrated={(newManga: Manga) => { - setMigrateOpen(false); - setActiveManga(newManga); - }} - /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/reader/Reader.svelte b/src/components/reader/Reader.svelte new file mode 100644 index 0000000..14b6a6c --- /dev/null +++ b/src/components/reader/Reader.svelte @@ -0,0 +1 @@ +
Reader.svelte
diff --git a/src/components/search/Search.svelte b/src/components/search/Search.svelte new file mode 100644 index 0000000..ea3be90 --- /dev/null +++ b/src/components/search/Search.svelte @@ -0,0 +1 @@ +
Search.svelte
diff --git a/src/components/settings/Settings.module.css b/src/components/settings/Settings.module.css deleted file mode 100644 index 7e1a601..0000000 --- a/src/components/settings/Settings.module.css +++ /dev/null @@ -1,561 +0,0 @@ -/* ─── Backdrop ── */ -.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; - animation: fadeIn 0.12s ease both; - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); -} - -/* ─── Modal shell ── */ -.modal { - width: min(720px, calc(100vw - 48px)); - height: min(520px, calc(100vh - 80px)); - display: flex; - background: var(--bg-surface); - border: 1px solid var(--border-base); - border-radius: var(--radius-xl); - overflow: hidden; - animation: scaleIn 0.16s ease both; - box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4); -} - -/* ─── Sidebar ── */ -.sidebar { - width: 152px; - 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-3); - gap: var(--sp-1); -} - -.modalTitle { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - padding: 0 var(--sp-2) var(--sp-3); -} - -.nav { display: flex; flex-direction: column; gap: 1px; } - -.navItem { - display: flex; - align-items: center; - gap: var(--sp-2); - padding: 7px var(--sp-2); - border-radius: var(--radius-md); - font-size: var(--text-sm); - color: var(--text-muted); - background: none; - border: none; - cursor: pointer; - text-align: left; - width: 100%; - transition: background var(--t-fast), color var(--t-fast); -} -.navItem:hover { background: var(--bg-overlay); color: var(--text-secondary); } -.navActive { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); } -.navActive:hover { background: var(--accent-muted); color: var(--accent-fg); } - -/* ─── Content ── */ -.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; } - -.contentHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-5) var(--sp-6) var(--sp-4); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; -} - -.contentTitle { - font-size: var(--text-md); - font-weight: var(--weight-medium); - color: var(--text-secondary); - letter-spacing: var(--tracking-tight); -} - -.closeBtn { - display: flex; align-items: center; justify-content: center; - width: 26px; height: 26px; border-radius: var(--radius-sm); - color: var(--text-faint); - transition: color var(--t-base), background var(--t-base); -} -.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } - -.contentBody { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); } - -/* ─── Panel / Section ── */ -.panel { display: flex; flex-direction: column; gap: var(--sp-6); } -.section { display: flex; flex-direction: column; gap: 1px; } - -.sectionTitle { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - margin-bottom: var(--sp-2); -} - -/* ─── Toggle ── */ -.toggleRow { - display: flex; align-items: center; justify-content: space-between; - gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md); - cursor: pointer; transition: background var(--t-fast); -} -.toggleRow:hover { background: var(--bg-raised); } - -.toggleInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } -.toggleLabel { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-tight); } -.toggleDesc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-snug); } - -.toggle { - position: relative; width: 34px; height: 18px; border-radius: var(--radius-full); - background: var(--bg-subtle); border: 1px solid var(--border-strong); flex-shrink: 0; - cursor: pointer; transition: background var(--t-base), border-color var(--t-base); -} -.toggleOn { background: var(--accent-dim); border-color: var(--accent); } -.toggleThumb { - position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; - border-radius: 50%; background: var(--text-faint); - transition: transform var(--t-base), background var(--t-base); -} -.toggleOn .toggleThumb { transform: translateX(16px); background: var(--accent-fg); } - -/* ─── Stepper ── */ -.stepRow { - display: flex; align-items: center; justify-content: space-between; - gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md); - transition: background var(--t-fast); -} -.stepRow:hover { background: var(--bg-raised); } - -.stepControls { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } - -.stepBtn { - display: flex; align-items: center; justify-content: center; - width: 24px; height: 24px; border-radius: var(--radius-sm); - border: 1px solid var(--border-strong); font-size: var(--text-base); - color: var(--text-muted); transition: background var(--t-base), color var(--t-base); - line-height: 1; -} -.stepBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); } -.stepBtn:disabled { opacity: 0.25; cursor: default; } - -.stepVal { - font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary); - min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide); -} - -/* ─── Select (custom) ── */ -.selectWrap { position: relative; flex-shrink: 0; min-width: 130px; } - -.selectBtn { - display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); - width: 100%; padding: 5px 10px; - background: var(--bg-raised); border: 1px solid var(--border-strong); - border-radius: var(--radius-md); color: var(--text-secondary); - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - cursor: pointer; transition: border-color var(--t-base), background var(--t-base); - text-align: left; -} -.selectBtn:hover { border-color: var(--border-focus); } - -.selectCaret { - color: var(--text-faint); flex-shrink: 0; - transition: transform var(--t-base); -} -.selectCaretOpen { transform: rotate(180deg); } - -.selectMenu { - position: absolute; top: 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: 200; animation: scaleIn 0.1s ease both; transform-origin: top center; -} - -.selectOption { - padding: 6px 10px; border-radius: var(--radius-sm); - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - color: var(--text-secondary); background: none; border: none; - cursor: pointer; text-align: left; - transition: background var(--t-fast), color var(--t-fast); -} -.selectOption:hover { background: var(--bg-overlay); color: var(--text-primary); } -.selectOptionActive { color: var(--accent-fg); background: var(--accent-muted); } -.selectOptionActive:hover { background: var(--accent-muted); color: var(--accent-fg); } - -/* ─── Scale ── */ -.scaleRow { - display: flex; align-items: center; gap: var(--sp-3); - padding: 10px var(--sp-3); border-radius: var(--radius-md); -} -.scaleSlider { flex: 1; } -.scaleVal { - font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary); - min-width: 36px; text-align: right; letter-spacing: var(--tracking-wide); -} -.scaleHint { - display: flex; flex-wrap: wrap; gap: var(--sp-1); - padding: 0 var(--sp-3) var(--sp-2); -} -.scalePreset { - 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: none; color: var(--text-faint); cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.scalePreset:hover { color: var(--text-muted); border-color: var(--border-strong); } -.scalePresetActive { - background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); -} - -/* ─── Text input ── */ -.textInput { - background: var(--bg-raised); border: 1px solid var(--border-strong); - border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-secondary); - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - outline: none; flex-shrink: 0; width: 180px; - transition: border-color var(--t-base); -} -.textInput:focus { border-color: var(--border-focus); } - -/* ─── Keybinds ── */ -.kbHeader { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); } -.kbHint { font-size: var(--text-xs); color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-3); } -.resetAllBtn { - font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); - letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); - border: 1px solid var(--border-dim); background: none; cursor: pointer; - transition: color var(--t-base), border-color var(--t-base); -} -.resetAllBtn:hover { color: var(--color-error); border-color: var(--color-error); } - -.kbList { display: flex; flex-direction: column; gap: 1px; } - -.kbRow { - display: flex; align-items: center; justify-content: space-between; - gap: var(--sp-4); padding: 8px var(--sp-3); border-radius: var(--radius-md); - transition: background var(--t-fast); -} -.kbRow:hover { background: var(--bg-raised); } - -.kbLabel { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; } -.kbRight { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } - -.kbBind { - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - padding: 4px 12px; border-radius: var(--radius-sm); - border: 1px solid var(--border-strong); background: var(--bg-overlay); - color: var(--text-secondary); cursor: pointer; min-width: 100px; text-align: center; - transition: border-color var(--t-base), background var(--t-base), color var(--t-base); -} -.kbBind:hover { border-color: var(--accent); color: var(--accent-fg); } -.kbBindListening { - border-color: var(--accent); background: var(--accent-muted); color: var(--accent-fg); - animation: pulse 1s ease infinite; -} - -.kbReset { - font-size: var(--text-base); color: var(--text-faint); width: 22px; height: 22px; - border-radius: var(--radius-sm); border: 1px solid transparent; background: none; - cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: color var(--t-base), border-color var(--t-base); -} -.kbReset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); } -.kbReset:disabled { opacity: 0.2; cursor: default; } - -/* ─── Storage ── */ -.storageLoading { - font-size: var(--text-sm); color: var(--text-faint); - padding: var(--sp-3) var(--sp-3); -} - -.storageBarWrap { padding: var(--sp-2) var(--sp-3) var(--sp-1); } - -.storageBar { - width: 100%; height: 7px; - background: var(--bg-overlay); border-radius: var(--radius-full); - overflow: hidden; -} - -.storageBarFill { - height: 100%; border-radius: var(--radius-full); - background: var(--accent); - transition: width 0.4s ease; -} -.storageBarWarn { background: #d97706; } -.storageBarCritical { background: var(--color-error); } - -.storageBarLabels { - display: flex; justify-content: space-between; - margin-top: var(--sp-2); - font-family: var(--font-ui); font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); -} -.storageBarUsed { color: var(--text-secondary); } -.storageBarFree { color: var(--text-faint); } - -.storageBarNote { - font-size: var(--text-xs); color: var(--text-faint); - margin-top: var(--sp-1); -} - -.storageLegend { - display: flex; flex-direction: column; gap: 1px; - padding: var(--sp-2) var(--sp-3); -} - -.storageLegendRow { - display: flex; align-items: center; gap: var(--sp-2); - padding: 6px 0; - font-size: var(--text-sm); -} - -.storageDot { - width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; -} -.storageDotManga { background: var(--accent); } -.storageDotApp { background: var(--border-strong); } -.storageDotFree { background: var(--bg-overlay); border: 1px solid var(--border-strong); } - -.storageLegendLabel { flex: 1; color: var(--text-muted); } -.storageLegendVal { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); } - -.storageLimitHint { - font-size: var(--text-xs); color: #d97706; - padding: 0 var(--sp-3) var(--sp-2); -} - -.setLimitBtn { - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - padding: 5px 12px; border-radius: var(--radius-md); - background: none; border: 1px solid var(--border-strong); - color: var(--text-muted); cursor: pointer; flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base); -} -.setLimitBtn:hover { color: var(--text-primary); border-color: var(--border-focus); } - -.storagePathNote { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-faint); letter-spacing: var(--tracking-wide); - padding: var(--sp-1) var(--sp-3) var(--sp-2); - word-break: break-all; -} - -/* ─── About ── */ -.aboutBlock { - padding: var(--sp-3); background: var(--bg-raised); border-radius: var(--radius-md); - border: 1px solid var(--border-dim); -} -.aboutLine { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-base); } -.dangerBtn { - font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); - padding: 5px 12px; border-radius: var(--radius-md); - background: none; border: 1px solid var(--color-error); - color: var(--color-error); cursor: pointer; flex-shrink: 0; - transition: background var(--t-base); -} -.dangerBtn:hover:not(:disabled) { background: var(--color-error-bg); } -.dangerBtn:disabled { opacity: 0.3; cursor: default; } - -/* ── Folder management (Settings FoldersTab) ────────────────────────── */ -.folderCreateRow { - display: flex; - align-items: center; - gap: var(--sp-2); - padding: var(--sp-1) var(--sp-3) var(--sp-3); -} - -.folderCreateBtn { - display: flex; - align-items: center; - gap: var(--sp-1); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - padding: 5px 12px; - border-radius: var(--radius-md); - border: 1px solid var(--border-strong); - background: none; - color: var(--text-muted); - cursor: pointer; - flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base); -} -.folderCreateBtn:hover:not(:disabled) { - color: var(--accent-fg); - border-color: var(--accent); -} -.folderCreateBtn:disabled { opacity: 0.3; cursor: default; } - -.folderList { - display: flex; - flex-direction: column; - gap: 1px; -} - -.folderRow { - display: flex; - align-items: center; - gap: var(--sp-2); - padding: 9px var(--sp-3); - border-radius: var(--radius-md); - transition: background var(--t-fast); -} -.folderRow:hover { background: var(--bg-raised); } - -.folderRowName { - flex: 1; - font-size: var(--text-sm); - color: var(--text-secondary); -} - -.folderRowCount { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - margin-right: var(--sp-1); -} - -.folderTabToggle { - 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: none; - color: var(--text-faint); - cursor: pointer; - flex-shrink: 0; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} -.folderTabToggle:hover { - color: var(--text-muted); - border-color: var(--border-strong); -} -.folderTabToggleOn { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} -.folderTabToggleOn:hover { - background: var(--accent-muted); - color: var(--accent-fg); -} - -/* ─── Theme picker ── */ -.themeGrid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--sp-2); - padding: var(--sp-2) var(--sp-3); -} - -.themeCard { - position: relative; - display: flex; - flex-direction: column; - gap: var(--sp-2); - padding: var(--sp-2); - border-radius: var(--radius-md); - border: 1px solid var(--border-dim); - background: var(--bg-raised); - cursor: pointer; - text-align: left; - transition: border-color var(--t-base), background var(--t-base); -} -.themeCard:hover { border-color: var(--border-strong); background: var(--bg-overlay); } -.themeCardActive { - border-color: var(--accent); - background: var(--accent-muted); -} -.themeCardActive:hover { border-color: var(--accent); } - -.themePreview { - width: 100%; - aspect-ratio: 16 / 9; - border-radius: var(--radius-sm); - overflow: hidden; - border: 1px solid rgba(0,0,0,0.15); - flex-shrink: 0; -} - -.themePreviewBg { - width: 100%; height: 100%; - display: flex; -} - -.themePreviewSidebar { - width: 22%; - height: 100%; - flex-shrink: 0; - opacity: 0.9; -} - -.themePreviewContent { - flex: 1; - padding: 10% 12%; - display: flex; - flex-direction: column; - gap: 8%; - justify-content: center; -} - -.themePreviewAccent { - height: 14%; - border-radius: 2px; - width: 55%; -} - -.themePreviewText { - height: 9%; - border-radius: 2px; - width: 100%; -} - -.themeCardInfo { - display: flex; - flex-direction: column; - gap: 1px; -} - -.themeCardLabel { - font-size: var(--text-xs); - font-weight: var(--weight-medium); - color: var(--text-secondary); - line-height: var(--leading-tight); -} - -.themeCardDesc { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - line-height: var(--leading-snug); -} - -.themeCardCheck { - position: absolute; - top: var(--sp-1); - right: var(--sp-2); - font-size: var(--text-xs); - color: var(--accent-fg); - font-family: var(--font-ui); -} \ No newline at end of file diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte new file mode 100644 index 0000000..6947208 --- /dev/null +++ b/src/components/settings/Settings.svelte @@ -0,0 +1,3 @@ + +
Settings stub
diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx deleted file mode 100644 index d5b8376..0000000 --- a/src/components/settings/Settings.tsx +++ /dev/null @@ -1,954 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from "react"; -import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "@phosphor-icons/react"; -import { invoke } from "@tauri-apps/api/core"; -import { gql } from "../../lib/client"; -import { GET_DOWNLOADS_PATH } from "../../lib/queries"; -import { useStore } from "../../store"; -import type { Folder } from "../../store"; -import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds"; -import type { Settings, FitMode, Theme } from "../../store"; -import s from "./Settings.module.css"; - -type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools"; - -const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ - { id: "general", label: "General", icon: }, - { id: "appearance", label: "Appearance", icon: }, - { id: "reader", label: "Reader", icon: }, - { id: "library", label: "Library", icon: }, - { id: "performance",label: "Performance",icon: }, - { id: "keybinds", label: "Keybinds", icon: }, - { id: "storage", label: "Storage", icon: }, - { id: "folders", label: "Folders", icon: }, - { id: "about", label: "About", icon: }, - { id: "devtools", label: "Dev Tools", icon: }, -]; - -// ── Primitives ──────────────────────────────────────────────────────────────── - -function Toggle({ checked, onChange, label, description }: { - checked: boolean; onChange: (v: boolean) => void; label: string; description?: string; -}) { - return ( - - ); -} - -function Stepper({ value, onChange, min, max, step = 1, label, description }: { - value: number; onChange: (v: number) => void; - min: number; max: number; step?: number; label: string; description?: string; -}) { - return ( -
-
- {label} - {description && {description}} -
-
- - {value} - -
-
- ); -} - -function SelectRow({ value, options, onChange, label, description }: { - value: string; - options: { value: string; label: string }[]; - onChange: (v: string) => void; - label: string; - description?: string; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const selected = options.find((o) => o.value === value); - - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; - document.addEventListener("mousedown", handler); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", handler); - document.removeEventListener("keydown", onKey); - }; - }, [open]); - - return ( -
-
- {label} - {description && {description}} -
-
- - {open && ( -
- {options.map((o) => ( - - ))} -
- )} -
-
- ); -} - -function TextRow({ value, onChange, label, description, placeholder }: { - value: string; onChange: (v: string) => void; - label: string; description?: string; placeholder?: string; -}) { - return ( -
-
- {label} - {description && {description}} -
- onChange(e.target.value)} - placeholder={placeholder} spellCheck={false} /> -
- ); -} - - -// ── Tabs ────────────────────────────────────────────────────────────────────── - -function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { - return ( -
-
-

Interface Scale

-
- update({ uiScale: Number(e.target.value) })} - className={s.scaleSlider} /> - {settings.uiScale}% - -
-

- {[70, 80, 90, 100, 110, 125, 150].map((v) => ( - - ))} -

-
-
-

Server

- update({ serverUrl: v })} - placeholder="http://localhost:4567" /> - update({ serverBinary: v })} - placeholder="tachidesk-server" /> - update({ autoStartServer: v })} /> -
-
-

Inactivity

- update({ idleTimeoutMin: Number(v) })} - /> -
-
- ); -} - -function ReaderTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { - return ( -
-
-

Page Layout

- update({ pageStyle: v as Settings["pageStyle"] })} /> - update({ readingDirection: v as Settings["readingDirection"] })} /> - update({ pageGap: v })} /> -
- -
-

Fit & Zoom

- update({ fitMode: v as FitMode })} /> -
-
- Max page width - Pixel cap for fit-width mode. Ctrl+scroll in reader to adjust live. -
-
- - {settings.maxPageWidth ?? 900}px - -
-
- update({ optimizeContrast: v })} /> -
- -
-

Behaviour

- update({ autoMarkRead: v })} /> - update({ autoNextChapter: v })} /> - {!(settings.autoNextChapter ?? false) && ( - update({ markReadOnNext: v })} /> - )} - update({ preloadPages: v })} /> -
-
- ); -} - -function LibraryTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { - const clearHistory = useStore((s) => s.clearHistory); - const historyLen = useStore((s) => s.history.length); - return ( -
-
-

Display

- update({ libraryCropCovers: v })} /> - update({ showNsfw: v })} /> - update({ libraryPageSize: v })} /> -
-
-

Chapters

- update({ chapterSortDir: v as Settings["chapterSortDir"] })} /> - update({ chapterPageSize: v })} /> -
-
-

Extensions

- update({ preferredExtensionLang: v })} /> -
-
-

History

-
-
- Reading history - {historyLen} entries stored -
- -
-
-
- ); -} - -function PerformanceTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { - return ( -
-
-

Rendering

- update({ gpuAcceleration: v })} /> -
-
-

Idle / Splash Screen

- update({ splashCards: v })} /> -
-
-

Interface

- update({ compactSidebar: v })} /> -
-
-

Reader

- update({ readerDebounceMs: v })} - /> -
-
- ); -} - -function KeybindsTab({ settings, update, reset }: { - settings: Settings; update: (p: Partial) => void; reset: () => void; -}) { - const [listening, setListening] = useState(null); - - useEffect(() => { - if (!listening) return; - function onKey(e: KeyboardEvent) { - e.preventDefault(); e.stopPropagation(); - const bind = eventToKeybind(e); - if (!bind) return; - update({ keybinds: { ...settings.keybinds, [listening!]: bind } }); - setListening(null); - } - window.addEventListener("keydown", onKey, true); - return () => window.removeEventListener("keydown", onKey, true); - }, [listening, settings.keybinds]); - - return ( -
-
-
-

Keyboard shortcuts

- -
-

Click a key to rebind, then press the new combination.

-
- {(Object.keys(KEYBIND_LABELS) as (keyof Keybinds)[]).map((key) => { - const isListening = listening === key; - const isDefault = settings.keybinds[key] === DEFAULT_KEYBINDS[key]; - return ( -
- {KEYBIND_LABELS[key]} -
- - -
-
- ); - })} -
-
-
- ); -} - -// ── Storage helpers ─────────────────────────────────────────────────────────── - -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]}`; -} - -interface StorageInfo { - manga_bytes: number; - total_bytes: number; - free_bytes: number; - path: string; -} - -function StorageBar({ used, free, limit, total }: { used: number; free: number; limit: number | null; total: number }) { - // "Available space" = what's actually usable: already-used manga bytes + free bytes on disk. - // We intentionally do NOT use total_bytes (full drive size) as the cap — other apps / OS - // overhead eat into that, and it makes our bar look almost empty even when downloads are large. - const available = used + free; // usable space relevant to downloads - const cap = limit !== null ? Math.min(limit, available) : available; - const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0; - const critical = pctUsed > 90; - const warning = pctUsed > 75; - const freeInCap = Math.max(0, cap - used); - - return ( -
-
-
-
-
- {fmtBytes(used)} used - {fmtBytes(freeInCap)} free -
- {limit !== null && ( -

- Limit {fmtBytes(limit)} · {fmtBytes(free)} free on disk of {fmtBytes(total)} total -

- )} -
- ); -} - -function StorageTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { - const [info, setInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [clearing, setClearing] = useState(false); - const [cleared, setCleared] = useState(false); - - const limitGb = settings.storageLimitGb ?? null; - const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null; - - async function fetchInfo() { - setLoading(true); - setError(null); - try { - const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH); - const result = await invoke("get_storage_info", { - downloadsPath: pathData.settings.downloadsPath, - }); - setInfo(result); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - } - } - - useEffect(() => { fetchInfo(); }, []); - - function handleClearCache() { - setClearing(true); - caches.keys() - .then((names) => Promise.all(names.map((n) => caches.delete(n)))) - .catch(() => {}) - .finally(() => { - setClearing(false); - setCleared(true); - setTimeout(() => setCleared(false), 2500); - fetchInfo(); - }); - } - - const mangaBytes = info?.manga_bytes ?? 0; - const totalBytes = info?.total_bytes ?? 0; - const freeBytes = info?.free_bytes ?? 0; - - return ( -
-
-

Disk Usage

- {loading &&

Reading filesystem…

} - {error &&

{error}

} - {!loading && !error && info && ( - <> - -
-
- - Downloaded manga - {fmtBytes(mangaBytes)} -
-
- - Drive free - {fmtBytes(freeBytes)} -
-
- - Drive total - {fmtBytes(totalBytes)} -
-
-

{info.path}

- - )} -
- -
-

Storage Limit

-
-
- Limit download storage - - {limitGb === null - ? "No limit — uses full drive capacity" - : `Warn when downloads exceed ${limitGb} GB`} - -
- {limitGb === null ? ( - - ) : ( -
- - {limitGb} GB - - -
- )} -
- {totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > (freeBytes + mangaBytes) && ( -

Limit exceeds available space ({fmtBytes(freeBytes)} free on disk)

- )} -
- -
-

Cache

-
-
- Image cache - Cached page images stored by the webview -
- -
-
-
- ); -} - -// ── Folders tab ─────────────────────────────────────────────────────────────── - -function FoldersTab() { - const folders = useStore((s) => s.settings.folders); - const addFolder = useStore((s) => s.addFolder); - const removeFolder = useStore((s) => s.removeFolder); - const renameFolder = useStore((s) => s.renameFolder); - const toggleFolderTab = useStore((s) => s.toggleFolderTab); - - const [newName, setNewName] = useState(""); - const [editingId, setEditingId] = useState(null); - const [editingName, setEditingName] = useState(""); - - function handleCreate() { - const name = newName.trim(); - if (!name) return; - addFolder(name); - setNewName(""); - } - - function startEdit(folder: Folder) { - setEditingId(folder.id); - setEditingName(folder.name); - } - - function commitEdit() { - if (editingId && editingName.trim()) { - renameFolder(editingId, editingName.trim()); - } - setEditingId(null); - setEditingName(""); - } - - return ( -
-
-

Manage Folders

-

- Assign manga to folders from the series detail page. Toggle the tab icon to show a folder as a filter tab in the library. -

- - {/* Create new folder */} -
- setNewName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} - style={{ flex: 1, width: "auto" }} - /> - -
- - {/* Folder list */} - {folders.length === 0 ? ( -

No folders yet. Create one above.

- ) : ( -
- {folders.map((folder) => ( -
- {editingId === folder.id ? ( - <> - setEditingName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commitEdit(); - if (e.key === "Escape") { setEditingId(null); } - }} - onBlur={commitEdit} - style={{ flex: 1, width: "auto" }} - /> - - - ) : ( - <> - - {folder.name} - {folder.mangaIds.length} manga - {/* Show as tab toggle */} - - - - - )} -
- ))} -
- )} -
-
- ); -} - -// ── Appearance tab ──────────────────────────────────────────────────────────── - -const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [ - { - id: "dark", - label: "Dark", - description: "Default near-black", - swatches: ["#101010", "#151515", "#a8c4a8", "#f0efec"], - }, - { - id: "high-contrast", - label: "High Contrast", - description: "Darker base, sharper text", - swatches: ["#080808", "#111111", "#bcd8bc", "#ffffff"], - }, - { - id: "light", - label: "Light", - description: "Warm off-white", - swatches: ["#f4f2ee", "#faf8f4", "#2a5a2a", "#1a1916"], - }, - { - id: "light-contrast", - label: "Light Contrast", - description: "Light with maximum text contrast", - swatches: ["#ece8e2", "#f5f2ec", "#183818", "#080806"], - }, - { - 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"], - }, -]; - -function AppearanceTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { - const current = settings.theme ?? "dark"; - return ( -
-
-

Theme

-
- {THEMES.map((theme) => { - const active = current === theme.id; - return ( - - ); - })} -
-
-
- ); -} - -function DevToolsTab() { - const [splashTriggered, setSplashTriggered] = useState(false); - - function triggerSplash() { - setSplashTriggered(true); - setTimeout(() => setSplashTriggered(false), 200); - (window as any).__mokuShowSplash?.(); - } - - return ( -
-
-

Splash Screen

-
-
- Preview idle screen - Show the idle splash — dismiss with any click or key -
- -
-
-
-

Build Info

-
-

- Mode: {import.meta.env.MODE} -

-

- Dev: {String(import.meta.env.DEV)} -

-
-
-
- ); -} - -function AboutTab() { - return ( -
-
-

Moku

-
-

A manga reader frontend for Suwayomi / Tachidesk.

-

- Built with Tauri + React. Connects to tachidesk-server. -

-
-
-
- ); -} - -// ── Modal ───────────────────────────────────────────────────────────────────── - -export default function SettingsModal() { - const [tab, setTab] = useState("general"); - const closeSettings = useStore((s) => s.closeSettings); - const settings = useStore((s) => s.settings); - const updateSettings = useStore((s) => s.updateSettings); - const resetKeybinds = useStore((s) => s.resetKeybinds); - const backdropRef = useRef(null); - const contentBodyRef = useRef(null); - - - - useEffect(() => { - contentBodyRef.current?.scrollTo({ top: 0 }); - }, [tab]); - - const handleBackdrop = useCallback( - (e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); }, - [closeSettings] - ); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeSettings(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [closeSettings]); - - return ( -
-
-
-

Settings

- -
-
-
-

{TABS.find((t) => t.id === tab)?.label}

- -
-
- {tab === "general" && } - {tab === "appearance" && } - {tab === "reader" && } - {tab === "library" && } - {tab === "performance" && } - {tab === "keybinds" && } - {tab === "storage" && } - {tab === "folders" && } - {tab === "about" && } - {tab === "devtools" && } -
-
-
-
- ); -} \ No newline at end of file diff --git a/src/components/sources/SourceBrowse.module.css b/src/components/sources/SourceBrowse.module.css deleted file mode 100644 index dd0f274..0000000 --- a/src/components/sources/SourceBrowse.module.css +++ /dev/null @@ -1,243 +0,0 @@ -.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); } - -.sourceName { - 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); } -.tabActive { background: var(--accent-muted); color: var(--accent-fg); } -.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); } - -.searchWrap { - position: relative; - display: flex; - align-items: center; -} - -.searchIcon { - 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); } - -/* ─── Responsive grid ─────────────────────────────────────────────────────── */ -/* - Adapts to screen width: - - narrow (< ~640px): 2 columns - - default (~640-900px): auto-fill ~120px → 4–6 cols - - wide (> ~900px): more columns, stays readable -*/ -.grid, .loadingGrid { - 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; - /* GPU for smooth scroll */ - 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 .cover { filter: brightness(1.06); } -.card:hover .title { color: var(--text-primary); } - -.coverWrap { - 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); -} - -.cover { - width: 100%; - height: 100%; - object-fit: cover; - transition: filter var(--t-base); - will-change: filter; -} - -.inLibraryBadge { - 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); - /* Use secondary not muted - readable against dark bg */ - 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); -} - -/* Skeleton */ -.cardSkeleton { padding: 0; } - -.coverSkeleton { - aspect-ratio: 2 / 3; - border-radius: var(--radius-md); -} - -.titleSkeleton { - height: 11px; - margin-top: var(--sp-2); - width: 75%; -} - -/* Pagination */ -.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; -} - -.pageBtn { - 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); -} - -.pageBtn:hover:not(:disabled) { - color: var(--text-primary); - border-color: var(--border-strong); - background: var(--bg-raised); -} - -.pageBtn:disabled { opacity: 0.3; cursor: default; } - -.pageNum { - 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); -} \ No newline at end of file diff --git a/src/components/sources/SourceBrowse.tsx b/src/components/sources/SourceBrowse.tsx deleted file mode 100644 index 8c81dba..0000000 --- a/src/components/sources/SourceBrowse.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useEffect, useState, useRef } from "react"; -import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; -import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; -import { useStore } from "../../store"; -import type { Manga } from "../../lib/types"; -import s from "./SourceBrowse.module.css"; - -type BrowseType = "POPULAR" | "LATEST" | "SEARCH"; - -export default function SourceBrowse() { - const activeSource = useStore((state) => state.activeSource); - const setActiveSource = useStore((state) => state.setActiveSource); - const setActiveManga = useStore((state) => state.setActiveManga); - const setNavPage = useStore((state) => state.setNavPage); - const folders = useStore((state) => state.settings.folders); - const addFolder = useStore((state) => state.addFolder); - const assignMangaToFolder = useStore((state) => state.assignMangaToFolder); - const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); - - const [mangas, setMangas] = useState([]); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(1); - const [hasNextPage, setHasNextPage] = useState(false); - const [browseType, setBrowseType] = useState("POPULAR"); - const [search, setSearch] = useState(""); - const [searchInput, setSearchInput] = useState(""); - const searchRef = useRef(null); - - async function fetch(type: BrowseType, p: number, q: string) { - if (!activeSource) return; - setLoading(true); - setMangas([]); - gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: activeSource.id, type, page: p, query: q || null } - ) - .then((d) => { - setMangas(d.fetchSourceManga.mangas); - setHasNextPage(d.fetchSourceManga.hasNextPage); - }) - .catch(console.error) - .finally(() => setLoading(false)); - } - - useEffect(() => { - fetch(browseType, page, search); - }, [activeSource?.id, browseType, page, search]); - - function submitSearch() { - const q = searchInput.trim(); - setSearch(q); - setBrowseType("SEARCH"); - setPage(1); - } - - function setMode(mode: BrowseType) { - if (mode === browseType) return; - setBrowseType(mode); - setSearch(""); - setSearchInput(""); - setPage(1); - } - - function openManga(m: Manga) { - setActiveManga(m); - setNavPage("library"); - } - - function openCtx(e: React.MouseEvent, m: Manga) { - e.preventDefault(); - e.stopPropagation(); - setCtx({ x: e.clientX, y: e.clientY, manga: m }); - } - - function buildCtxItems(m: Manga): ContextMenuEntry[] { - return [ - { - label: m.inLibrary ? "In Library" : "Add to library", - icon: , - disabled: m.inLibrary, - onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) - .then(() => setMangas((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))) - .catch(console.error), - }, - ...(folders.length > 0 ? [ - { separator: true } as ContextMenuEntry, - ...folders.map((f): ContextMenuEntry => ({ - label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, - icon: , - onClick: () => assignMangaToFolder(f.id, m.id), - })), - ] : []), - { separator: true }, - { - label: "New folder & add", - icon: , - onClick: () => { - const name = prompt("Folder name:"); - if (name?.trim()) { - const id = addFolder(name.trim()); - assignMangaToFolder(id, m.id); - } - }, - }, - ]; - } - - if (!activeSource) return null; - - return ( -
-
- - {activeSource.displayName} -
- -
-
- {(["POPULAR", "LATEST"] as BrowseType[]).map((mode) => ( - - ))} - {search && ( - - )} -
- -
- - setSearchInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submitSearch()} - /> -
-
- - {loading ? ( -
- {Array.from({ length: 18 }).map((_, i) => ( -
-
-
-
- ))} -
- ) : mangas.length === 0 ? ( -
No results.
- ) : ( -
- {mangas.map((m) => ( - - ))} -
- )} - - {!loading && (page > 1 || hasNextPage) && ( -
- - {page} - -
- )} - {ctx && ( - setCtx(null)} - /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/sources/SourceList.module.css b/src/components/sources/SourceList.module.css deleted file mode 100644 index 6882c8f..0000000 --- a/src/components/sources/SourceList.module.css +++ /dev/null @@ -1,161 +0,0 @@ -.root { - padding: var(--sp-6); - overflow-y: auto; - height: 100%; - animation: fadeIn 0.14s ease both; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--sp-5); -} - -.heading { - font-family: var(--font-ui); - font-size: var(--text-xs); - font-weight: var(--weight-normal); - color: var(--text-faint); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; -} - -.searchWrap { - position: relative; - display: flex; - align-items: center; -} - -.searchIcon { - 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: 180px; - outline: none; - transition: border-color var(--t-base); -} - -.search::placeholder { color: var(--text-faint); } -.search:focus { border-color: var(--border-strong); } - -.langRow { - display: flex; - flex-wrap: wrap; - gap: var(--sp-1); - margin-bottom: var(--sp-4); -} - -.langBtn { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wider); - padding: 3px 8px; - 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), background var(--t-base); -} - -.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); } - -.langBtnActive { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} - -.langBtnActive:hover { - background: var(--accent-muted); - color: var(--accent-fg); -} - -.list { display: flex; flex-direction: column; gap: 1px; } - -.row { - display: flex; - align-items: center; - gap: var(--sp-3); - padding: 9px var(--sp-3); - border-radius: var(--radius-md); - border: 1px solid transparent; - background: none; - text-align: left; - width: 100%; - cursor: pointer; - transition: background var(--t-fast), border-color var(--t-fast); -} - -.row:hover { background: var(--bg-raised); border-color: var(--border-dim); } - -.rowIndented { - padding-left: var(--sp-5); -} - -.indentSpacer { - width: 32px; - flex-shrink: 0; -} - -.icon { - width: 32px; - height: 32px; - border-radius: var(--radius-md); - object-fit: cover; - flex-shrink: 0; - background: var(--bg-raised); -} - -.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } - -.name { - font-size: var(--text-base); - font-weight: var(--weight-medium); - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.meta { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); -} - -.arrow { - display: flex; - align-items: center; - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--text-faint); - flex-shrink: 0; - opacity: 0; - transition: opacity var(--t-base); -} - -.row:hover .arrow { opacity: 1; } - -.empty { - display: flex; - align-items: center; - justify-content: center; - height: 160px; - color: var(--text-faint); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); -} \ No newline at end of file diff --git a/src/components/sources/SourceList.tsx b/src/components/sources/SourceList.tsx deleted file mode 100644 index 49b4177..0000000 --- a/src/components/sources/SourceList.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useState } from "react"; -import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "@phosphor-icons/react"; -import { gql, thumbUrl } from "../../lib/client"; -import { GET_SOURCES } from "../../lib/queries"; -import { useStore } from "../../store"; -import type { Source } from "../../lib/types"; -import s from "./SourceList.module.css"; - -type Group = { name: string; icon: string; sources: Source[] }; - -export default function SourceList() { - const [sources, setSources] = useState([]); - const [loading, setLoading] = useState(true); - const [lang, setLang] = useState("all"); - const [search, setSearch] = useState(""); - const [expanded, setExpanded] = useState>(new Set()); - const setActiveSource = useStore((state) => state.setActiveSource); - - useEffect(() => { - gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => setSources(d.sources.nodes)) - .catch(console.error) - .finally(() => setLoading(false)); - }, []); - - const langs = ["all", ...Array.from(new Set(sources.map((src) => src.lang))).sort()]; - - const filtered = sources.filter((src) => { - if (src.id === "0") return false; - const matchLang = lang === "all" || src.lang === lang; - const matchSearch = - src.name.toLowerCase().includes(search.toLowerCase()) || - src.displayName.toLowerCase().includes(search.toLowerCase()); - return matchLang && matchSearch; - }); - - const groups: Group[] = []; - const seen = new Map(); - for (const src of filtered) { - const key = src.name; - if (!seen.has(key)) { - const g: Group = { name: src.name, icon: src.iconUrl, sources: [] }; - seen.set(key, g); - groups.push(g); - } - seen.get(key)!.sources.push(src); - } - - function toggleGroup(name: string) { - setExpanded((prev) => { - const next = new Set(prev); - next.has(name) ? next.delete(name) : next.add(name); - return next; - }); - } - - return ( -
-
-

Sources

-
- - setSearch(e.target.value)} - /> -
-
- -
- {langs.map((l) => ( - - ))} -
- - {loading ? ( -
- -
- ) : groups.length === 0 ? ( -
No sources found.
- ) : ( -
- {groups.map((g) => { - const single = g.sources.length === 1; - const open = expanded.has(g.name); - - return ( -
- - - {!single && open && g.sources.map((src) => ( - - ))} -
- ); - })} -
- )} -
- ); -} \ No newline at end of file diff --git a/src/lib/sourceUtils.ts b/src/lib/sourceUtils.ts deleted file mode 100644 index b839f21..0000000 --- a/src/lib/sourceUtils.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Source } from "./types"; - -/** - * Deduplicates sources by name, preferring the given language. - * This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately. - */ -export function dedupeSources(sources: Source[], preferredLang: string): Source[] { - const byName = new Map(); - for (const src of sources) { - if (src.id === "0") continue; - if (!byName.has(src.name)) byName.set(src.name, []); - byName.get(src.name)!.push(src); - } - const picked: Source[] = []; - for (const group of byName.values()) { - const preferred = group.find((s) => s.lang === preferredLang); - picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]); - } - return picked; -} - -/** - * Deduplicates manga by title (case-insensitive), keeping the first occurrence. - * This eliminates the same series appearing from multiple sources in grids. - */ -export function dedupeMangaByTitle(items: T[]): T[] { - const seen = new Set(); - const out: T[] = []; - for (const m of items) { - const key = m.title.toLowerCase().trim(); - if (!seen.has(key)) { seen.add(key); out.push(m); } - } - return out; -} - -/** - * Deduplicates manga by id only (lossless — use when sources are already deduped). - */ -export function dedupeMangaById(items: T[]): T[] { - const seen = new Set(); - const out: T[] = []; - for (const m of items) { - if (!seen.has(m.id)) { seen.add(m.id); out.push(m); } - } - return out; -} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1cd4ee6 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,6 @@ +import App from "./App.svelte"; +import "./styles/global.css"; + +const app = new App({ target: document.getElementById("app")! }); + +export default app; diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index a5b9a95..0000000 --- a/src/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App"; - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - -); \ No newline at end of file diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..f8eb5e6 --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,15 @@ +import Library from "./components/pages/Library.svelte"; +import Search from "./components/search/Search.svelte"; +import History from "./components/history/History.svelte"; +import Explore from "./components/pages/Explore.svelte"; +import Downloads from "./components/downloads/Downloads.svelte"; +import Extensions from "./components/pages/Extensions.svelte"; + +export default { + "/": Library, + "/search": Search, + "/history": History, + "/explore": Explore, + "/downloads": Downloads, + "/extensions": Extensions, +}; diff --git a/src/store/index.ts b/src/store/index.ts index eef6ec8..763dab3 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,317 +1,180 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { writable, get } from "svelte/store"; import type { Manga, Chapter, Source } from "../lib/types"; import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds"; export type PageStyle = "single" | "double" | "longstrip"; export type FitMode = "width" | "height" | "screen" | "original"; -export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id +export type LibraryFilter = "all" | "library" | "downloaded" | string; export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search"; export type ReadingDirection = "ltr" | "rtl"; export type ChapterSortDir = "desc" | "asc"; -export type Theme = - | "dark" // default — near-black - | "high-contrast" // darker + sharper text - | "light" // warm off-white - | "light-contrast" // light + max contrast - | "midnight" // blue-black tint - | "warm"; // amber/sepia tint +export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm"; export interface HistoryEntry { - mangaId: number; - mangaTitle: string; - thumbnailUrl: string; - chapterId: number; - chapterName: string; - pageNumber: number; - readAt: number; + mangaId: number; mangaTitle: string; thumbnailUrl: string; + chapterId: number; chapterName: string; pageNumber: number; readAt: number; } export interface Toast { - id: string; - kind: "success" | "error" | "info" | "download"; - title: string; - body?: string; - duration?: number; + id: string; kind: "success" | "error" | "info" | "download"; + title: string; body?: string; duration?: number; } -export interface ActiveDownload { - chapterId: number; - mangaId: number; - progress: number; -} +export interface ActiveDownload { chapterId: number; mangaId: number; progress: number } -export interface Folder { - id: string; - name: string; - mangaIds: number[]; - showTab: boolean; -} +export interface Folder { id: string; name: string; mangaIds: number[]; showTab: boolean } export interface Settings { - pageStyle: PageStyle; - readingDirection: ReadingDirection; - fitMode: FitMode; - maxPageWidth: number; - pageGap: boolean; - optimizeContrast: boolean; - offsetDoubleSpreads: boolean; - preloadPages: number; - autoMarkRead: boolean; - autoNextChapter: boolean; - libraryCropCovers: boolean; - libraryPageSize: number; - showNsfw: boolean; - chapterSortDir: ChapterSortDir; - chapterPageSize: number; - uiScale: number; - compactSidebar: boolean; - gpuAcceleration: boolean; - serverUrl: string; - serverBinary: string; - autoStartServer: boolean; - preferredExtensionLang: string; - keybinds: Keybinds; - idleTimeoutMin?: number; - splashCards?: boolean; - storageLimitGb: number | null; - folders: Folder[]; - /** - * Mark a chapter as read when the user manually taps the "next chapter" - * button/key while autoNextChapter is off. Has no effect when autoNextChapter - * is on (the scroll-based mark-as-read logic handles that path). - */ - markReadOnNext: boolean; - /** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */ - readerDebounceMs: number; - /** UI colour theme. Applied as data-theme on . */ - theme: Theme; + pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode; + maxPageWidth: number; pageGap: boolean; optimizeContrast: boolean; + offsetDoubleSpreads: boolean; preloadPages: number; autoMarkRead: boolean; + autoNextChapter: boolean; libraryCropCovers: boolean; libraryPageSize: number; + showNsfw: boolean; chapterSortDir: ChapterSortDir; chapterPageSize: number; + uiScale: number; compactSidebar: boolean; gpuAcceleration: boolean; + serverUrl: string; serverBinary: string; autoStartServer: boolean; + preferredExtensionLang: string; keybinds: Keybinds; idleTimeoutMin?: number; + splashCards?: boolean; storageLimitGb: number | null; folders: Folder[]; + markReadOnNext: boolean; readerDebounceMs: number; theme: Theme; } export const DEFAULT_SETTINGS: Settings = { - pageStyle: "longstrip", - readingDirection: "ltr", - fitMode: "width", - maxPageWidth: 900, - pageGap: true, - optimizeContrast: false, - offsetDoubleSpreads: false, - preloadPages: 3, - autoMarkRead: true, - autoNextChapter: true, - libraryCropCovers: true, - libraryPageSize: 48, - showNsfw: false, - chapterSortDir: "desc", - chapterPageSize: 25, - uiScale: 100, - compactSidebar: false, - gpuAcceleration: true, - serverUrl: "http://localhost:4567", - serverBinary: "tachidesk-server", - autoStartServer: true, - preferredExtensionLang: "en", - keybinds: DEFAULT_KEYBINDS, - idleTimeoutMin: 5, - splashCards: true, - storageLimitGb: null, - folders: [], - markReadOnNext: true, - readerDebounceMs: 120, - theme: "dark", + pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width", + maxPageWidth: 900, pageGap: true, optimizeContrast: false, + offsetDoubleSpreads: false, preloadPages: 3, autoMarkRead: true, + autoNextChapter: true, libraryCropCovers: true, libraryPageSize: 48, + showNsfw: false, chapterSortDir: "desc", chapterPageSize: 25, + uiScale: 100, compactSidebar: false, gpuAcceleration: true, + serverUrl: "http://localhost:4567", serverBinary: "tachidesk-server", + autoStartServer: true, preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS, + idleTimeoutMin: 5, splashCards: true, storageLimitGb: null, folders: [], + markReadOnNext: true, readerDebounceMs: 120, theme: "dark", }; -interface Store { - navPage: NavPage; - setNavPage: (page: NavPage) => void; - genreFilter: string; - setGenreFilter: (genre: string) => void; - searchPrefill: string; - setSearchPrefill: (q: string) => void; - activeManga: Manga | null; - setActiveManga: (manga: Manga | null) => void; - previewManga: Manga | null; - setPreviewManga: (manga: Manga | null) => void; - activeChapter: Chapter | null; - activeChapterList: Chapter[]; - openReader: (chapter: Chapter, chapterList: Chapter[]) => void; - closeReader: () => void; - activeSource: Source | null; - setActiveSource: (source: Source | null) => void; - pageUrls: string[]; - setPageUrls: (urls: string[]) => void; - pageNumber: number; - setPageNumber: (n: number) => void; - libraryFilter: LibraryFilter; - setLibraryFilter: (filter: LibraryFilter) => void; - libraryTagFilter: string[]; - setLibraryTagFilter: (tags: string[]) => void; - settingsOpen: boolean; - openSettings: () => void; - closeSettings: () => void; - activeDownloads: ActiveDownload[]; - setActiveDownloads: (items: ActiveDownload[]) => void; - history: HistoryEntry[]; - addHistory: (entry: HistoryEntry) => void; - clearHistory: () => void; - toasts: Toast[]; - addToast: (toast: Omit) => void; - dismissToast: (id: string) => void; - settings: Settings; - updateSettings: (patch: Partial) => void; - resetKeybinds: () => void; - // Folder helpers - addFolder: (name: string) => string; - removeFolder: (id: string) => void; - renameFolder: (id: string, name: string) => void; - toggleFolderTab: (id: string) => void; - assignMangaToFolder: (folderId: string, mangaId: number) => void; - removeMangaFromFolder: (folderId: string, mangaId: number) => void; - getMangaFolders: (mangaId: number) => Folder[]; +function loadPersisted() { + try { + const raw = localStorage.getItem("moku-store"); + if (!raw) return null; + return JSON.parse(raw); + } catch { return null; } } -function genId(): string { - return Math.random().toString(36).slice(2, 10); +function persist(key: string, value: unknown) { + try { localStorage.setItem(key, JSON.stringify(value)); } catch {} } -export const useStore = create()( - persist( - (set, get) => ({ - navPage: "library", - setNavPage: (navPage) => set({ navPage }), - genreFilter: "", - setGenreFilter: (genreFilter) => set({ genreFilter }), - searchPrefill: "", - setSearchPrefill: (searchPrefill) => set({ searchPrefill }), - activeManga: null, - setActiveManga: (activeManga) => set({ activeManga }), - previewManga: null, - setPreviewManga: (previewManga) => set({ previewManga }), - activeChapter: null, - activeChapterList: [], - openReader: (chapter, chapterList) => - set({ activeChapter: chapter, activeChapterList: chapterList, pageUrls: [], pageNumber: 1 }), - closeReader: () => - set({ activeChapter: null, activeChapterList: [], pageUrls: [], pageNumber: 1 }), - activeSource: null, - setActiveSource: (activeSource) => set({ activeSource }), - pageUrls: [], - setPageUrls: (pageUrls) => set({ pageUrls }), - pageNumber: 1, - setPageNumber: (pageNumber) => set({ pageNumber }), - libraryFilter: "library", - setLibraryFilter: (libraryFilter) => set({ libraryFilter }), - libraryTagFilter: [], - setLibraryTagFilter: (libraryTagFilter) => set({ libraryTagFilter }), - settingsOpen: false, - openSettings: () => set({ settingsOpen: true }), - closeSettings: () => set({ settingsOpen: false }), - activeDownloads: [], - setActiveDownloads: (activeDownloads) => set({ activeDownloads }), - history: [], - addHistory: (entry) => - set((s) => { - const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId); - if (existing === 0) { - const updated = [...s.history]; - updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt }; - return { history: updated }; - } - const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId); - return { history: [entry, ...deduped].slice(0, 300) }; - }), - clearHistory: () => set({ history: [] }), - toasts: [], - addToast: (toast) => - set((s) => ({ - toasts: [...s.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5), - })), - dismissToast: (id) => - set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), - settings: DEFAULT_SETTINGS, - updateSettings: (patch) => - set((s) => ({ settings: { ...s.settings, ...patch } })), - resetKeybinds: () => - set((s) => ({ settings: { ...s.settings, keybinds: DEFAULT_KEYBINDS } })), +const saved = loadPersisted(); - // ── Folder actions ────────────────────────────────────────────────────── - addFolder: (name) => { - const id = genId(); - set((s) => ({ - settings: { - ...s.settings, - folders: [...s.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }], - }, - })); - return id; - }, - removeFolder: (id) => - set((s) => ({ - settings: { - ...s.settings, - folders: s.settings.folders.filter((f) => f.id !== id), - }, - })), - renameFolder: (id, name) => - set((s) => ({ - settings: { - ...s.settings, - folders: s.settings.folders.map((f) => f.id === id ? { ...f, name: name.trim() } : f), - }, - })), - toggleFolderTab: (id) => - set((s) => ({ - settings: { - ...s.settings, - folders: s.settings.folders.map((f) => f.id === id ? { ...f, showTab: !f.showTab } : f), - }, - })), - assignMangaToFolder: (folderId, mangaId) => - set((s) => ({ - settings: { - ...s.settings, - folders: s.settings.folders.map((f) => - f.id === folderId && !f.mangaIds.includes(mangaId) - ? { ...f, mangaIds: [...f.mangaIds, mangaId] } - : f - ), - }, - })), - removeMangaFromFolder: (folderId, mangaId) => - set((s) => ({ - settings: { - ...s.settings, - folders: s.settings.folders.map((f) => - f.id === folderId - ? { ...f, mangaIds: f.mangaIds.filter((id) => id !== mangaId) } - : f - ), - }, - })), - getMangaFolders: (mangaId) => - get().settings.folders.filter((f) => f.mangaIds.includes(mangaId)), - }), - { - name: "moku-store", - partialize: (s) => ({ - settings: s.settings, - navPage: s.navPage, - libraryFilter: s.libraryFilter, - history: s.history, - }), - merge: (persisted: any, current) => ({ - ...current, - ...(persisted as object), - settings: { - ...DEFAULT_SETTINGS, - ...(persisted as any)?.settings, - folders: (persisted as any)?.settings?.folders ?? [], - keybinds: { - ...DEFAULT_KEYBINDS, - ...(persisted as any)?.settings?.keybinds, - }, - }, - }), +function mergeSettings(saved: any): Settings { + return { + ...DEFAULT_SETTINGS, + ...saved?.settings, + folders: saved?.settings?.folders ?? [], + keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds }, + }; +} + +export const navPage = writable(saved?.navPage ?? "library"); +export const libraryFilter = writable(saved?.libraryFilter ?? "library"); +export const history = writable(saved?.history ?? []); +export const settings = writable(mergeSettings(saved)); + +export const genreFilter = writable(""); +export const searchPrefill = writable(""); +export const activeManga = writable(null); +export const previewManga = writable(null); +export const activeSource = writable(null); +export const pageUrls = writable([]); +export const pageNumber = writable(1); +export const libraryTagFilter = writable([]); +export const settingsOpen = writable(false); +export const activeDownloads = writable([]); +export const toasts = writable([]); + +export const activeChapter = writable(null); +export const activeChapterList = writable([]); + +export function openReader(chapter: Chapter, chapterList: Chapter[]) { + activeChapter.set(chapter); + activeChapterList.set(chapterList); + pageUrls.set([]); + pageNumber.set(1); +} + +export function closeReader() { + activeChapter.set(null); + activeChapterList.set([]); + pageUrls.set([]); + pageNumber.set(1); +} + +export function addHistory(entry: HistoryEntry) { + history.update((h) => { + if (h[0]?.chapterId === entry.chapterId) { + const updated = [...h]; + updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt }; + return updated; } - ) -); \ No newline at end of file + return [entry, ...h.filter((x) => x.chapterId !== entry.chapterId)].slice(0, 300); + }); +} + +export function addToast(toast: Omit) { + toasts.update((t) => [...t, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5)); +} + +export function dismissToast(id: string) { + toasts.update((t) => t.filter((x) => x.id !== id)); +} + +export function updateSettings(patch: Partial) { + settings.update((s) => ({ ...s, ...patch })); +} + +export function resetKeybinds() { + settings.update((s) => ({ ...s, keybinds: DEFAULT_KEYBINDS })); +} + +const genId = () => Math.random().toString(36).slice(2, 10); + +export function addFolder(name: string): string { + const id = genId(); + settings.update((s) => ({ ...s, folders: [...s.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] })); + return id; +} + +export function removeFolder(id: string) { + settings.update((s) => ({ ...s, folders: s.folders.filter((f) => f.id !== id) })); +} + +export function renameFolder(id: string, name: string) { + settings.update((s) => ({ ...s, folders: s.folders.map((f) => f.id === id ? { ...f, name: name.trim() } : f) })); +} + +export function toggleFolderTab(id: string) { + settings.update((s) => ({ ...s, folders: s.folders.map((f) => f.id === id ? { ...f, showTab: !f.showTab } : f) })); +} + +export function assignMangaToFolder(folderId: string, mangaId: number) { + settings.update((s) => ({ + ...s, folders: s.folders.map((f) => + f.id === folderId && !f.mangaIds.includes(mangaId) ? { ...f, mangaIds: [...f.mangaIds, mangaId] } : f + ), + })); +} + +export function removeMangaFromFolder(folderId: string, mangaId: number) { + settings.update((s) => ({ + ...s, folders: s.folders.map((f) => + f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter((id) => id !== mangaId) } : f + ), + })); +} + +export function getMangaFolders(mangaId: number): Folder[] { + return get(settings).folders.filter((f) => f.mangaIds.includes(mangaId)); +} + +navPage.subscribe((v) => persist("moku-store", { ...loadPersisted(), navPage: v })); +libraryFilter.subscribe((v) => persist("moku-store", { ...loadPersisted(), libraryFilter: v })); +history.subscribe((v) => persist("moku-store", { ...loadPersisted(), history: v })); +settings.subscribe((v) => persist("moku-store", { ...loadPersisted(), settings: v })); diff --git a/src/styles/global.css b/src/styles/global.css index 0d63d3b..e737cf5 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,21 +1,10 @@ -/* ───────────────────────────────────────────── - Moku — Global Styles - ───────────────────────────────────────────── */ - @import url("https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap"); @import "./tokens.css"; @import "./animations.css"; -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -html, body, #root { - height: 100%; - overflow: hidden; -} +html, body, #app { height: 100%; overflow: hidden; } body { background: var(--bg-void); @@ -26,52 +15,22 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-feature-settings: "kern" 1, "liga" 1; - /* GPU: promote root to compositor layer for smooth compositing */ transform: translateZ(0); } -/* Scrollbars */ ::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: var(--radius-full); - transition: background var(--t-base); -} +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--radius-full); transition: background var(--t-base); } ::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } -/* Focus */ -:focus-visible { - outline: 1px solid var(--border-focus); - outline-offset: 2px; -} - -/* Selection */ -::selection { - background: var(--accent-dim); - color: var(--accent-fg); -} - -/* Base resets */ -button { - font-family: inherit; - cursor: pointer; - background: none; - border: none; - color: inherit; -} - -input, textarea { - font-family: inherit; - background: none; - border: none; - color: inherit; -} +:focus-visible { outline: 1px solid var(--border-focus); outline-offset: 2px; } +::selection { background: var(--accent-dim); color: var(--accent-fg); } +button { font-family: inherit; cursor: pointer; background: none; border: none; color: inherit; } +input, textarea { font-family: inherit; background: none; border: none; color: inherit; } img { display: block; } a { color: inherit; text-decoration: none; } -/* Range — reader scrubber */ input[type="range"] { flex: 1; appearance: none; @@ -82,24 +41,14 @@ input[type="range"] { outline: none; cursor: pointer; } - input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; - width: 12px; - height: 12px; + width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; transition: background var(--t-base), transform var(--t-base); } +input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent-bright); transform: scale(1.15); } -input[type="range"]::-webkit-slider-thumb:hover { - background: var(--accent-bright); - transform: scale(1.15); -} - -/* Monospace label utility */ -.mono { - font-family: var(--font-ui); - letter-spacing: var(--tracking-wide); -} \ No newline at end of file +.mono { font-family: var(--font-ui); letter-spacing: var(--tracking-wide); } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/tsconfig.json b/tsconfig.json index d0104ed..99b496c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,19 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ESNext", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", - "skipLibCheck": true, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "paths": { + "$lib/*": ["./src/lib/*"], + "$store/*": ["./src/store/*"], + "$components/*": ["./src/components/*"] + } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} \ No newline at end of file + "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index 099658c..0000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 68f7899..e78c010 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,20 @@ import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; export default defineConfig({ - plugins: [react()], + plugins: [svelte()], clearScreen: false, server: { port: 1420, strictPort: true, watch: { - ignored: ["**/src-tauri/**", '**/build-dir/**', '**/repo/**', '**/.flatpak-builder/**'], + ignored: ["**/.flatpak-builder/**", "**/src-tauri/**"], }, }, -}); \ No newline at end of file + envPrefix: ["VITE_", "TAURI_"], + build: { + target: ["es2021", "chrome100", "safari13"], + minify: !process.env.TAURI_DEBUG ? "esbuild" : false, + sourcemap: !!process.env.TAURI_DEBUG, + }, +});