feat(web): move from react-router to @tanstack/router (#1338)

* fix(auth): invalid cookie handling and wrongful basic auth invalidation

* fix(auth): fix test to reflect new HTTP status code

* fix(auth/web): do not throw on error

* fix(http): replace http codes in middleware to prevent basic auth invalidation
fix typo in comment

* fix test

* fix(web): api client handle 403

* refactor(http): auth_test use testify.assert

* refactor(http): set session opts after valid login

* refactor(http): send more client headers

* fix(http): test

* refactor(web): move router to tanstack/router

* refactor(web): use route loaders and suspense

* refactor(web): useSuspense for settings

* refactor(web): invalidate cookie in middleware

* fix: loclfile

* fix: load filter/id

* fix(web): login, onboard, types, imports

* fix(web): filter load

* fix(web): build errors

* fix(web): ts-expect-error

* fix(tests): filter_test.go

* fix(filters): tests

* refactor: remove duplicate spinner components
refactor: ReleaseTable.tsx loading animation
refactor: remove dedicated `pendingComponent` for `settingsRoute`

* fix: refactor missed SectionLoader to RingResizeSpinner

* fix: substitute divides with borders to account for unloaded elements

* fix(api): action status URL param

* revert: action status URL param
add comment

* fix(routing): notfound handling and split files

* fix(filters): notfound get params

* fix(queries): colon

* fix(queries): comments ts-ignore

* fix(queries): extract queryKeys

* fix(queries): remove err

* fix(routes): move zob schema inline

* fix(auth): middleware and redirect to login

* fix(auth): failing test

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix: JSX element stealing focus from searchbar

* reimplement empty release table state text

* fix(context): use deep-copy

* fix(releases): empty state and filter input warnings

* fix(releases): empty states

* fix(auth): onboarding

* fix(cache): invalidate queries

---------

Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
martylukyy 2024-02-12 13:07:00 +01:00 committed by GitHub
parent cc9656cd41
commit 1a23b69bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 2543 additions and 2091 deletions

View file

@ -39,11 +39,11 @@
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-query": "^5.17.19",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-router": "^1.16.0",
"@types/node": "^20.11.6",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.19",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
@ -64,7 +64,6 @@
"react-popper-tooltip": "^4.4.2",
"react-portal": "^4.2.2",
"react-ridge-state": "4.2.9",
"react-router-dom": "6.21.3",
"react-select": "^5.8.0",
"react-table": "^7.8.0",
"react-textarea-autosize": "^8.5.3",
@ -78,6 +77,7 @@
"devDependencies": {
"@microsoft/eslint-formatter-sarif": "^3.0.0",
"@rollup/wasm-node": "^4.9.6",
"@tanstack/router-devtools": "^1.1.4",
"@types/node": "^20.11.6",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",

209
web/pnpm-lock.yaml generated
View file

@ -30,6 +30,9 @@ dependencies:
'@tanstack/react-query-devtools':
specifier: ^5.8.4
version: 5.8.4(@tanstack/react-query@5.17.19)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-router':
specifier: ^1.16.0
version: 1.16.0(react-dom@18.2.0)(react@18.2.0)
'@types/node':
specifier: ^20.11.6
version: 20.11.6
@ -42,9 +45,6 @@ dependencies:
'@types/react-portal':
specifier: ^4.0.7
version: 4.0.7
'@types/react-router-dom':
specifier: ^5.3.3
version: 5.3.3
'@types/react-table':
specifier: ^7.7.19
version: 7.7.19
@ -105,9 +105,6 @@ dependencies:
react-ridge-state:
specifier: 4.2.9
version: 4.2.9(react@18.2.0)
react-router-dom:
specifier: 6.21.3
version: 6.21.3(react-dom@18.2.0)(react@18.2.0)
react-select:
specifier: ^5.8.0
version: 5.8.0(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
@ -143,6 +140,9 @@ devDependencies:
'@rollup/wasm-node':
specifier: ^4.9.6
version: 4.9.6
'@tanstack/router-devtools':
specifier: ^1.1.4
version: 1.1.4(react-dom@18.2.0)(react@18.2.0)
eslint:
specifier: ^8.56.0
version: 8.56.0
@ -175,7 +175,7 @@ devDependencies:
version: 0.17.5(vite@5.0.12)(workbox-build@7.0.0)(workbox-window@7.0.0)
vite-plugin-svgr:
specifier: ^4.2.0
version: 4.2.0(@rollup/wasm-node@4.9.6)(typescript@5.3.3)(vite@5.0.12)
version: 4.2.0(@rollup/wasm-node@4.10.0)(typescript@5.3.3)(vite@5.0.12)
packages:
@ -1432,7 +1432,6 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
regenerator-runtime: 0.14.1
dev: false
/@babel/runtime@7.23.9:
resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==}
@ -1982,12 +1981,7 @@ packages:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/@remix-run/router@1.14.2:
resolution: {integrity: sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==}
engines: {node: '>=14.0.0'}
dev: false
/@rollup/plugin-babel@5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.9.6):
/@rollup/plugin-babel@5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'}
peerDependencies:
@ -2000,36 +1994,36 @@ packages:
dependencies:
'@babel/core': 7.23.9
'@babel/helper-module-imports': 7.22.15
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.9.6)
rollup: /@rollup/wasm-node@4.9.6
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.10.0)
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.9.6):
/@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==}
engines: {node: '>= 10.0.0'}
peerDependencies:
rollup: npm:@rollup/wasm-node
dependencies:
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.9.6)
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.10.0)
'@types/resolve': 1.17.1
builtin-modules: 3.3.0
deepmerge: 4.3.1
is-module: 1.0.0
resolve: 1.22.8
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.9.6):
/@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==}
peerDependencies:
rollup: npm:@rollup/wasm-node
dependencies:
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.9.6)
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.10.0)
magic-string: 0.25.9
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.9.6):
/@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
engines: {node: '>= 8.0.0'}
peerDependencies:
@ -2038,10 +2032,10 @@ packages:
'@types/estree': 0.0.39
estree-walker: 1.0.1
picomatch: 2.3.1
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/pluginutils@5.0.5(@rollup/wasm-node@4.9.6):
/@rollup/pluginutils@5.0.5(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
@ -2053,9 +2047,18 @@ packages:
'@types/estree': 1.0.5
estree-walker: 2.0.2
picomatch: 2.3.1
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/wasm-node@4.10.0:
resolution: {integrity: sha512-wH/ih4T/iP2PUyTrkyioZqDoFY/gmu63LPLTOM5Q21gSB/D3Ejw3UBpUOMLt86fIbN3mV+wL45MyA71XAj1ytg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
dependencies:
'@types/estree': 1.0.5
optionalDependencies:
fsevents: 2.3.3
/@rollup/wasm-node@4.9.6:
resolution: {integrity: sha512-B3FpAkroTE6q+MRHzv8XLBgPbxdjJiy5UnduZNQ/4lxeF1JT2O/OAr0JPpXeRG/7zpKm/kdqU/4m6AULhmnSqw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -2064,6 +2067,7 @@ packages:
'@types/estree': 1.0.5
optionalDependencies:
fsevents: 2.3.3
dev: true
/@surma/rollup-plugin-off-main-thread@2.2.3:
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@ -2332,6 +2336,16 @@ packages:
tailwindcss: 3.4.1(ts-node@10.9.2)
dev: false
/@tanstack/history@1.1.4:
resolution: {integrity: sha512-H80reryZP3Ib5HzAo9zp1B8nbGzd+zOxe0Xt6bLYY2qtgCb+iIrVadDDt5ZnaFsrMBGbFTkEsS2ITVrAUao54A==}
engines: {node: '>=12'}
dev: true
/@tanstack/history@1.15.13:
resolution: {integrity: sha512-ToaeMtK5S4YaxCywAlYexc7KPFN0esjyTZ4vXzJhXEWAkro9iHgh7m/4ozPJb7oTo65WkHWX0W9GjcZbInSD8w==}
engines: {node: '>=12'}
dev: false
/@tanstack/query-core@5.17.19:
resolution: {integrity: sha512-Lzw8FUtnLCc9Jwz0sw9xOjZB+/mCCmJev38v2wHMUl/ioXNIhnNWeMxu0NKUjIhAd62IRB3eAtvxAGDJ55UkyA==}
dev: false
@ -2362,6 +2376,49 @@ packages:
react: 18.2.0
dev: false
/@tanstack/react-router@1.1.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-X+Nak7IxZfCHpH2GIZU9vDSpzpfDUmC30QzuYgwNRhWxGmmkDRF49d07CSc/CW5FQ9RvjECO/3dqw6X519E7HQ==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@babel/runtime': 7.23.7
'@tanstack/history': 1.1.4
'@tanstack/react-store': 0.2.1(react-dom@18.2.0)(react@18.2.0)
'@tanstack/store': 0.1.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tiny-invariant: 1.3.1
tiny-warning: 1.0.3
dev: true
/@tanstack/react-router@1.16.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-jY/mbRsdtIcaj56Jys+pr1Z17rFKIGcOwDTI5V6615e/ZzNUaPRxEvz3dAk3mWDqKNTxBUiCU5UOz5dJKx2UOg==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@tanstack/history': 1.15.13
'@tanstack/react-store': 0.2.1(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tiny-invariant: 1.3.1
tiny-warning: 1.0.3
dev: false
/@tanstack/react-store@0.2.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-tEbMCQjbeVw9KOP/202LfqZMSNAVi6zYkkp1kBom8nFuMx/965Hzes3+6G6b/comCwVxoJU8Gg9IrcF8yRPthw==}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@tanstack/store': 0.1.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
use-sync-external-store: 1.2.0(react@18.2.0)
/@tanstack/react-virtual@3.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA==}
peerDependencies:
@ -2373,6 +2430,23 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@tanstack/router-devtools@1.1.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-/chCH/ty386podf2vwON55pAJ9MQ+94vSv35tsF6LgUlTXCw8fYOL4WR1Fp6PgBsUXrPfDt3TMAueVqSitVpeA==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@babel/runtime': 7.23.7
'@tanstack/react-router': 1.1.4(react-dom@18.2.0)(react@18.2.0)
date-fns: 2.30.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: true
/@tanstack/store@0.1.3:
resolution: {integrity: sha512-GnolmC8Fr4mvsHE1fGQmR3Nm0eBO3KnZjDU0a+P3TeQNM/dDscFGxtA7p31NplQNW3KwBw4t1RVFmz0VeKLxcw==}
/@tanstack/virtual-core@3.0.0:
resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==}
dev: false
@ -2396,10 +2470,6 @@ packages:
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
/@types/history@4.7.11:
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
dev: false
/@types/hoist-non-react-statics@3.3.5:
resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==}
dependencies:
@ -2446,21 +2516,6 @@ packages:
'@types/react': 18.2.48
dev: false
/@types/react-router-dom@5.3.3:
resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
dependencies:
'@types/history': 4.7.11
'@types/react': 18.2.48
'@types/react-router': 5.1.20
dev: false
/@types/react-router@5.1.20:
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
dependencies:
'@types/history': 4.7.11
'@types/react': 18.2.48
dev: false
/@types/react-table@7.7.19:
resolution: {integrity: sha512-47jMa1Pai7ily6BXJCW33IL5ghqmCWs2VM9s+h1D4mCaK5P4uNkZOW3RMMg8MCXBvAJ0v9+sPqKjhid0PaJPQA==}
dependencies:
@ -3108,6 +3163,13 @@ packages:
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
dev: false
/date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
dependencies:
'@babel/runtime': 7.23.9
dev: true
/date-fns@3.3.1:
resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==}
dev: false
@ -4838,7 +4900,6 @@ packages:
loose-envify: 1.4.0
react: 18.2.0
scheduler: 0.23.0
dev: false
/react-error-boundary@4.0.12(react@18.2.0):
resolution: {integrity: sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA==}
@ -4939,29 +5000,6 @@ packages:
react: 18.2.0
dev: false
/react-router-dom@6.21.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-kNzubk7n4YHSrErzjLK72j0B5i969GsuCGazRl3G6j1zqZBLjuSlYBdVdkDOgzGdPIffUOc9nmgiadTEVoq91g==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16.8'
dependencies:
'@remix-run/router': 1.14.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router: 6.21.3(react@18.2.0)
dev: false
/react-router@6.21.3(react@18.2.0):
resolution: {integrity: sha512-a0H638ZXULv1OdkmiK6s6itNhoy33ywxmUFT/xtSoVyf9VnC7n7+VT4LjVzdIHSaF5TIh9ylUgxMXksHTgGrKg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: ^18.2.0
dependencies:
'@remix-run/router': 1.14.2
react: 18.2.0
dev: false
/react-select@5.8.0(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==}
peerDependencies:
@ -5024,7 +5062,6 @@ packages:
engines: {node: '>=0.10.0'}
dependencies:
loose-envify: 1.4.0
dev: false
/read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@ -5138,7 +5175,7 @@ packages:
dependencies:
glob: 7.2.3
/rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.9.6):
/rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==}
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser
peerDependencies:
@ -5146,7 +5183,7 @@ packages:
dependencies:
'@babel/code-frame': 7.23.5
jest-worker: 26.6.2
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
serialize-javascript: 4.0.0
terser: 5.27.0
dev: true
@ -5182,7 +5219,6 @@ packages:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
loose-envify: 1.4.0
dev: false
/semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
@ -5516,9 +5552,11 @@ packages:
any-promise: 1.3.0
dev: false
/tiny-invariant@1.3.1:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
/tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
@ -5750,6 +5788,13 @@ packages:
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.48)(react@18.2.0)
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^18.2.0
dependencies:
react: 18.2.0
/utf8@3.0.0:
resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==}
dev: true
@ -5779,12 +5824,12 @@ packages:
- supports-color
dev: true
/vite-plugin-svgr@4.2.0(@rollup/wasm-node@4.9.6)(typescript@5.3.3)(vite@5.0.12):
/vite-plugin-svgr@4.2.0(@rollup/wasm-node@4.10.0)(typescript@5.3.3)(vite@5.0.12):
resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==}
peerDependencies:
vite: ^2.6.0 || 3 || 4 || 5
dependencies:
'@rollup/pluginutils': 5.0.5(@rollup/wasm-node@4.9.6)
'@rollup/pluginutils': 5.0.5(@rollup/wasm-node@4.10.0)
'@svgr/core': 8.1.0(typescript@5.3.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0)
vite: 5.0.12(@types/node@20.11.6)
@ -5825,7 +5870,7 @@ packages:
'@types/node': 20.11.6
esbuild: 0.19.12
postcss: 8.4.33
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
optionalDependencies:
fsevents: 2.3.3
@ -5923,9 +5968,9 @@ packages:
'@babel/core': 7.23.9
'@babel/preset-env': 7.23.9(@babel/core@7.23.9)
'@babel/runtime': 7.23.9
'@rollup/plugin-babel': 5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.9.6)
'@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.9.6)
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.9.6)
'@rollup/plugin-babel': 5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.10.0)
'@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.10.0)
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.10.0)
'@surma/rollup-plugin-off-main-thread': 2.2.3
ajv: 8.12.0
common-tags: 1.8.2
@ -5934,8 +5979,8 @@ packages:
glob: 7.2.3
lodash: 4.17.21
pretty-bytes: 5.6.0
rollup: /@rollup/wasm-node@4.9.6
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.9.6)
rollup: /@rollup/wasm-node@4.10.0
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.10.0)
source-map: 0.8.0-beta.0
stringify-object: 3.3.0
strip-comments: 2.0.1

View file

@ -3,60 +3,34 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ErrorBoundary } from "react-error-boundary";
import { toast, Toaster } from "react-hot-toast";
import { LocalRouter } from "./domain/routes";
import { AuthContext, SettingsContext } from "./utils/Context";
import { ErrorPage } from "./components/alerts";
import Toast from "./components/notifications/Toast";
import { RouterProvider } from "@tanstack/react-router"
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "react-hot-toast";
import { Portal } from "react-portal";
import { Router } from "@app/routes";
import { routerBasePath } from "@utils";
import { queryClient } from "@api/QueryClient";
import { AuthContext } from "@utils/Context";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// The retries will have exponential delay.
// See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay
// delay = Math.min(1000 * 2 ** attemptIndex, 30000)
retry: true,
throwOnError: true,
},
mutations: {
onError: (error) => {
// Use a format string to convert the error object to a proper string without much hassle.
const message = (
typeof (error) === "object" && typeof ((error as Error).message) ?
(error as Error).message :
`${error}`
);
toast.custom((t) => <Toast type="error" body={message} t={t} />);
}
}
declare module '@tanstack/react-router' {
interface Register {
router: typeof Router
}
});
}
export function App() {
const { reset } = useQueryErrorResetBoundary();
const authContext = AuthContext.useValue();
const settings = SettingsContext.useValue();
return (
<ErrorBoundary
onReset={reset}
FallbackComponent={ErrorPage}
>
<QueryClientProvider client={queryClient}>
<Portal>
<Toaster position="top-right" />
</Portal>
<LocalRouter isLoggedIn={authContext.isLoggedIn} />
{settings.debug ? (
<ReactQueryDevtools initialIsOpen={false} />
) : null}
<RouterProvider
basepath={routerBasePath()}
router={Router}
context={{
auth: AuthContext,
}}
/>
</QueryClientProvider>
</ErrorBoundary>
);
}
}

View file

@ -4,7 +4,6 @@
*/
import { baseUrl, sseBaseUrl } from "@utils";
import { AuthContext } from "@utils/Context";
import { GithubRelease } from "@app/types/Update";
type RequestBody = BodyInit | object | Record<string, unknown> | null;
@ -30,7 +29,8 @@ export async function HttpClient<T = unknown>(
): Promise<T> {
const init: RequestInit = {
method: config.method,
headers: { "Accept": "*/*" }
headers: { "Accept": "*/*", 'x-requested-with': 'XMLHttpRequest' },
credentials: "include",
};
if (config.body) {
@ -87,22 +87,17 @@ export async function HttpClient<T = unknown>(
return Promise.resolve<T>({} as T);
}
case 401: {
// Remove auth info from localStorage
AuthContext.reset();
// Show an error toast to notify the user what occurred
// return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
return Promise.reject(response);
// return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
}
case 403: {
// Remove auth info from localStorage
AuthContext.reset();
// Show an error toast to notify the user what occurred
return Promise.reject(response);
}
case 404: {
return Promise.reject(new Error(`[404] Not found: "${endpoint}"`));
const isJson = response.headers.get("Content-Type")?.includes("application/json");
const json = isJson ? await response.json() : null;
return Promise.reject<T>(json as T);
// return Promise.reject(new Error(`[404] Not Found: "${endpoint}"`));
}
case 500: {
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
@ -326,6 +321,8 @@ export const APIClient = {
if (filter.id == "indexer") {
params["indexer"].push(filter.value);
} else if (filter.id === "action_status") {
params["push_status"].push(filter.value); // push_status is the correct value here otherwise the releases table won't load when filtered by push status
} else if (filter.id === "push_status") {
params["push_status"].push(filter.value);
} else if (filter.id == "name") {
params["q"].push(filter.value);

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import Toast from "@components/notifications/Toast";
const MAX_RETRIES = 6;
const HTTP_STATUS_TO_NOT_RETRY = [400, 401, 403, 404];
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error ) => {
console.error("query client error: ", error);
toast.custom((t) => <Toast type="error" body={error?.message} t={t}/>);
// @ts-expect-error TS2339: Property status does not exist on type Error
if (error?.status === 401 || error?.status === 403) {
// @ts-expect-error TS2339: Property status does not exist on type Error
console.error("bad status, redirect to login", error?.status)
// Redirect to login page
window.location.href = "/login";
return
}
}
}),
defaultOptions: {
queries: {
// The retries will have exponential delay.
// See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay
// delay = Math.min(1000 * 2 ** attemptIndex, 30000)
// retry: false,
throwOnError: true,
retry: (failureCount, error) => {
console.debug("retry count:", failureCount)
console.error("retry err: ", error)
// @ts-expect-error TS2339: ignore
if (HTTP_STATUS_TO_NOT_RETRY.includes(error.status)) {
// @ts-expect-error TS2339: ignore
console.log(`retry: Aborting retry due to ${error.status} status`);
return false;
}
return failureCount <= MAX_RETRIES;
},
},
mutations: {
onError: (error) => {
// Use a format string to convert the error object to a proper string without much hassle.
const message = (
typeof (error) === "object" && typeof ((error as Error).message) ?
(error as Error).message :
`${error}`
);
toast.custom((t) => <Toast type="error" body={message} t={t}/>);
}
}
}
});

135
web/src/api/queries.ts Normal file
View file

@ -0,0 +1,135 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import {
ApiKeys,
DownloadClientKeys,
FeedKeys,
FilterKeys,
IndexerKeys,
IrcKeys, NotificationKeys,
ReleaseKeys,
SettingsKeys
} from "@api/query_keys";
export const FiltersQueryOptions = (indexers: string[], sortOrder: string) =>
queryOptions({
queryKey: FilterKeys.list(indexers, sortOrder),
queryFn: () => APIClient.filters.find(indexers, sortOrder),
refetchOnWindowFocus: false
});
export const FilterByIdQueryOptions = (filterId: number) =>
queryOptions({
queryKey: FilterKeys.detail(filterId),
queryFn: async ({queryKey}) => await APIClient.filters.getByID(queryKey[2]),
retry: false,
});
export const ConfigQueryOptions = (enabled: boolean = true) =>
queryOptions({
queryKey: SettingsKeys.config(),
queryFn: () => APIClient.config.get(),
retry: false,
refetchOnWindowFocus: false,
enabled: enabled,
});
export const UpdatesQueryOptions = (enabled: boolean) =>
queryOptions({
queryKey: SettingsKeys.updates(),
queryFn: () => APIClient.updates.getLatestRelease(),
retry: false,
refetchOnWindowFocus: false,
enabled: enabled,
});
export const IndexersQueryOptions = () =>
queryOptions({
queryKey: IndexerKeys.lists(),
queryFn: () => APIClient.indexers.getAll()
});
export const IndexersOptionsQueryOptions = () =>
queryOptions({
queryKey: IndexerKeys.options(),
queryFn: () => APIClient.indexers.getOptions(),
refetchOnWindowFocus: false,
staleTime: Infinity
});
export const IndexersSchemaQueryOptions = (enabled: boolean) =>
queryOptions({
queryKey: IndexerKeys.schema(),
queryFn: () => APIClient.indexers.getSchema(),
refetchOnWindowFocus: false,
staleTime: Infinity,
enabled: enabled
});
export const IrcQueryOptions = () =>
queryOptions({
queryKey: IrcKeys.lists(),
queryFn: () => APIClient.irc.getNetworks(),
refetchOnWindowFocus: false,
refetchInterval: 3000 // Refetch every 3 seconds
});
export const FeedsQueryOptions = () =>
queryOptions({
queryKey: FeedKeys.lists(),
queryFn: () => APIClient.feeds.find(),
});
export const DownloadClientsQueryOptions = () =>
queryOptions({
queryKey: DownloadClientKeys.lists(),
queryFn: () => APIClient.download_clients.getAll(),
});
export const NotificationsQueryOptions = () =>
queryOptions({
queryKey: NotificationKeys.lists(),
queryFn: () => APIClient.notifications.getAll()
});
export const ApikeysQueryOptions = () =>
queryOptions({
queryKey: ApiKeys.lists(),
queryFn: () => APIClient.apikeys.getAll(),
refetchOnWindowFocus: false,
});
export const ReleasesListQueryOptions = (offset: number, limit: number, filters: ReleaseFilter[]) =>
queryOptions({
queryKey: ReleaseKeys.list(offset, limit, filters),
queryFn: () => APIClient.release.findQuery(offset, limit, filters),
staleTime: 5000
});
export const ReleasesLatestQueryOptions = () =>
queryOptions({
queryKey: ReleaseKeys.latestActivity(),
queryFn: () => APIClient.release.findRecent(),
refetchOnWindowFocus: false
});
export const ReleasesStatsQueryOptions = () =>
queryOptions({
queryKey: ReleaseKeys.stats(),
queryFn: () => APIClient.release.stats(),
refetchOnWindowFocus: false
});
// ReleasesIndexersQueryOptions get basic list of used indexers by identifier
export const ReleasesIndexersQueryOptions = () =>
queryOptions({
queryKey: ReleaseKeys.indexers(),
queryFn: () => APIClient.release.indexerOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});

77
web/src/api/query_keys.ts Normal file
View file

@ -0,0 +1,77 @@
export const SettingsKeys = {
all: ["settings"] as const,
updates: () => [...SettingsKeys.all, "updates"] as const,
config: () => [...SettingsKeys.all, "config"] as const,
lists: () => [...SettingsKeys.all, "list"] as const,
};
export const FilterKeys = {
all: ["filters"] as const,
lists: () => [...FilterKeys.all, "list"] as const,
list: (indexers: string[], sortOrder: string) => [...FilterKeys.lists(), {indexers, sortOrder}] as const,
details: () => [...FilterKeys.all, "detail"] as const,
detail: (id: number) => [...FilterKeys.details(), id] as const
};
export const ReleaseKeys = {
all: ["releases"] as const,
lists: () => [...ReleaseKeys.all, "list"] as const,
list: (pageIndex: number, pageSize: number, filters: ReleaseFilter[]) => [...ReleaseKeys.lists(), {
pageIndex,
pageSize,
filters
}] as const,
details: () => [...ReleaseKeys.all, "detail"] as const,
detail: (id: number) => [...ReleaseKeys.details(), id] as const,
indexers: () => [...ReleaseKeys.all, "indexers"] as const,
stats: () => [...ReleaseKeys.all, "stats"] as const,
latestActivity: () => [...ReleaseKeys.all, "latest-activity"] as const,
};
export const ApiKeys = {
all: ["api_keys"] as const,
lists: () => [...ApiKeys.all, "list"] as const,
details: () => [...ApiKeys.all, "detail"] as const,
detail: (id: string) => [...ApiKeys.details(), id] as const
};
export const DownloadClientKeys = {
all: ["download_clients"] as const,
lists: () => [...DownloadClientKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...DownloadClientKeys.all, "detail"] as const,
detail: (id: number) => [...DownloadClientKeys.details(), id] as const
};
export const FeedKeys = {
all: ["feeds"] as const,
lists: () => [...FeedKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...feedKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...FeedKeys.all, "detail"] as const,
detail: (id: number) => [...FeedKeys.details(), id] as const
};
export const IndexerKeys = {
all: ["indexers"] as const,
schema: () => [...IndexerKeys.all, "indexer-definitions"] as const,
options: () => [...IndexerKeys.all, "options"] as const,
lists: () => [...IndexerKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...indexerKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...IndexerKeys.all, "detail"] as const,
detail: (id: number) => [...IndexerKeys.details(), id] as const
};
export const IrcKeys = {
all: ["irc_networks"] as const,
lists: () => [...IrcKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...ircKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...IrcKeys.all, "detail"] as const,
detail: (id: number) => [...IrcKeys.details(), id] as const
};
export const NotificationKeys = {
all: ["notifications"] as const,
lists: () => [...NotificationKeys.all, "list"] as const,
details: () => [...NotificationKeys.all, "detail"] as const,
detail: (id: number) => [...NotificationKeys.details(), id] as const
};

View file

@ -1,32 +0,0 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { RingResizeSpinner } from "@components/Icons";
import { classNames } from "@utils";
const SIZE = {
small: "w-6 h-6",
medium: "w-8 h-8",
large: "w-12 h-12",
xlarge: "w-24 h-24"
} as const;
interface SectionLoaderProps {
$size: keyof typeof SIZE;
}
export const SectionLoader = ({ $size }: SectionLoaderProps) => {
if ($size === "xlarge") {
return (
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<RingResizeSpinner className={classNames(SIZE[$size], "mx-auto my-36 text-blue-500")} />
</div>
);
} else {
return (
<RingResizeSpinner className={classNames(SIZE[$size], "text-blue-500")} />
);
}
};

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link } from "react-router-dom";
import { Link } from "@tanstack/react-router";
import { ExternalLink } from "@components/ExternalLink";
import Logo from "@app/logo.svg?react";
@ -12,8 +12,11 @@ export const NotFound = () => {
return (
<div className="min-h-screen flex flex-col justify-center ">
<div className="flex justify-center">
<Logo className="h-24 sm:h-48" />
<Logo className="h-24 sm:h-48"/>
</div>
<h2 className="text-2xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
404 Page not found
</h2>
<h1 className="text-3xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Oops, looks like there was a little too much brr!
</h1>

View file

@ -9,7 +9,6 @@ import { formatDistanceToNowStrict } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CellProps } from "react-table";
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
import { ExternalLink } from "../ExternalLink";
import {
ClockIcon,
XMarkIcon,
@ -19,8 +18,9 @@ import {
} from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import {classNames, humanFileSize, simplifyDate} from "@utils";
import { filterKeys } from "@screens/filters/List";
import { FilterKeys } from "@api/query_keys";
import { classNames, humanFileSize, simplifyDate } from "@utils";
import { ExternalLink } from "../ExternalLink";
import Toast from "@components/notifications/Toast";
import { RingResizeSpinner } from "@components/Icons";
import { Tooltip } from "@components/tooltips/Tooltip";
@ -164,7 +164,7 @@ const RetryActionButton = ({ status }: RetryActionButtonProps) => {
mutationFn: (vars: RetryAction) => APIClient.release.replayAction(vars.releaseId, vars.actionId),
onSuccess: () => {
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
toast.custom((t) => (
<Toast type="success" body={`${status?.action} replayed`} t={t} />

View file

@ -23,3 +23,11 @@ export const DEBUG: FC<DebugProps> = ({ values }) => {
</div>
);
};
export function LogDebug(...data: any[]): void {
if (process.env.NODE_ENV !== "development") {
return;
}
console.log(...data)
}

View file

@ -5,11 +5,11 @@
import toast from "react-hot-toast";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { Disclosure } from "@headlessui/react";
import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { AuthContext } from "@utils/Context";
import Toast from "@components/notifications/Toast";
import { LeftNav } from "./LeftNav";
@ -17,37 +17,35 @@ import { RightNav } from "./RightNav";
import { MobileNav } from "./MobileNav";
import { ExternalLink } from "@components/ExternalLink";
export const Header = () => {
const { isError:isConfigError, error: configError, data: config } = useQuery({
queryKey: ["config"],
queryFn: () => APIClient.config.get(),
retry: false,
refetchOnWindowFocus: false
});
import { AuthIndexRoute } from "@app/routes";
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
export const Header = () => {
const router = useRouter()
const { auth } = AuthIndexRoute.useRouteContext()
const { isError:isConfigError, error: configError, data: config } = useQuery(ConfigQueryOptions(true));
if (isConfigError) {
console.log(configError);
}
const { isError, error, data } = useQuery({
queryKey: ["updates"],
queryFn: () => APIClient.updates.getLatestRelease(),
retry: false,
refetchOnWindowFocus: false,
enabled: config?.check_for_updates === true
});
if (isError) {
console.log(error);
const { isError: isUpdateError, error, data } = useQuery(UpdatesQueryOptions(config?.check_for_updates === true));
if (isUpdateError) {
console.log("update error", error);
}
const logoutMutation = useMutation({
mutationFn: APIClient.auth.logout,
onSuccess: () => {
AuthContext.reset();
toast.custom((t) => (
<Toast type="success" body="You have been logged out. Goodbye!" t={t} />
));
auth.logout()
router.history.push("/")
},
onError: (err) => {
console.error("logout error", err)
}
});
@ -62,7 +60,7 @@ export const Header = () => {
<div className="border-b border-gray-300 dark:border-gray-775">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<LeftNav />
<RightNav logoutMutation={logoutMutation.mutate} />
<RightNav logoutMutation={logoutMutation.mutate} auth={auth} />
<div className="-mr-2 flex sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="bg-gray-200 dark:bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-600 dark:text-gray-400 hover:text-white hover:bg-gray-700">
@ -94,7 +92,7 @@ export const Header = () => {
)}
</div>
<MobileNav logoutMutation={logoutMutation.mutate} />
<MobileNav logoutMutation={logoutMutation.mutate} auth={auth} />
</>
)}
</Disclosure>

View file

@ -3,7 +3,10 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link, NavLink } from "react-router-dom";
// import { Link, NavLink } from "react-router-dom";
import { Link } from '@tanstack/react-router'
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
@ -23,22 +26,27 @@ export const LeftNav = () => (
<div className="sm:ml-3 hidden sm:block">
<div className="flex items-baseline space-x-4">
{NAV_ROUTES.map((item, itemIdx) => (
<NavLink
<Link
key={item.name + itemIdx}
to={item.path}
className={({ isActive }) =>
classNames(
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200",
isActive
? "text-black dark:text-gray-50 font-bold"
: "text-gray-600 dark:text-gray-500"
)
}
end={item.path === "/"}
params={{}}
>
{item.name}
</NavLink>
{({ isActive }) => {
return (
<>
<span className={
classNames(
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200",
isActive
? "text-black dark:text-gray-50 font-bold"
: "text-gray-600 dark:text-gray-500"
)
}>{item.name}</span>
</>
)
}}
</Link>
))}
<ExternalLink
href="https://autobrr.com"

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { NavLink } from "react-router-dom";
import {Link} from "@tanstack/react-router";
import { Disclosure } from "@headlessui/react";
import { classNames } from "@utils";
@ -15,21 +15,28 @@ export const MobileNav = (props: RightNavProps) => (
<Disclosure.Panel className="border-b border-gray-300 dark:border-gray-700 md:hidden">
<div className="px-2 py-3 space-y-1 sm:px-3">
{NAV_ROUTES.map((item) => (
<NavLink
<Link
key={item.path}
activeOptions={{ exact: item.exact }}
to={item.path}
className={({ isActive }) =>
classNames(
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
isActive
search={{}}
params={{}}
>
{({ isActive }) => {
return (
<span className={
classNames(
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
isActive
? "underline underline-offset-2 decoration-2 decoration-sky-500 font-bold text-black"
: "font-medium"
)
}
end={item.path === "/"}
>
{item.name}
</NavLink>
)
}>
{item.name}
</span>
)
}}
</Link>
))}
<button
onClick={(e) => {

View file

@ -4,18 +4,16 @@
*/
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { UserIcon } from "@heroicons/react/24/solid";
import { Menu, Transition } from "@headlessui/react";
import { classNames } from "@utils";
import { AuthContext } from "@utils/Context";
import { RightNavProps } from "./_shared";
import { Cog6ToothIcon, ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
import {Link} from "@tanstack/react-router";
export const RightNav = (props: RightNavProps) => {
const authContext = AuthContext.useValue();
return (
<div className="hidden sm:block">
<div className="ml-4 flex items-center sm:ml-6">
@ -34,7 +32,7 @@ export const RightNav = (props: RightNavProps) => {
<span className="sr-only">
Open user menu for{" "}
</span>
{authContext.username}
{props.auth.username}
</span>
<UserIcon
className="inline ml-1 h-5 w-5"

View file

@ -3,17 +3,21 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { AuthCtx } from "@utils/Context";
interface NavItem {
name: string;
path: string;
exact?: boolean;
}
export interface RightNavProps {
logoutMutation: () => void;
auth: AuthCtx
}
export const NAV_ROUTES: Array<NavItem> = [
{ name: "Dashboard", path: "/" },
{ name: "Dashboard", path: "/", exact: true },
{ name: "Filters", path: "/filters" },
{ name: "Releases", path: "/releases" },
{ name: "Settings", path: "/settings" },

View file

@ -8,7 +8,7 @@ import { FC, Fragment, MutableRefObject, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { SectionLoader } from "@components/SectionLoader";
import { RingResizeSpinner } from "@components/Icons";
interface ModalUpperProps {
title: string;
@ -58,7 +58,7 @@ const ModalUpper = ({ title, text }: ModalUpperProps) => (
const ModalLower = ({ isOpen, isLoading, toggle, deleteAction }: ModalLowerProps) => (
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{isLoading ? (
<SectionLoader $size="small" />
<RingResizeSpinner className="text-blue-500 size-6" />
) : (
<>
<button
@ -221,7 +221,7 @@ export const ForceRunModal: FC<ForceRunModalProps> = (props: ForceRunModalProps)
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{props.isLoading ? (
<SectionLoader $size="small" />
<RingResizeSpinner className="text-blue-500 size-6" />
) : (
<>
<button

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense } from "react";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { baseUrl } from "@utils";
import { Header } from "@components/header";
import { SectionLoader } from "@components/SectionLoader";
import { NotFound } from "@components/alerts/NotFound";
import { Logs } from "@screens/Logs";
import { Releases } from "@screens/Releases";
import { Settings } from "@screens/Settings";
import { Dashboard } from "@screens/Dashboard";
import { Login, Onboarding } from "@screens/auth";
import { Filters, FilterDetails } from "@screens/filters";
import * as SettingsSubPage from "@screens/settings/index";
const BaseLayout = () => (
<div className="min-h-screen">
<Header />
<Suspense fallback={<SectionLoader $size="xlarge" />}>
<Outlet />
</Suspense>
</div>
);
export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
<BrowserRouter basename={baseUrl()}>
{isLoggedIn ? (
<Routes>
<Route path="*" element={<NotFound />} />
<Route element={<BaseLayout />}>
<Route index element={<Dashboard />} />
<Route path="logs" element={<Logs />} />
<Route path="releases" element={<Releases />} />
<Route path="filters">
<Route index element={<Filters />} />
<Route path=":filterId/*" element={<FilterDetails />} />
</Route>
<Route path="settings" element={<Settings />}>
<Route index element={<SettingsSubPage.Application />} />
<Route path="logs" element={<SettingsSubPage.Logs />} />
<Route path="api-keys" element={<SettingsSubPage.Api />} />
<Route path="indexers" element={<SettingsSubPage.Indexer />} />
<Route path="feeds" element={<SettingsSubPage.Feed />} />
<Route path="irc" element={<SettingsSubPage.Irc />} />
<Route path="clients" element={<SettingsSubPage.DownloadClient />} />
<Route path="notifications" element={<SettingsSubPage.Notification />} />
<Route path="releases" element={<SettingsSubPage.Release />} />
<Route path="regex-playground" element={<SettingsSubPage.RegexPlayground />} />
<Route path="account" element={<SettingsSubPage.Account />} />
</Route>
</Route>
</Routes>
) : (
<Routes>
<Route path="/onboard" element={<Onboarding />} />
<Route path="*" element={<Login />} />
</Routes>
)}
</BrowserRouter>
);

View file

@ -5,17 +5,18 @@
import { Fragment } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "react-hot-toast";
import { XMarkIcon } from "@heroicons/react/24/solid";
import { Dialog, Transition } from "@headlessui/react";
import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import { useNavigate } from "react-router-dom";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import { DEBUG } from "@components/debug";
import Toast from "@components/notifications/Toast";
import { filterKeys } from "@screens/filters/List";
interface filterAddFormProps {
isOpen: boolean;
@ -28,13 +29,12 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
const mutation = useMutation({
mutationFn: (filter: Filter) => APIClient.filters.create(filter),
onSuccess: (filter) => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Filter ${filter.name} was added`} t={t} />);
toggle();
if (filter.id) {
navigate(filter.id.toString());
navigate({ to: "/filters/$filterId", params: { filterId: filter.id }})
}
}
});

View file

@ -12,9 +12,9 @@ import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import { APIClient } from "@api/APIClient";
import { ApiKeys } from "@api/query_keys";
import { DEBUG } from "@components/debug";
import Toast from "@components/notifications/Toast";
import { apiKeys } from "@screens/settings/Api";
interface apiKeyAddFormProps {
isOpen: boolean;
@ -27,7 +27,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
const mutation = useMutation({
mutationFn: (apikey: APIKey) => APIClient.apikeys.create(apikey),
onSuccess: (_, key) => {
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
queryClient.invalidateQueries({ queryKey: ApiKeys.lists() });
toast.custom((t) => <Toast type="success" body={`API key ${key.name} was added`} t={t}/>);

View file

@ -13,6 +13,7 @@ import { toast } from "react-hot-toast";
import { classNames, sleep } from "@utils";
import { DEBUG } from "@components/debug";
import { APIClient } from "@api/APIClient";
import { DownloadClientKeys } from "@api/query_keys";
import { DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants";
import Toast from "@components/notifications/Toast";
import { useToggle } from "@hooks/hooks";
@ -24,7 +25,6 @@ import {
SwitchGroupWide,
TextFieldWide
} from "@components/inputs";
import { clientKeys } from "@screens/settings/DownloadClient";
import { DocsLink, ExternalLink } from "@components/ExternalLink";
import { SelectFieldBasic } from "@components/inputs/select_wide";
@ -693,7 +693,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: formProps) {
const addMutation = useMutation({
mutationFn: (client: DownloadClient) => APIClient.download_clients.create(client),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
toast.custom((t) => <Toast type="success" body="Client was added" t={t} />);
toggle();
@ -865,8 +865,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
const mutation = useMutation({
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
toast.custom((t) => <Toast type="success" body={`${client.name} was updated successfully`} t={t} />);
toggle();
@ -878,8 +878,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
const deleteMutation = useMutation({
mutationFn: (clientID: number) => APIClient.download_clients.delete(clientID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
toast.custom((t) => <Toast type="success" body={`${client.name} was deleted.`} t={t} />);
toggleDeleteModal();

View file

@ -9,6 +9,7 @@ import { toast } from "react-hot-toast";
import { useFormikContext } from "formik";
import { APIClient } from "@api/APIClient";
import { FeedKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { SlideOver } from "@components/panels";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
@ -17,7 +18,7 @@ import { componentMapType } from "./DownloadClientForms";
import { sleep } from "@utils";
import { ImplementationBadges } from "@screens/settings/Indexer";
import { FeedDownloadTypeOptions } from "@domain/constants";
import { feedKeys } from "@screens/settings/Feed";
interface UpdateProps {
isOpen: boolean;
@ -50,7 +51,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
const mutation = useMutation({
mutationFn: (feed: Feed) => APIClient.feeds.update(feed),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${feed.name} was updated successfully`} t={t} />);
toggle();
@ -62,7 +63,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
const deleteMutation = useMutation({
mutationFn: (feedID: number) => APIClient.feeds.delete(feedID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${feed.name} was deleted.`} t={t} />);
}

View file

@ -15,13 +15,13 @@ import { Dialog, Transition } from "@headlessui/react";
import { classNames, sleep } from "@utils";
import { DEBUG } from "@components/debug";
import { APIClient } from "@api/APIClient";
import { FeedKeys, IndexerKeys, ReleaseKeys } from "@api/query_keys";
import { IndexersSchemaQueryOptions } from "@api/queries";
import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
import { FeedDownloadTypeOptions } from "@domain/constants";
import { feedKeys } from "@screens/settings/Feed";
import { indexerKeys } from "@screens/settings/Indexer";
import { DocsLink } from "@components/ExternalLink";
import * as common from "@components/inputs/common";
@ -263,17 +263,14 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
const [indexer, setIndexer] = useState<IndexerDefinition>({} as IndexerDefinition);
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ["indexerDefinition"],
queryFn: APIClient.indexers.getSchema,
enabled: isOpen,
refetchOnWindowFocus: false
});
const { data } = useQuery(IndexersSchemaQueryOptions(isOpen));
const mutation = useMutation({
mutationFn: (indexer: Indexer) => APIClient.indexers.create(indexer),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.options() });
queryClient.invalidateQueries({ queryKey: ReleaseKeys.indexers() });
toast.custom((t) => <Toast type="success" body="Indexer was added" t={t} />);
sleep(1500);
@ -291,7 +288,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
const feedMutation = useMutation({
mutationFn: (feed: FeedCreate) => APIClient.feeds.create(feed),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
}
});
@ -738,7 +735,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
const mutation = useMutation({
mutationFn: (indexer: Indexer) => APIClient.indexers.update(indexer),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t} />);
sleep(1500);
@ -755,7 +752,9 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.indexers.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.options() });
queryClient.invalidateQueries({ queryKey: ReleaseKeys.indexers() });
toast.custom((t) => <Toast type="success" body={`${indexer.name} was deleted.`} t={t} />);

View file

@ -14,8 +14,8 @@ import Select from "react-select";
import { Dialog } from "@headlessui/react";
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
import { ircKeys } from "@screens/settings/Irc";
import { APIClient } from "@api/APIClient";
import { IrcKeys } from "@api/query_keys";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast";
@ -132,7 +132,7 @@ export function IrcNetworkAddForm({ isOpen, toggle }: AddFormProps) {
const mutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.createNetwork(network),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom((t) => <Toast type="success" body="IRC Network added. Please allow up to 30 seconds for the network to come online." t={t} />);
toggle();
@ -288,7 +288,7 @@ export function IrcNetworkUpdateForm({
const updateMutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${network.name} was updated successfully`} t={t} />);
@ -301,7 +301,7 @@ export function IrcNetworkUpdateForm({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${network.name} was deleted.`} t={t} />);

View file

@ -13,7 +13,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { notificationKeys } from "@screens/settings/Notifications";
import { NotificationKeys } from "@api/query_keys";
import { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/constants";
import { DEBUG } from "@components/debug";
import { SlideOver } from "@components/panels";
@ -294,7 +294,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
const createMutation = useMutation({
mutationFn: (notification: ServiceNotification) => APIClient.notifications.create(notification),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
toast.custom((t) => <Toast type="success" body="Notification added!" t={t} />);
toggle();
@ -565,7 +565,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
const mutation = useMutation({
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${notification.name} was updated successfully`} t={t}/>);
toggle();
@ -577,7 +577,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
const deleteMutation = useMutation({
mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${notification.name} was deleted.`} t={t}/>);
}

377
web/src/routes.tsx Normal file
View file

@ -0,0 +1,377 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import {
createRootRouteWithContext,
createRoute,
createRouter,
ErrorComponent,
notFound,
Outlet,
redirect,
} from "@tanstack/react-router";
import { z } from "zod";
import { QueryClient } from "@tanstack/react-query";
import { Actions, Advanced, External, General, MoviesTv, Music } from "@screens/filters/sections";
import { APIClient } from "@api/APIClient";
import { Login, Onboarding } from "@screens/auth";
import ReleaseSettings from "@screens/settings/Releases";
import { NotFound } from "@components/alerts/NotFound";
import { FilterDetails, FilterNotFound, Filters } from "@screens/filters";
import { Settings } from "@screens/Settings";
import {
ApikeysQueryOptions,
ConfigQueryOptions,
DownloadClientsQueryOptions,
FeedsQueryOptions,
FilterByIdQueryOptions,
IndexersQueryOptions,
IrcQueryOptions,
NotificationsQueryOptions
} from "@api/queries";
import LogSettings from "@screens/settings/Logs";
import NotificationSettings from "@screens/settings/Notifications";
import ApplicationSettings from "@screens/settings/Application";
import { Logs } from "@screens/Logs";
import IrcSettings from "@screens/settings/Irc";
import { Header } from "@components/header";
import { RingResizeSpinner } from "@components/Icons";
import APISettings from "@screens/settings/Api";
import { Releases } from "@screens/Releases";
import IndexerSettings from "@screens/settings/Indexer";
import DownloadClientSettings from "@screens/settings/DownloadClient";
import FeedSettings from "@screens/settings/Feed";
import { Dashboard } from "@screens/Dashboard";
import AccountSettings from "@screens/settings/Account";
import { AuthContext, AuthCtx, localStorageUserKey, SettingsContext } from "@utils/Context";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@api/QueryClient";
const DashboardRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: '/',
loader: () => {
// https://tanstack.com/router/v1/docs/guide/deferred-data-loading#deferred-data-loading-with-defer-and-await
// TODO load stats
// TODO load recent releases
return {}
},
component: Dashboard,
});
const FiltersRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'filters'
});
const FilterIndexRoute = createRoute({
getParentRoute: () => FiltersRoute,
path: '/',
component: Filters,
});
export const FilterGetByIdRoute = createRoute({
getParentRoute: () => FiltersRoute,
path: '$filterId',
parseParams: (params) => ({
filterId: z.number().int().parse(Number(params.filterId)),
}),
stringifyParams: ({filterId}) => ({filterId: `${filterId}`}),
loader: async ({context, params}) => {
try {
const filter = await context.queryClient.ensureQueryData(FilterByIdQueryOptions(params.filterId))
return { filter }
} catch (e) {
throw notFound()
}
},
component: FilterDetails,
notFoundComponent: () => {
return <FilterNotFound />
},
});
export const FilterGeneralRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: '/',
component: General
});
export const FilterMoviesTvRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'movies-tv',
component: MoviesTv
});
export const FilterMusicRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'music',
component: Music
});
export const FilterAdvancedRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'advanced',
component: Advanced
});
export const FilterExternalRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'external',
component: External
});
export const FilterActionsRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'actions',
component: Actions
});
const ReleasesRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'releases'
});
// type ReleasesSearch = z.infer<typeof releasesSearchSchema>
export const ReleasesIndexRoute = createRoute({
getParentRoute: () => ReleasesRoute,
path: '/',
component: Releases,
validateSearch: (search) => z.object({
offset: z.number().optional(),
limit: z.number().optional(),
filter: z.string().optional(),
q: z.string().optional(),
action_status: z.enum(['PUSH_APPROVED', 'PUSH_REJECTED', 'PUSH_ERROR', '']).optional(),
// filters: z.array().catch(''),
// sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
}).parse(search),
});
export const SettingsRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'settings',
pendingMs: 3000,
component: Settings
});
export const SettingsIndexRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: '/',
component: ApplicationSettings
});
export const SettingsLogRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'logs',
loader: (opts) => opts.context.queryClient.ensureQueryData(ConfigQueryOptions()),
component: LogSettings
});
export const SettingsIndexersRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'indexers',
loader: (opts) => opts.context.queryClient.ensureQueryData(IndexersQueryOptions()),
component: IndexerSettings
});
export const SettingsIrcRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'irc',
loader: (opts) => opts.context.queryClient.ensureQueryData(IrcQueryOptions()),
component: IrcSettings
});
export const SettingsFeedsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'feeds',
loader: (opts) => opts.context.queryClient.ensureQueryData(FeedsQueryOptions()),
component: FeedSettings
});
export const SettingsClientsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'clients',
loader: (opts) => opts.context.queryClient.ensureQueryData(DownloadClientsQueryOptions()),
component: DownloadClientSettings
});
export const SettingsNotificationsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'notifications',
loader: (opts) => opts.context.queryClient.ensureQueryData(NotificationsQueryOptions()),
component: NotificationSettings
});
export const SettingsApiRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'api',
loader: (opts) => opts.context.queryClient.ensureQueryData(ApikeysQueryOptions()),
component: APISettings
});
export const SettingsReleasesRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'releases',
component: ReleaseSettings
});
export const SettingsAccountRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'account',
component: AccountSettings
});
export const LogsRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'logs',
component: Logs
});
export const OnboardRoute = createRoute({
getParentRoute: () => RootRoute,
path: 'onboard',
beforeLoad: async () => {
// Check if onboarding is available for this instance
// and redirect if needed
try {
await APIClient.auth.canOnboard()
} catch (e) {
console.error("onboarding not available, redirect to login")
throw redirect({
to: LoginRoute.to,
})
}
},
component: Onboarding
});
export const LoginRoute = createRoute({
getParentRoute: () => RootRoute,
path: 'login',
validateSearch: z.object({
redirect: z.string().optional(),
}),
beforeLoad: ({ navigate}) => {
// handle canOnboard
APIClient.auth.canOnboard().then(() => {
console.info("onboarding available, redirecting")
navigate({ to: OnboardRoute.to })
}).catch(() => {
console.info("onboarding not available, please login")
})
},
}).update({component: Login});
export const AuthRoute = createRoute({
getParentRoute: () => RootRoute,
id: 'auth',
// Before loading, authenticate the user via our auth context
// This will also happen during prefetching (e.g. hovering over links, etc.)
beforeLoad: ({context, location}) => {
// If the user is not logged in, check for item in localStorage
if (!context.auth.isLoggedIn) {
const storage = localStorage.getItem(localStorageUserKey);
if (storage) {
try {
const json = JSON.parse(storage);
if (json === null) {
console.warn(`JSON localStorage value for '${localStorageUserKey}' context state is null`);
} else {
context.auth.isLoggedIn = json.isLoggedIn
context.auth.username = json.username
}
} catch (e) {
console.error(`auth Failed to merge ${localStorageUserKey} context state: ${e}`);
}
} else {
// If the user is logged out, redirect them to the login page
throw redirect({
to: LoginRoute.to,
search: {
// Use the current location to power a redirect after login
// (Do not use `router.state.resolvedLocation` as it can
// potentially lag behind the actual current location)
redirect: location.href,
},
})
}
}
// Otherwise, return the user in context
return {
username: AuthContext.username,
}
},
})
function AuthenticatedLayout() {
return (
<div className="min-h-screen">
<Header/>
<Outlet/>
</div>
)
}
export const AuthIndexRoute = createRoute({
getParentRoute: () => AuthRoute,
component: AuthenticatedLayout,
id: 'authenticated-routes',
});
export const RootComponent = () => {
const settings = SettingsContext.useValue();
return (
<div className="min-h-screen">
<Outlet/>
{settings.debug ? (
<>
<TanStackRouterDevtools/>
<ReactQueryDevtools initialIsOpen={false}/>
</>
) : null}
</div>
)
}
export const RootRoute = createRootRouteWithContext<{
auth: AuthCtx,
queryClient: QueryClient
}>()({
component: RootComponent,
notFoundComponent: NotFound,
});
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsReleasesRoute, SettingsAccountRoute])
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute.addChildren([ReleasesIndexRoute]), settingsRouteTree, LogsRoute])])
const routeTree = RootRoute.addChildren([
authenticatedTree,
LoginRoute,
OnboardRoute
]);
export const Router = createRouter({
routeTree,
defaultPendingComponent: () => (
<div className="absolute top-1/4 left-1/2 !border-0">
<RingResizeSpinner className="text-blue-500 size-24"/>
</div>
),
defaultErrorComponent: ({error}) => <ErrorComponent error={error}/>,
context: {
auth: undefined!, // We'll inject this when we render
queryClient
},
});

View file

@ -23,7 +23,6 @@ import { EmptySimple } from "@components/emptystates";
import { RingResizeSpinner } from "@components/Icons";
import Toast from "@components/notifications/Toast";
type LogEvent = {
time: string;
level: string;
@ -182,7 +181,7 @@ export const LogFiles = () => {
});
if (isError) {
console.log(error);
console.log("could not load log files", error);
}
return (
@ -194,7 +193,7 @@ export const LogFiles = () => {
</p>
</div>
{data && data.files.length > 0 ? (
{data && data.files && data.files.length > 0 ? (
<ul className="py-3 min-w-full relative">
<li className="grid grid-cols-12 mb-2 border-b border-gray-200 dark:border-gray-700">
<div className="hidden sm:block col-span-5 px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">

View file

@ -3,8 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
BellIcon,
ChatBubbleLeftRightIcon,
@ -16,25 +14,26 @@ import {
Square3Stack3DIcon,
UserCircleIcon
} from "@heroicons/react/24/outline";
import { Link, Outlet } from "@tanstack/react-router";
import { classNames } from "@utils";
import { SectionLoader } from "@components/SectionLoader";
interface NavTabType {
name: string;
href: string;
icon: typeof CogIcon;
exact?: boolean;
}
const subNavigation: NavTabType[] = [
{ name: "Application", href: "", icon: CogIcon },
{ name: "Application", href: ".", icon: CogIcon, exact: true },
{ name: "Logs", href: "logs", icon: Square3Stack3DIcon },
{ name: "Indexers", href: "indexers", icon: KeyIcon },
{ name: "IRC", href: "irc", icon: ChatBubbleLeftRightIcon },
{ name: "Feeds", href: "feeds", icon: RssIcon },
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
{ name: "Notifications", href: "notifications", icon: BellIcon },
{ name: "API keys", href: "api-keys", icon: KeyIcon },
{ name: "API keys", href: "api", icon: KeyIcon },
{ name: "Releases", href: "releases", icon: RectangleStackIcon },
{ name: "Account", href: "account", icon: UserCircleIcon }
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
@ -46,29 +45,38 @@ interface NavLinkProps {
}
function SubNavLink({ item }: NavLinkProps) {
const { pathname } = useLocation();
const splitLocation = pathname.split("/");
// const { pathname } = useLocation();
// const splitLocation = pathname.split("/");
// we need to clean the / if it's a base root path
return (
<NavLink
key={item.name}
<Link
key={item.href}
to={item.href}
end
className={({ isActive }) => classNames(
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive
? "font-bold bg-blue-100 dark:bg-gray-700 border-sky-500 dark:border-blue-500 text-sky-700 dark:text-gray-200 hover:bg-blue-200 dark:hover:bg-gray-600 hover:text-sky-900 dark:hover:text-white"
: "border-transparent text-gray-900 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-gray-300"
)}
aria-current={splitLocation[2] === item.href ? "page" : undefined}
activeOptions={{ exact: item.exact }}
search={{}}
params={{}}
// aria-current={splitLocation[2] === item.href ? "page" : undefined}
>
<item.icon
className="text-gray-500 dark:text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</NavLink>
{({ isActive }) => {
return (
<span className={
classNames(
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive
? "font-bold bg-blue-100 dark:bg-gray-700 border-sky-500 dark:border-blue-500 text-sky-700 dark:text-gray-200 hover:bg-blue-200 dark:hover:bg-gray-600 hover:text-sky-900 dark:hover:text-white"
: "border-transparent text-gray-900 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-gray-300"
)
}>
<item.icon
className="text-gray-500 dark:text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</span>
)
}}
</Link>
);
}
@ -78,10 +86,10 @@ interface SidebarNavProps {
function SidebarNav({ subNavigation }: SidebarNavProps) {
return (
<aside className="py-2 lg:col-span-3">
<aside className="py-2 lg:col-span-3 border-b lg:border-b-0 lg:border-r border-gray-150 dark:border-gray-725">
<nav className="space-y-1">
{subNavigation.map((item) => (
<SubNavLink item={item} key={item.href} />
<SubNavLink key={item.href} item={item} />
))}
</nav>
</aside>
@ -97,17 +105,9 @@ export function Settings() {
<div className="max-w-screen-xl mx-auto pb-6 px-2 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-table border border-gray-250 dark:border-gray-775">
<div className="divide-y divide-gray-150 dark:divide-gray-725 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<div className="lg:grid lg:grid-cols-12">
<SidebarNav subNavigation={subNavigation}/>
<Suspense
fallback={
<div className="flex items-center justify-center lg:col-span-9">
<SectionLoader $size="large" />
</div>
}
>
<Outlet />
</Suspense>
</div>
</div>
</div>

View file

@ -3,19 +3,19 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useEffect } from "react";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { useRouter, useSearch } from "@tanstack/react-router";
import toast from "react-hot-toast";
import { RocketLaunchIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { AuthContext } from "@utils/Context";
import Toast from "@components/notifications/Toast";
import { Tooltip } from "@components/tooltips/Tooltip";
import { PasswordInput, TextInput } from "@components/inputs/text";
import { LoginRoute } from "@app/routes";
import Logo from "@app/logo.svg?react";
@ -25,35 +25,25 @@ type LoginFormFields = {
};
export const Login = () => {
const router = useRouter()
const { auth } = LoginRoute.useRouteContext()
const search = useSearch({ from: LoginRoute.id })
const { handleSubmit, register, formState } = useForm<LoginFormFields>({
defaultValues: { username: "", password: "" },
mode: "onBlur"
});
const navigate = useNavigate();
const [, setAuthContext] = AuthContext.use();
useEffect(() => {
// remove user session when visiting login page'
APIClient.auth.logout()
.then(() => {
AuthContext.reset();
});
// Check if onboarding is available for this instance
// and redirect if needed
APIClient.auth.canOnboard()
.then(() => navigate("/onboard"))
.catch(() => { /*don't log to console PAHLLEEEASSSE*/ });
}, [navigate]);
// remove user session when visiting login page
auth.logout()
}, []);
const loginMutation = useMutation({
mutationFn: (data: LoginFormFields) => APIClient.auth.login(data.username, data.password),
onSuccess: (_, variables: LoginFormFields) => {
setAuthContext({
username: variables.username,
isLoggedIn: true
});
navigate("/");
auth.login(variables.username)
router.invalidate()
},
onError: () => {
toast.custom((t) => (
@ -64,6 +54,14 @@ export const Login = () => {
const onSubmit = (data: LoginFormFields) => loginMutation.mutate(data);
React.useLayoutEffect(() => {
if (auth.isLoggedIn && search.redirect) {
router.history.push(search.redirect)
} else if (auth.isLoggedIn) {
router.history.push("/")
}
}, [auth.isLoggedIn, search.redirect])
return (
<div className="min-h-screen flex flex-col justify-center px-3">
<div className="mx-auto w-full max-w-md mb-6">

View file

@ -5,7 +5,7 @@
import { Form, Formik } from "formik";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {useNavigate} from "@tanstack/react-router";
import { APIClient } from "@api/APIClient";
import { TextField, PasswordField } from "@components/inputs";
@ -43,7 +43,7 @@ export const Onboarding = () => {
const mutation = useMutation({
mutationFn: (data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
onSuccess: () => navigate("/")
onSuccess: () => navigate({ to: "/" })
});
return (

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import React, { useState } from "react";
import React, { Suspense, useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
useTable,
@ -12,13 +12,14 @@ import {
useSortBy,
usePagination, FilterProps, Column
} from "react-table";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { EmptyListState } from "@components/emptystates";
import * as Icons from "@components/Icons";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import * as DataTable from "@components/data-table";
import { RandomLinuxIsos } from "@utils";
import { RingResizeSpinner } from "@components/Icons";
import { ReleasesLatestQueryOptions } from "@api/queries";
// This is a custom filter UI for selecting
// a unique option from a list
@ -80,8 +81,14 @@ function Table({ columns, data }: TableProps) {
usePagination
);
if (!page.length) {
return <EmptyListState text="No recent activity" />;
if (data.length === 0) {
return (
<div className="mt-4 mb-2 bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<div className="flex items-center justify-center py-16">
<EmptyListState text="No recent activity"/>
</div>
</div>
)
}
// Render the UI for your table
@ -159,6 +166,28 @@ function Table({ columns, data }: TableProps) {
);
}
export const RecentActivityTable = () => {
return (
<div className="flex flex-col mt-12">
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
Recent activity
</h3>
<div className="animate-pulse text-black dark:text-white">
<Suspense
fallback={
<div className="flex items-center justify-center lg:col-span-9">
<RingResizeSpinner className="text-blue-500 size-12" />
</div>
}
>
{/*<EmptyListState text="Loading..."/>*/}
<ActivityTableContent />
</Suspense>
</div>
</div>
)
}
export const ActivityTable = () => {
const columns = React.useMemo(() => [
{
@ -185,11 +214,7 @@ export const ActivityTable = () => {
}
] as Column[], []);
const { isLoading, data } = useSuspenseQuery({
queryKey: ["dash_recent_releases"],
queryFn: APIClient.release.findRecent,
refetchOnWindowFocus: false
});
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
@ -198,7 +223,7 @@ export const ActivityTable = () => {
return (
<div className="flex flex-col mt-12">
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
&nbsp;
Recent activity
</h3>
<div className="animate-pulse text-black dark:text-white">
<EmptyListState text="Loading..."/>
@ -245,3 +270,75 @@ export const ActivityTable = () => {
</div>
);
};
export const ActivityTableContent = () => {
const columns = React.useMemo(() => [
{
Header: "Age",
accessor: "timestamp",
Cell: DataTable.AgeCell
},
{
Header: "Release",
accessor: "name",
Cell: DataTable.TitleCell
},
{
Header: "Actions",
accessor: "action_status",
Cell: DataTable.ReleaseStatusCell
},
{
Header: "Indexer",
accessor: "indexer",
Cell: DataTable.TitleCell,
Filter: SelectColumnFilter,
filter: "includes"
}
] as Column[], []);
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
if (isLoading) {
return (
<EmptyListState text="Loading..."/>
);
}
const toggleReleaseNames = () => {
setShowLinuxIsos(!showLinuxIsos);
if (!showLinuxIsos && data && data.data) {
const randomNames = RandomLinuxIsos(data.data.length);
const newData: Release[] = data.data.map((item, index) => ({
...item,
name: `${randomNames[index]}.iso`,
indexer: index % 2 === 0 ? "distrowatch" : "linuxtracker"
}));
setModifiedData(newData);
}
};
const displayData = showLinuxIsos ? modifiedData : (data?.data ?? []);
return (
<>
<Table columns={columns} data={displayData} />
<button
onClick={toggleReleaseNames}
className="p-2 absolute -bottom-8 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeSlashIcon className="h-4 w-4" />
)}
</button>
</>
);
};

View file

@ -3,23 +3,28 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useSuspenseQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { useQuery} from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { classNames } from "@utils";
import { useNavigate } from "react-router-dom";
import { LinkIcon } from "@heroicons/react/24/solid";
import { ReleasesStatsQueryOptions } from "@api/queries";
interface StatsItemProps {
name: string;
value?: number;
placeholder?: string;
onClick?: () => void;
name: string;
value?: number;
placeholder?: string;
to?: string;
eventType?: string;
}
const StatsItem = ({ name, placeholder, value, onClick }: StatsItemProps) => (
<div
const StatsItem = ({ name, placeholder, value, to, eventType }: StatsItemProps) => (
<Link
className="group relative px-4 py-3 cursor-pointer overflow-hidden rounded-lg shadow-lg bg-white dark:bg-gray-800 hover:scale-110 hover:shadow-xl transition-all duration-200 ease-in-out"
onClick={onClick}
to={to}
search={{
action_status: eventType
}}
params={{}}
>
<dt>
<div className="flex items-center text-sm font-medium text-gray-500 group-hover:dark:text-gray-475 group-hover:text-gray-600 transition-colors duration-200 ease-in-out">
@ -36,24 +41,11 @@ const StatsItem = ({ name, placeholder, value, onClick }: StatsItemProps) => (
<p>{value}</p>
</dd>
</div>
</div>
</Link>
);
export const Stats = () => {
const navigate = useNavigate();
const handleStatClick = (filterType: string) => {
if (filterType) {
navigate(`/releases?filter=${filterType}`);
} else {
navigate("/releases");
}
};
const { isLoading, data } = useSuspenseQuery({
queryKey: ["dash_release_stats"],
queryFn: APIClient.release.stats,
refetchOnWindowFocus: false
});
const { isLoading, data } = useQuery(ReleasesStatsQueryOptions());
return (
<div>
@ -62,11 +54,11 @@ export const Stats = () => {
</h1>
<dl className={classNames("grid grid-cols-2 gap-2 sm:gap-5 mt-5 sm:grid-cols-2 lg:grid-cols-4", isLoading ? "animate-pulse" : "")}>
<StatsItem name="Filtered Releases" onClick={() => handleStatClick("")} value={data?.filtered_count ?? 0} />
<StatsItem name="Filtered Releases" to="/releases" value={data?.filtered_count ?? 0} />
{/* <StatsItem name="Filter Rejected Releases" stat={data?.filter_rejected_count} /> */}
<StatsItem name="Approved Pushes" onClick={() => handleStatClick("PUSH_APPROVED")} value={data?.push_approved_count ?? 0} />
<StatsItem name="Rejected Pushes" onClick={() => handleStatClick("PUSH_REJECTED")} value={data?.push_rejected_count ?? 0 } />
<StatsItem name="Errored Pushes" onClick={() => handleStatClick("PUSH_ERROR")} value={data?.push_error_count ?? 0} />
<StatsItem name="Approved Pushes" to="/releases" eventType="PUSH_APPROVED" value={data?.push_approved_count ?? 0} />
<StatsItem name="Rejected Pushes" to="/releases" eventType="PUSH_REJECTED" value={data?.push_rejected_count ?? 0 } />
<StatsItem name="Errored Pushes" to="/releases" eventType="PUSH_ERROR" value={data?.push_error_count ?? 0} />
</dl>
</div>
);

View file

@ -3,17 +3,18 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense, useEffect, useRef } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Form, Formik, useFormikContext } from "formik";
import type { FormikErrors, FormikValues } from "formik";
import { z } from "zod";
import { toast } from "react-hot-toast";
import { toFormikValidationSchema } from "zod-formik-adapter";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { APIClient } from "@api/APIClient";
import { FilterByIdQueryOptions } from "@api/queries";
import { FilterKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { DOWNLOAD_CLIENTS } from "@domain/constants";
@ -21,18 +22,18 @@ import { DOWNLOAD_CLIENTS } from "@domain/constants";
import { DEBUG } from "@components/debug";
import Toast from "@components/notifications/Toast";
import { DeleteModal } from "@components/modals";
import { SectionLoader } from "@components/SectionLoader";
import { filterKeys } from "./List";
import * as Section from "./sections";
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import { FilterGetByIdRoute } from "@app/routes";
interface tabType {
name: string;
href: string;
exact?: boolean;
}
const tabs: tabType[] = [
{ name: "General", href: "" },
{ name: "General", href: ".", exact: true },
{ name: "Movies and TV", href: "movies-tv" },
{ name: "Music", href: "music" },
{ name: "Advanced", href: "advanced" },
@ -45,25 +46,35 @@ export interface NavLinkProps {
}
function TabNavLink({ item }: NavLinkProps) {
const location = useLocation();
const splitLocation = location.pathname.split("/");
// const location = useLocation();
// const splitLocation = location.pathname.split("/");
// we need to clean the / if it's a base root path
return (
<NavLink
key={item.name}
<Link
to={item.href}
end
className={({ isActive }) => classNames(
"transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg",
isActive
? "text-blue-600 dark:text-white border-blue-600 dark:border-blue-500"
: "text-gray-550 hover:text-blue-500 dark:hover:text-white border-transparent"
)}
aria-current={splitLocation[2] === item.href ? "page" : undefined}
activeOptions={{ exact: item.exact }}
search={{}}
params={{}}
// aria-current={splitLocation[2] === item.href ? "page" : undefined}
// className="transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg"
>
{item.name}
</NavLink>
{({ isActive }) => {
return (
<span
className={
classNames(
"transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg",
isActive
? "text-blue-600 dark:text-white border-blue-600 dark:border-blue-500"
: "text-gray-550 hover:text-blue-500 dark:hover:text-white border-transparent"
)
}>
{item.name}
</span>
)
}}
</Link>
);
}
@ -281,32 +292,20 @@ const schema = z.object({
});
export const FilterDetails = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { filterId } = useParams<{ filterId: string }>();
const ctx = FilterGetByIdRoute.useRouteContext()
const queryClient = ctx.queryClient
if (filterId === "0" || filterId === undefined) {
navigate("/filters");
}
const id = parseInt(filterId!);
const { isLoading, isError, data: filter } = useSuspenseQuery({
queryKey: filterKeys.detail(id),
queryFn: ({ queryKey }) => APIClient.filters.getByID(queryKey[2]),
refetchOnWindowFocus: false
});
if (isError) {
navigate("/filters");
}
const params = FilterGetByIdRoute.useParams()
const filterQuery = useSuspenseQuery(FilterByIdQueryOptions(params.filterId))
const filter = filterQuery.data
const updateMutation = useMutation({
mutationFn: (filter: Filter) => APIClient.filters.update(filter),
onSuccess: (newFilter, variables) => {
queryClient.setQueryData(filterKeys.detail(variables.id), newFilter);
queryClient.setQueryData(FilterKeys.detail(variables.id), newFilter);
queryClient.setQueryData<Filter[]>(filterKeys.lists(), (previous) => {
queryClient.setQueryData<Filter[]>(FilterKeys.lists(), (previous) => {
if (previous) {
return previous.map((filter: Filter) => (filter.id === variables.id ? newFilter : filter));
}
@ -322,22 +321,18 @@ export const FilterDetails = () => {
mutationFn: (id: number) => APIClient.filters.delete(id),
onSuccess: () => {
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.removeQueries({ queryKey: FilterKeys.detail(params.filterId) });
toast.custom((t) => (
<Toast type="success" body={`${filter?.name} was deleted`} t={t} />
));
// redirect
navigate("/filters");
navigate({ to: "/filters" });
}
});
if (!filter) {
return null;
}
const handleSubmit = (data: Filter) => {
// force set method and type on webhook actions
// TODO add options for these
@ -362,9 +357,9 @@ export const FilterDetails = () => {
<main>
<div className="my-6 max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center text-black dark:text-white">
<h1 className="text-3xl font-bold">
<NavLink to="/filters">
<Link to="/filters">
Filters
</NavLink>
</Link>
</h1>
<ChevronRightIcon className="h-6 w-4 shrink-0 sm:shrink sm:h-6 sm:w-6 mx-1" aria-hidden="true" />
<h1 className="text-3xl font-bold truncate" title={filter.name}>{filter.name}</h1>
@ -372,9 +367,9 @@ export const FilterDetails = () => {
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-250 dark:border-gray-775">
<div className="rounded-t-lg bg-gray-125 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<nav className="px-4 -mb-px flex space-x-6 sm:space-x-8 overflow-x-auto">
<nav className="px-4 py-4 -mb-px flex space-x-6 sm:space-x-8 overflow-x-auto">
{tabs.map((tab) => (
<TabNavLink item={tab} key={tab.href} />
<TabNavLink key={tab.href} item={tab} />
))}
</nav>
</div>
@ -452,22 +447,13 @@ export const FilterDetails = () => {
{({ values, dirty, resetForm }) => (
<Form className="pt-1 pb-4 px-5">
<FormErrorNotification />
<Suspense fallback={<SectionLoader $size="large" />}>
<Routes>
<Route index element={<Section.General />} />
<Route path="movies-tv" element={<Section.MoviesTv />} />
<Route path="music" element={<Section.Music values={values} />} />
<Route path="advanced" element={<Section.Advanced values={values} />} />
<Route path="external" element={<Section.External />} />
<Route path="actions" element={<Section.Actions filter={filter} values={values} />} />
</Routes>
</Suspense>
<Outlet />
<FormButtonsGroup
values={values}
deleteAction={deleteAction}
dirty={dirty}
reset={resetForm}
isLoading={isLoading}
isLoading={false}
/>
<DEBUG values={values} />
</Form>

View file

@ -4,9 +4,9 @@ import { useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { filterKeys } from "./List";
import { AutodlIrssiConfigParser } from "./_configParser";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
@ -211,7 +211,7 @@ export const Importer = ({
} finally {
setIsOpen(false);
// Invalidate filter cache, and trigger refresh request
await queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
await queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
}
};

View file

@ -3,24 +3,23 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Dispatch, FC, Fragment, MouseEventHandler, useReducer, useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { Dispatch, FC, Fragment, MouseEventHandler, useCallback, useEffect, useReducer, useRef, useState } from "react";
import { Link } from '@tanstack/react-router'
import { toast } from "react-hot-toast";
import { Listbox, Menu, Transition } from "@headlessui/react";
import { useMutation, useQuery, useQueryClient, keepPreviousData, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormikValues } from "formik";
import { useCallback } from "react";
import {
ArrowsRightLeftIcon,
ArrowUpOnSquareIcon,
ChatBubbleBottomCenterTextIcon,
CheckIcon,
ChevronDownIcon,
PlusIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
ChatBubbleBottomCenterTextIcon,
TrashIcon,
ArrowUpOnSquareIcon
PlusIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
@ -29,6 +28,8 @@ import { classNames } from "@utils";
import { FilterAddForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import { FiltersQueryOptions, IndexersOptionsQueryOptions } from "@api/queries";
import Toast from "@components/notifications/Toast";
import { EmptyListState } from "@components/emptystates";
import { DeleteModal } from "@components/modals";
@ -37,14 +38,6 @@ import { Importer } from "./Importer";
import { Tooltip } from "@components/tooltips/Tooltip";
import { Checkbox } from "@components/Checkbox";
export const filterKeys = {
all: ["filters"] as const,
lists: () => [...filterKeys.all, "list"] as const,
list: (indexers: string[], sortOrder: string) => [...filterKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...filterKeys.all, "detail"] as const,
detail: (id: number) => [...filterKeys.details(), id] as const
};
enum ActionType {
INDEXER_FILTER_CHANGE = "INDEXER_FILTER_CHANGE",
INDEXER_FILTER_RESET = "INDEXER_FILTER_RESET",
@ -192,11 +185,7 @@ function FilterList({ toggleCreateFilter }: any) {
filterListState
);
const { data, error } = useSuspenseQuery({
queryKey: filterKeys.list(indexerFilter, sortOrder),
queryFn: ({ queryKey }) => APIClient.filters.find(queryKey[2].indexers, queryKey[2].sortOrder),
refetchOnWindowFocus: false
});
const { data, error } = useQuery(FiltersQueryOptions(indexerFilter, sortOrder));
useEffect(() => {
FilterListContext.set({ indexerFilter, sortOrder, status });
@ -407,8 +396,8 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.filters.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} was deleted`} t={t} />);
}
@ -417,7 +406,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
const duplicateMutation = useMutation({
mutationFn: (id: number) => APIClient.filters.duplicate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} duplicated`} t={t} />);
}
@ -459,7 +448,11 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
<Menu.Item>
{({ active }) => (
<Link
to={filter.id.toString()}
// to={filter.id.toString()}
to="/filters/$filterId"
params={{
filterId: filter.id
}}
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
@ -600,8 +593,8 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
// We need to invalidate both keys here.
// The filters key is used on the /filters page,
// while the ["filter", filter.id] key is used on the details page.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
}
});
@ -629,7 +622,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</span>
<div className="py-2 flex flex-col overflow-hidden w-full justify-center">
<Link
to={filter.id.toString()}
to="/filters/$filterId"
params={{
filterId: filter.id
}}
className="transition w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
>
{filter.name}
@ -645,7 +641,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
<Tooltip
label={
<Link
to={`${filter.id.toString()}/actions`}
to="/filters/$filterId/actions"
params={{
filterId: filter.id
}}
className="flex items-center cursor-pointer hover:text-black dark:hover:text-gray-300"
>
<span className={filter.actions_count === 0 || filter.actions_enabled_count === 0 ? "text-red-500 hover:text-red-400 dark:hover:text-red-400" : ""}>
@ -666,7 +665,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</Tooltip>
) : (
<Link
to={`${filter.id.toString()}/actions`}
to="/filters/$filterId/actions"
params={{
filterId: filter.id
}}
className="flex items-center cursor-pointer hover:text-black dark:hover:text-gray-300"
>
<span>
@ -784,12 +786,9 @@ const ListboxFilter = ({
// a unique option from a list
const IndexerSelectFilter = ({ dispatch }: any) => {
const { data, isSuccess } = useQuery({
queryKey: ["filters", "indexers_options"],
queryFn: () => APIClient.indexers.getOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});
const filterListState = FilterListContext.useValue();
const { data, isSuccess } = useQuery(IndexersOptionsQueryOptions());
const setFilter = (value: string) => {
if (value == undefined || value == "") {
@ -804,11 +803,11 @@ const IndexerSelectFilter = ({ dispatch }: any) => {
<ListboxFilter
id="1"
key="indexer-select"
label="Indexer"
currentValue={""}
label={data && filterListState.indexerFilter[0] ? `Indexer: ${data.find(i => i.identifier == filterListState.indexerFilter[0])?.name}` : "Indexer"}
currentValue={filterListState.indexerFilter[0] ?? ""}
onChange={setFilter}
>
<FilterOption label="All" />
<FilterOption label="All" value="" />
{isSuccess && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer.name} value={indexer.identifier} />
))}
@ -830,7 +829,7 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
value={value}
>
{({ selected }) => (
<>
<div className="flex justify-between">
<span
className={classNames(
"block truncate",
@ -840,16 +839,18 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
{label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400">
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null}
</>
</div>
)}
</Listbox.Option>
);
export const SortSelectFilter = ({ dispatch }: any) => {
const filterListState = FilterListContext.useValue();
const setFilter = (value: string) => {
if (value == undefined || value == "") {
dispatch({ type: ActionType.SORT_ORDER_RESET, payload: "" });
@ -870,8 +871,8 @@ export const SortSelectFilter = ({ dispatch }: any) => {
<ListboxFilter
id="sort"
key="sort-select"
label="Sort"
currentValue={""}
label={filterListState.sortOrder ? `Sort: ${options.find(o => o.value == filterListState.sortOrder)?.label}` : "Sort"}
currentValue={filterListState.sortOrder ?? ""}
onChange={setFilter}
>
<>

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link } from "@tanstack/react-router";
import { FilterGetByIdRoute } from "@app/routes";
import { ExternalLink } from "@components/ExternalLink";
import Logo from "@app/logo.svg?react";
export const FilterNotFound = () => {
const { filterId } = FilterGetByIdRoute.useParams()
return (
<div className="mt-20 flex flex-col justify-center">
<div className="flex justify-center">
<Logo className="h-24 sm:h-48"/>
</div>
<h2 className="text-2xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Status 404
</h2>
<h1 className="text-3xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Filter with id <span className="text-blue-600 dark:text-blue-500">{filterId}</span> not found!
</h1>
<h3 className="text-xl text-center text-gray-700 dark:text-gray-400 mb-1 px-2">
In case you think this is a bug rather than too much brr,
</h3>
<h3 className="text-xl text-center text-gray-700 dark:text-gray-400 mb-1 px-2">
feel free to report this to our
{" "}
<ExternalLink
href="https://github.com/autobrr/autobrr"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-sky-500 hover:decoration-2 hover:text-black hover:dark:text-gray-100"
>
GitHub page
</ExternalLink>
{" or to "}
<ExternalLink
href="https://discord.gg/WQ2eUycxyT"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-purple-500 hover:decoration-2 hover:text-black hover:dark:text-gray-100"
>
our official Discord channel
</ExternalLink>
.
</h3>
<h3 className="text-xl text-center leading-6 text-gray-700 dark:text-gray-400 mb-8 px-2">
Otherwise, let us help you to get you back on track for more brr!
</h3>
<div className="flex justify-center">
<Link to="/filters">
<button
className="w-48 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
>
Back to filters
</button>
</Link>
</div>
</div>
);
};

View file

@ -5,3 +5,4 @@
export { Filters } from "./List";
export { FilterDetails } from "./Details";
export { FilterNotFound } from "./NotFound";

View file

@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Field, FieldArray, useFormikContext } from "formik";
import type { FieldProps, FieldArrayRenderProps, FormikValues } from "formik";
import type { FieldProps, FieldArrayRenderProps } from "formik";
import { ChevronRightIcon, BoltIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
@ -25,18 +25,17 @@ import { TitleSubtitle } from "@components/headings";
import * as FilterSection from "./_components";
import * as FilterActions from "./action_components";
import { DownloadClientsQueryOptions } from "@api/queries";
interface FilterActionsProps {
filter: Filter;
values: FormikValues;
}
// interface FilterActionsProps {
// filter: Filter;
// values: FormikValues;
// }
export function Actions({ filter, values }: FilterActionsProps) {
const { data } = useQuery({
queryKey: ["filters", "download_clients"],
queryFn: () => APIClient.download_clients.getAll(),
refetchOnWindowFocus: false
});
export function Actions() {
const { values } = useFormikContext<Filter>();
const { data } = useQuery(DownloadClientsQueryOptions());
const newAction: Action = {
id: 0,
@ -63,7 +62,7 @@ export function Actions({ filter, values }: FilterActionsProps) {
reannounce_delete: false,
reannounce_interval: 7,
reannounce_max_attempts: 25,
filter_id: filter.id,
filter_id: values.id,
webhook_host: "",
webhook_type: "",
webhook_method: "",

View file

@ -1,4 +1,4 @@
import type { FormikValues } from "formik";
import { useFormikContext } from "formik";
import { DocsLink } from "@components/ExternalLink";
import { WarningAlert } from "@components/alerts";
@ -10,493 +10,533 @@ import { CollapsibleSection } from "./_components";
import * as Components from "./_components";
import { classNames } from "@utils";
type ValueConsumer = {
values: FormikValues;
};
// type ValueConsumer = {
// values: FormikValues;
// };
const Releases = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex || values.match_releases || values.except_releases}
title="Release Names"
subtitle="Match only certain release names and/or ignore other release names."
>
<Components.Layout>
<Components.HalfRow>
<Input.SwitchGroup name="use_regex" label="Use Regex" className="pt-2" />
</Components.HalfRow>
</Components.Layout>
const Releases = () => {
const { values } = useFormikContext<Filter>();
<Components.Layout>
<Components.HalfRow>
<Input.RegexTextAreaField
name="match_releases"
label="Match releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
return (
<CollapsibleSection
//defaultOpen={values.use_regex || values.match_releases !== "" || values.except_releases !== ""}
title="Release Names"
subtitle="Match only certain release names and/or ignore other release names."
>
<Components.Layout>
<Components.HalfRow>
<Input.SwitchGroup name="use_regex" label="Use Regex" className="pt-2" />
</Components.HalfRow>
</Components.Layout>
<Components.Layout>
<Components.HalfRow>
<Input.RegexTextAreaField
name="match_releases"
label="Match releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
<Components.HalfRow>
<Input.RegexTextAreaField
name="except_releases"
label="Except releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
</Components.Layout>
{values.match_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Match releases</strong> instead of one of the other tabs?
</>
}
colors="text-cyan-700 bg-cyan-100 dark:bg-cyan-200 dark:text-cyan-800"
/>
) : null}
{values.except_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Except releases</strong> instead of one of the other tabs?
</>
}
colors="text-fuchsia-700 bg-fuchsia-100 dark:bg-fuchsia-200 dark:text-fuchsia-800"
/>
) : null}
</CollapsibleSection>
);
}
const Groups = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.match_release_groups !== "" || values.except_release_groups !== ""}
title="Groups"
subtitle="Match only certain groups and/or ignore other groups."
>
<Input.TextAreaAutoResize
name="match_release_groups"
label="Match release groups"
columns={6}
placeholder="eg. group1,group2"
tooltip={
<div>
<p>Comma separated list of release groups to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_release_groups"
label="Except release groups"
columns={6}
placeholder="eg. badgroup1,badgroup2"
tooltip={
<div>
<p>Comma separated list of release groups to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
}
const Categories = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.match_categories.length >0 || values.except_categories !== ""}
title="Categories"
subtitle="Match or exclude categories (if announced)"
>
<Input.TextAreaAutoResize
name="match_categories"
label="Match categories"
columns={6}
placeholder="eg. *category*,category1"
tooltip={
<div>
<p>Comma separated list of categories to match.</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_categories"
label="Except categories"
columns={6}
placeholder="eg. *category*"
tooltip={
<div>
<p>Comma separated list of categories to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
</CollapsibleSection>
);
}
const Tags = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.tags !== "" || values.except_tags !== ""}
title="Tags"
subtitle="Match or exclude tags (if announced)"
>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="tags"
label="Match tags"
columns={8}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<p>Comma separated list of tags to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
<Components.HalfRow>
<Input.RegexTextAreaField
name="except_releases"
label="Except releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
<Input.Select
name="tags_match_logic"
label="Match logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<p>Logic used to match filter tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
</div>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="except_tags"
label="Except tags"
columns={8}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>Comma separated list of tags to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="except_tags_match_logic"
label="Except logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>Logic used to match except tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
</CollapsibleSection>
);
}
</Components.Layout>
const Uploaders = () => {
// const { values } = useFormikContext<Filter>();
{values.match_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Match releases</strong> instead of one of the other tabs?
</>
}
colors="text-cyan-700 bg-cyan-100 dark:bg-cyan-200 dark:text-cyan-800"
/>
) : null}
{values.except_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Except releases</strong> instead of one of the other tabs?
</>
}
colors="text-fuchsia-700 bg-fuchsia-100 dark:bg-fuchsia-200 dark:text-fuchsia-800"
/>
) : null}
</CollapsibleSection>
);
const Groups = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.match_release_groups || values.except_release_groups}
title="Groups"
subtitle="Match only certain groups and/or ignore other groups."
>
<Input.TextAreaAutoResize
name="match_release_groups"
label="Match release groups"
columns={6}
placeholder="eg. group1,group2"
tooltip={
<div>
<p>Comma separated list of release groups to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_release_groups"
label="Except release groups"
columns={6}
placeholder="eg. badgroup1,badgroup2"
tooltip={
<div>
<p>Comma separated list of release groups to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
const Categories = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.match_categories || values.except_categories}
title="Categories"
subtitle="Match or exclude categories (if announced)"
>
<Input.TextAreaAutoResize
name="match_categories"
label="Match categories"
columns={6}
placeholder="eg. *category*,category1"
tooltip={
<div>
<p>Comma separated list of categories to match.</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_categories"
label="Except categories"
columns={6}
placeholder="eg. *category*"
tooltip={
<div>
<p>Comma separated list of categories to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
</CollapsibleSection>
);
const Tags = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.tags || values.except_tags}
title="Tags"
subtitle="Match or exclude tags (if announced)"
>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
return (
<CollapsibleSection
//defaultOpen={values.match_uploaders !== "" || values.except_uploaders !== ""}
title="Uploaders"
subtitle="Match or ignore uploaders (if announced)"
>
<Input.TextAreaAutoResize
name="tags"
label="Match tags"
columns={8}
placeholder="eg. tag1,tag2"
name="match_uploaders"
label="Match uploaders"
columns={6}
placeholder="eg. uploader1,uploader2"
tooltip={
<div>
<p>Comma separated list of tags to match.</p>
<p>Comma separated list of uploaders to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="tags_match_logic"
label="Match logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>Logic used to match filter tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="except_tags"
label="Except tags"
columns={8}
placeholder="eg. tag1,tag2"
name="except_uploaders"
label="Except uploaders"
columns={6}
placeholder="eg. anonymous1,anonymous2"
tooltip={
<div>
<p>Comma separated list of tags to ignore (takes priority over Match releases).</p>
<p>Comma separated list of uploaders to ignore (takes priority over Match releases).
</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="except_tags_match_logic"
label="Except logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
</CollapsibleSection>
);
}
const Language = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
title="Language"
subtitle="Match or ignore languages (if announced)"
>
<Input.MultiSelect
name="match_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Match Language"
columns={6}
/>
<Input.MultiSelect
name="except_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Except Language"
columns={6}
/>
</CollapsibleSection>
);
}
const Origins = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
title="Origins"
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
>
<Input.MultiSelect
name="origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Match Origins"
columns={6}
/>
<Input.MultiSelect
name="except_origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Except Origins"
columns={6}
/>
</CollapsibleSection>
);
}
const Freeleech = () => {
const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.freeleech || values.freeleech_percent !== ""}
title="Freeleech"
subtitle="Match based off freeleech (if announced)"
>
<Input.TextField
name="freeleech_percent"
label="Freeleech percent"
disabled={values.freeleech}
tooltip={
<div>
<p>Logic used to match except tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
</CollapsibleSection>
);
const Uploaders = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.uploaders || values.except_uploaders}
title="Uploaders"
subtitle="Match or ignore uploaders (if announced)"
>
<Input.TextAreaAutoResize
name="match_uploaders"
label="Match uploaders"
columns={6}
placeholder="eg. uploader1,uploader2"
tooltip={
<div>
<p>Comma separated list of uploaders to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_uploaders"
label="Except uploaders"
columns={6}
placeholder="eg. anonymous1,anonymous2"
tooltip={
<div>
<p>Comma separated list of uploaders to ignore (takes priority over Match releases).
</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
const Language = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
title="Language"
subtitle="Match or ignore languages (if announced)"
>
<Input.MultiSelect
name="match_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Match Language"
columns={6}
/>
<Input.MultiSelect
name="except_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Except Language"
columns={6}
/>
</CollapsibleSection>
);
const Origins = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
title="Origins"
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
>
<Input.MultiSelect
name="origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Match Origins"
columns={6}
/>
<Input.MultiSelect
name="except_origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Except Origins"
columns={6}
/>
</CollapsibleSection>
);
const Freeleech = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.freeleech || values.freeleech_percent}
title="Freeleech"
subtitle="Match based off freeleech (if announced)"
>
<Input.TextField
name="freeleech_percent"
label="Freeleech percent"
disabled={values.freeleech}
tooltip={
<div>
<p>
<p>
Freeleech may be announced as a binary true/false value or as a
percentage (less likely), depending on the indexer. Use one <span className="font-bold">or</span> the other.
The Freeleech toggle overrides this field if it is toggled/true.
</p>
<br />
<p>
Refer to our documentation for more details:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
columns={6}
placeholder="eg. 50,75-100"
/>
<Components.HalfRow>
<Input.SwitchGroup
name="freeleech"
label="Freeleech"
className="py-0"
description="Cannot be used with Freeleech percent. Overrides Freeleech percent if toggled/true."
tooltip={
<div>
<p>
Freeleech may be announced as a binary true/false value (more likely) or as a
percentage, depending on the indexer. Use one <span className="font-bold">or</span> the other.
This field overrides Freeleech percent if it is toggled/true.
</p>
<br />
<p>
See who uses what in the documentation:{" "}
Refer to our documentation for more details:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
columns={6}
placeholder="eg. 50,75-100"
/>
</Components.HalfRow>
</CollapsibleSection>
);
<Components.HalfRow>
<Input.SwitchGroup
name="freeleech"
label="Freeleech"
className="py-0"
description="Cannot be used with Freeleech percent. Overrides Freeleech percent if toggled/true."
tooltip={
<div>
<p>
Freeleech may be announced as a binary true/false value (more likely) or as a
percentage, depending on the indexer. Use one <span className="font-bold">or</span> the other.
This field overrides Freeleech percent if it is toggled/true.
</p>
<br />
<p>
See who uses what in the documentation:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
/>
</Components.HalfRow>
</CollapsibleSection>
);
}
const FeedSpecific = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex_description || values.match_description || values.except_description}
title="RSS/Torznab/Newznab-specific"
subtitle={
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
}
>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_description"
label="Use Regex"
className="col-span-12 sm:col-span-6"
const FeedSpecific = () => {
const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.use_regex_description || values.match_description || values.except_description}
title="RSS/Torznab/Newznab-specific"
subtitle={
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
}
>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_description"
label="Use Regex"
className="col-span-12 sm:col-span-6"
/>
</Components.Layout>
<Input.RegexTextAreaField
name="match_description"
label="Match description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.Layout>
<Input.RegexTextAreaField
name="match_description"
label="Match description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
<Input.RegexTextAreaField
name="except_description"
label="Except description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
<Input.NumberField
name="min_seeders"
label="Min Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_seeders"
label="Max Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="min_leechers"
label="Min Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_leechers"
label="Max Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
</CollapsibleSection>
);
const RawReleaseTags = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
title="Raw Release Tags"
subtitle={
<>
<span className="underline underline-offset-2">Advanced users only</span>
{": "}This is the <span className="font-bold">raw</span> releaseTags string from the announce.
</>
}
>
<WarningAlert
text={
<>These might not be what you think they are. For <span className="underline font-bold">very advanced</span> users who know how things are parsed.</>
}
/>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_release_tags"
label="Use Regex"
className="col-span-12 sm:col-span-6"
<Input.RegexTextAreaField
name="except_description"
label="Except description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.Layout>
<Input.NumberField
name="min_seeders"
label="Min Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_seeders"
label="Max Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="min_leechers"
label="Min Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_leechers"
label="Max Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
</CollapsibleSection>
);
}
const RawReleaseTags = () => {
const { values } = useFormikContext<Filter>();
<Input.RegexField
name="match_release_tags"
label="Match release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
<Input.RegexField
name="except_release_tags"
label="Except release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
</CollapsibleSection>
);
return (
<CollapsibleSection
//defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
title="Raw Release Tags"
subtitle={
<>
<span className="underline underline-offset-2">Advanced users only</span>
{": "}This is the <span className="font-bold">raw</span> releaseTags string from the announce.
</>
}
>
<WarningAlert
text={
<>These might not be what you think they are. For <span className="underline font-bold">very advanced</span> users who know how things are parsed.</>
}
/>
export const Advanced = ({ values }: { values: FormikValues; }) => (
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
<Releases values={values} />
<Groups values={values} />
<Categories values={values} />
<Freeleech values={values} />
<Tags values={values}/>
<Uploaders values={values}/>
<Language values={values}/>
<Origins values={values} />
<FeedSpecific values={values} />
<RawReleaseTags values={values} />
</div>
);
<Components.Layout>
<Input.SwitchGroup
name="use_regex_release_tags"
label="Use Regex"
className="col-span-12 sm:col-span-6"
/>
</Components.Layout>
<Input.RegexField
name="match_release_tags"
label="Match release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
<Input.RegexField
name="except_release_tags"
label="Except release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
</CollapsibleSection>
);
}
export const Advanced = () => {
return (
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
<Releases />
<Groups />
<Categories />
<Freeleech />
<Tags />
<Uploaders />
<Language />
<Origins />
<FeedSpecific />
<RawReleaseTags />
</div>
);
}

View file

@ -1,25 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { downloadsPerUnitOptions } from "@domain/constants";
import { IndexersOptionsQueryOptions } from "@api/queries";
import { DocsLink } from "@components/ExternalLink";
import * as Input from "@components/inputs";
import * as Components from "./_components";
const MapIndexer = (indexer: Indexer) => (
{ label: indexer.name, value: indexer.id } as Input.MultiSelectOption
);
export const General = () => {
const { isLoading, data } = useQuery({
queryKey: ["filters", "indexer_list"],
queryFn: APIClient.indexers.getOptions,
refetchOnWindowFocus: false
});
const indexersQuery = useSuspenseQuery(IndexersOptionsQueryOptions())
const indexerOptions = indexersQuery.data && indexersQuery.data.map(MapIndexer)
const indexerOptions = data?.map(MapIndexer) ?? [];
// const indexerOptions = data?.map(MapIndexer) ?? [];
return (
<Components.Page>
@ -27,9 +25,9 @@ export const General = () => {
<Components.Layout>
<Input.TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
{!isLoading && (
{/*{!isLoading && (*/}
<Input.IndexerMultiSelect name="indexers" options={indexerOptions} label="Indexers" columns={6} />
)}
{/*)}*/}
</Components.Layout>
</Components.Section>

View file

@ -1,4 +1,4 @@
import type { FormikValues } from "formik";
import { useFormikContext } from "formik";
import { DocsLink } from "@components/ExternalLink";
import * as Input from "@components/inputs";
@ -6,182 +6,186 @@ import * as Input from "@components/inputs";
import * as CONSTS from "@domain/constants";
import * as Components from "./_components";
export const Music = ({ values }: { values: FormikValues; }) => (
<Components.Page>
<Components.Section>
<Components.Layout>
<Input.TextAreaAutoResize
name="artists"
label="Artists"
columns={6}
placeholder="eg. Artist One"
tooltip={
<div>
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
<Input.TextAreaAutoResize
name="albums"
label="Albums"
columns={6}
placeholder="eg. That Album"
tooltip={
<div>
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
export const Music = () => {
const { values } = useFormikContext<Filter>();
<Components.Section
title="Release details"
subtitle="Type (Album, Single, EP, etc.) and year of release (if announced)"
>
<Components.Layout>
<Input.MultiSelect
name="match_release_types"
options={CONSTS.RELEASE_TYPE_MUSIC_OPTIONS}
label="Music Type"
columns={6}
tooltip={
<div>
<p>Will only match releases with any of the selected types.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.TextField
name="years"
label="Years"
columns={6}
placeholder="eg. 2018,2019-2021"
tooltip={
<div>
<p>This field takes a range of years and/or comma separated single years.</p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Section
title="Quality"
subtitle="Format, source, log, etc."
>
<Components.Layout>
return (
<Components.Page>
<Components.Section>
<Components.Layout>
<Input.MultiSelect
name="formats"
options={CONSTS.FORMATS_OPTIONS}
label="Format"
columns={4}
disabled={values.perfect_flac}
<Input.TextAreaAutoResize
name="artists"
label="Artists"
columns={6}
placeholder="eg. Artist One"
tooltip={
<div>
<p>Will only match releases with any of the selected formats. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
<Input.MultiSelect
name="quality"
options={CONSTS.QUALITY_MUSIC_OPTIONS}
label="Quality"
columns={4}
disabled={values.perfect_flac}
<Input.TextAreaAutoResize
name="albums"
label="Albums"
columns={6}
placeholder="eg. That Album"
tooltip={
<div>
<p>Will only match releases with any of the selected qualities. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.MultiSelect
name="media"
options={CONSTS.SOURCES_MUSIC_OPTIONS}
label="Media"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected sources. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Layout className="items-end sm:!gap-x-6">
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="cue"
label="Cue"
description="Must include CUE info"
<Components.Section
title="Release details"
subtitle="Type (Album, Single, EP, etc.) and year of release (if announced)"
>
<Components.Layout>
<Input.MultiSelect
name="match_release_types"
options={CONSTS.RELEASE_TYPE_MUSIC_OPTIONS}
label="Music Type"
columns={6}
tooltip={
<div>
<p>Will only match releases with any of the selected types.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.TextField
name="years"
label="Years"
columns={6}
placeholder="eg. 2018,2019-2021"
tooltip={
<div>
<p>This field takes a range of years and/or comma separated single years.</p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Section
title="Quality"
subtitle="Format, source, log, etc."
>
<Components.Layout>
<Components.Layout>
<Input.MultiSelect
name="formats"
options={CONSTS.FORMATS_OPTIONS}
label="Format"
columns={4}
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="log"
label="Log"
description="Must include LOG info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.NumberField
name="log_score"
label="Log score"
placeholder="eg. 100"
min={0}
max={100}
disabled={values.perfect_flac || !values.log}
tooltip={
<div>
<p>Log scores go from 0 to 100. This is overridden by Perfect FLAC.</p>
<p>Will only match releases with any of the selected formats. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Row>
</Components.Layout>
</Components.Layout>
<Input.MultiSelect
name="quality"
options={CONSTS.QUALITY_MUSIC_OPTIONS}
label="Quality"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected qualities. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.MultiSelect
name="media"
options={CONSTS.SOURCES_MUSIC_OPTIONS}
label="Media"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected sources. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Layout>
<div className="col-span-12 flex items-center justify-center">
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
<span className="flex mx-2 shrink-0 text-lg font-bold uppercase tracking-wide text-gray-700 dark:text-gray-200">
<Components.Layout className="items-end sm:!gap-x-6">
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="cue"
label="Cue"
description="Must include CUE info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="log"
label="Log"
description="Must include LOG info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.NumberField
name="log_score"
label="Log score"
placeholder="eg. 100"
min={0}
max={100}
disabled={values.perfect_flac || !values.log}
tooltip={
<div>
<p>Log scores go from 0 to 100. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Row>
</Components.Layout>
</Components.Layout>
<div className="col-span-12 flex items-center justify-center">
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
<span className="flex mx-2 shrink-0 text-lg font-bold uppercase tracking-wide text-gray-700 dark:text-gray-200">
OR
</span>
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
</div>
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
</div>
<Components.Layout className="sm:!gap-x-6">
<Input.SwitchGroup
name="perfect_flac"
label="Perfect FLAC"
description="Override all options about quality, source, format, and cue/log/log score."
className="py-2 col-span-12 sm:col-span-6"
tooltip={
<div>
<p>Override all options about quality, source, format, and CUE/LOG/LOG score.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Components.Layout className="sm:!gap-x-6">
<Input.SwitchGroup
name="perfect_flac"
label="Perfect FLAC"
description="Override all options about quality, source, format, and cue/log/log score."
className="py-2 col-span-12 sm:col-span-6"
tooltip={
<div>
<p>Override all options about quality, source, format, and CUE/LOG/LOG score.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<span className="col-span-12 sm:col-span-6 self-center ml-0 text-center sm:text-left text-sm text-gray-500 dark:text-gray-425 underline underline-offset-2">
<span className="col-span-12 sm:col-span-6 self-center ml-0 text-center sm:text-left text-sm text-gray-500 dark:text-gray-425 underline underline-offset-2">
This is what you want in 90% of cases (instead of options above).
</span>
</Components.Layout>
</Components.Section>
</Components.Page>
);
</Components.Layout>
</Components.Section>
</Components.Page>
);
}

View file

@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "@tanstack/react-router";
import { DocsLink } from "@components/ExternalLink";
import { ActionContentLayoutOptions, ActionPriorityOptions } from "@domain/constants";

View file

@ -4,15 +4,15 @@
*/
import * as React from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Listbox, Transition } from "@headlessui/react";
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { classNames } from "@utils";
import { PushStatusOptions } from "@domain/constants";
import { FilterProps } from "react-table";
import { DebounceInput } from "react-debounce-input";
import { ReleasesIndexersQueryOptions } from "@api/queries";
interface ListboxFilterProps {
id: string;
@ -54,7 +54,7 @@ const ListboxFilter = ({
<Listbox.Options
className="absolute z-10 w-full mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 border border-opacity-5 border-black dark:border-gray-700 dark:border-opacity-40 focus:outline-none sm:text-sm"
>
<FilterOption label="All" />
<FilterOption label="All" value="" />
{children}
</Listbox.Options>
</Transition>
@ -67,12 +67,7 @@ const ListboxFilter = ({
export const IndexerSelectColumnFilter = ({
column: { filterValue, setFilter, id }
}: FilterProps<object>) => {
const { data, isSuccess } = useQuery({
queryKey: ["indexer_options"],
queryFn: () => APIClient.release.indexerOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});
const { data, isSuccess } = useQuery(ReleasesIndexersQueryOptions());
// Render a multi-select box
return (
@ -80,10 +75,10 @@ export const IndexerSelectColumnFilter = ({
id={id}
key={id}
label={filterValue ?? "Indexer"}
currentValue={filterValue}
currentValue={filterValue ?? ""}
onChange={setFilter}
>
{isSuccess && data?.map((indexer, idx) => (
{isSuccess && data && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer} value={indexer} />
))}
</ListboxFilter>
@ -138,7 +133,7 @@ export const PushStatusSelectColumnFilter = ({
<ListboxFilter
id={id}
label={label ?? "Push status"}
currentValue={filterValue}
currentValue={filterValue ?? ""}
onChange={setFilter}
>
{PushStatusOptions.map((status, idx) => (

View file

@ -4,33 +4,28 @@
*/
import React, { useState } from "react";
import { useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Column, useFilters, usePagination, useSortBy, useTable } from "react-table";
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon
ChevronRightIcon,
EyeIcon,
EyeSlashIcon
} from "@heroicons/react/24/solid";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { ReleasesIndexRoute } from "@app/routes";
import { ReleasesListQueryOptions } from "@api/queries";
import { RandomLinuxIsos } from "@utils";
import { APIClient } from "@api/APIClient";
import { EmptyListState } from "@components/emptystates";
import * as Icons from "@components/Icons";
import { RingResizeSpinner } from "@components/Icons";
import * as DataTable from "@components/data-table";
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./Filters";
export const releaseKeys = {
all: ["releases"] as const,
lists: () => [...releaseKeys.all, "list"] as const,
list: (pageIndex: number, pageSize: number, filters: ReleaseFilter[]) => [...releaseKeys.lists(), { pageIndex, pageSize, filters }] as const,
details: () => [...releaseKeys.all, "detail"] as const,
detail: (id: number) => [...releaseKeys.details(), id] as const
};
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./ReleaseFilters";
import { EmptyListState } from "@components/emptystates";
type TableState = {
queryPageIndex: number;
@ -79,10 +74,28 @@ const TableReducer = (state: TableState, action: Actions): TableState => {
}
};
const EmptyReleaseList = () => (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<tr>
<th>
<div className="flex items-center justify-between">
<span className="h-10"/>
</div>
</th>
</tr>
</thead>
</table>
<div className="flex items-center justify-center py-52">
<EmptyListState text="No results"/>
</div>
</div>
);
export const ReleaseTable = () => {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const filterTypeFromUrl = queryParams.get("filter");
const search = ReleasesIndexRoute.useSearch()
const columns = React.useMemo(() => [
{
Header: "Age",
@ -116,14 +129,14 @@ export const ReleaseTable = () => {
}
] as Column<Release>[], []);
if (search.action_status != "") {
initialState.queryFilters = [{id: "action_status", value: search.action_status! }]
}
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
React.useReducer(TableReducer, initialState);
const { isLoading, error, data, isSuccess } = useQuery({
queryKey: releaseKeys.list(queryPageIndex, queryPageSize, queryFilters),
queryFn: () => APIClient.release.findQuery(queryPageIndex * queryPageSize, queryPageSize, queryFilters),
staleTime: 5000
});
const { isLoading, error, data, isSuccess } = useQuery(ReleasesListQueryOptions(queryPageIndex * queryPageSize, queryPageSize, queryFilters));
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
@ -207,10 +220,10 @@ export const ReleaseTable = () => {
}, [filters]);
React.useEffect(() => {
if (filterTypeFromUrl != null) {
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: filterTypeFromUrl! }] });
if (search.action_status != null) {
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: search.action_status! }] });
}
}, [filterTypeFromUrl]);
}, [search.action_status]);
if (error) {
return <p>Error</p>;
@ -218,167 +231,33 @@ export const ReleaseTable = () => {
if (isLoading) {
return (
<div className="flex flex-col animate-pulse">
<div>
<div className="flex mb-6 flex-col sm:flex-row">
{headerGroups.map((headerGroup) =>
headerGroup.headers.map((column) => (
{ headerGroups.map((headerGroup) => headerGroup.headers.map((column) => (
column.Filter ? (
<React.Fragment key={column.id}>{column.render("Filter")}</React.Fragment>
<React.Fragment key={ column.id }>{ column.render("Filter") }</React.Fragment>
) : null
))
)}
) }
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th
scope="col"
className="first:pl-5 pl-3 pr-3 py-3 first:rounded-tl-md last:rounded-tr-md text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
>
<div className="flex items-center justify-between">
{/* Add a sort direction indicator */}
<span className="h-4">
</span>
</div>
</th>
</tr>
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md mt-4">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<tr>
<th>
<div className="flex items-center justify-between">
<span className="h-10"/>
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-150 dark:divide-gray-700">
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr className="justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap text-center">
<p className="text-black dark:text-white">Loading release table...</p>
</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Previous</span>
<ChevronLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
onClick={() => nextPage()}
disabled={!canNextPage}>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Next</span>
<ChevronRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Last</span>
<ChevronDoubleRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
</nav>
</div>
</div>
<div className="flex items-center justify-center py-64">
<RingResizeSpinner className="text-blue-500 size-24"/>
</div>
</div>
</div>
);
}
if (!data) {
return <EmptyListState text="No recent activity" />;
)
}
// Render the UI for your table
@ -394,18 +273,21 @@ export const ReleaseTable = () => {
)}
</div>
<div className="relative">
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{displayData.length === 0
? <EmptyReleaseList/>
: (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{headerGroups.map((headerGroup) => {
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
const {key: rowKey, ...rowRest} = headerGroup.getHeaderGroupProps();
return (
<tr key={rowKey} {...rowRest}>
{headerGroup.headers.map((column) => {
const { key: columnKey, ...columnRest } = column.getHeaderProps(column.getSortByToggleProps());
const {key: columnKey, ...columnRest} = column.getHeaderProps(column.getSortByToggleProps());
return (
// Add the sorting props to control sorting. For this example
// we can add them into the header props
// Add the sorting props to control sorting. For this example
// we can add them into the header props
<th
key={`${rowKey}-${columnKey}`}
scope="col"
@ -418,12 +300,12 @@ export const ReleaseTable = () => {
<span>
{column.isSorted ? (
column.isSortedDesc ? (
<Icons.SortDownIcon className="w-4 h-4 text-gray-400" />
<Icons.SortDownIcon className="w-4 h-4 text-gray-400"/>
) : (
<Icons.SortUpIcon className="w-4 h-4 text-gray-400" />
<Icons.SortUpIcon className="w-4 h-4 text-gray-400"/>
)
) : (
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100"/>
)}
</span>
</div>
@ -433,19 +315,19 @@ export const ReleaseTable = () => {
</tr>
);
})}
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
{page.map((row) => {
prepareRow(row);
const { key: bodyRowKey, ...bodyRowRest } = row.getRowProps();
const {key: bodyRowKey, ...bodyRowRest} = row.getRowProps();
return (
<tr key={bodyRowKey} {...bodyRowRest}>
{row.cells.map((cell) => {
const { key: cellRowKey, ...cellRowRest } = cell.getCellProps();
const {key: cellRowKey, ...cellRowRest} = cell.getCellProps();
return (
<td
key={cellRowKey}
@ -460,88 +342,90 @@ export const ReleaseTable = () => {
</tr>
);
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
Page <span className="font-medium">{pageIndex + 1}</span> of <span
className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true" />
</DataTable.PageButton>
<DataTable.PageButton
className="pl-1 pr-2"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true" />
<span>Prev</span>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-2 pr-1"
onClick={() => nextPage()}
disabled={!canNextPage}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true" />
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true" />
<span className="sr-only">Last</span>
</DataTable.PageButton>
</nav>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-1 pr-2"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true"/>
<span>Prev</span>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-2 pr-1"
onClick={() => nextPage()}
disabled={!canNextPage}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true"/>
<span className="sr-only">Last</span>
</DataTable.PageButton>
</nav>
</div>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4"/>
) : (
<EyeSlashIcon className="h-4 w-4"/>
)}
</button>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeSlashIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
</div>
</div>
);

View file

@ -4,15 +4,17 @@
*/
import { useMutation } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import Toast from "@components/notifications/Toast";
import { Section } from "./_components";
import { Form, Formik } from "formik";
import { PasswordField, TextField } from "@components/inputs";
import { AuthContext } from "@utils/Context";
import toast from "react-hot-toast";
import { UserIcon } from "@heroicons/react/24/solid";
import { SettingsAccountRoute } from "@app/routes";
import { AuthContext } from "@utils/Context";
import { APIClient } from "@api/APIClient";
import { Section } from "./_components";
import { PasswordField, TextField } from "@components/inputs";
import Toast from "@components/notifications/Toast";
const AccountSettings = () => (
<Section
title="Account"
@ -33,8 +35,7 @@ interface InputValues {
}
function Credentials() {
const [ getAuthContext ] = AuthContext.use();
const ctx = SettingsAccountRoute.useRouteContext()
const validate = (values: InputValues) => {
const errors: Record<string, string> = {};
@ -51,7 +52,8 @@ function Credentials() {
const logoutMutation = useMutation({
mutationFn: APIClient.auth.logout,
onSuccess: () => {
AuthContext.reset();
AuthContext.logout();
toast.custom((t) => (
<Toast type="success" body="User updated successfully. Please sign in again!" t={t} />
));
@ -76,7 +78,7 @@ function Credentials() {
<div className="px-2 pb-6 bg-white dark:bg-gray-800">
<Formik
initialValues={{
username: getAuthContext.username,
username: ctx.auth.username!,
newUsername: "",
oldPassword: "",
newPassword: "",

View file

@ -13,33 +13,19 @@ import { DeleteModal } from "@components/modals";
import { APIKeyAddForm } from "@forms/settings/APIKeyAddForm";
import Toast from "@components/notifications/Toast";
import { APIClient } from "@api/APIClient";
import { ApikeysQueryOptions } from "@api/queries";
import { ApiKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { EmptySimple } from "@components/emptystates";
import { Section } from "./_components";
import { PlusIcon } from "@heroicons/react/24/solid";
export const apiKeys = {
all: ["api_keys"] as const,
lists: () => [...apiKeys.all, "list"] as const,
details: () => [...apiKeys.all, "detail"] as const,
// detail: (id: number) => [...apiKeys.details(), id] as const
detail: (id: string) => [...apiKeys.details(), id] as const
};
function APISettings() {
const [addFormIsOpen, toggleAddForm] = useToggle(false);
const { isError, error, data } = useSuspenseQuery({
queryKey: apiKeys.lists(),
queryFn: APIClient.apikeys.getAll,
retry: false,
refetchOnWindowFocus: false
});
if (isError) {
console.log(error);
}
const apikeysQuery = useSuspenseQuery(ApikeysQueryOptions())
return (
<Section
@ -58,7 +44,7 @@ function APISettings() {
>
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} />
{data && data.length > 0 ? (
{apikeysQuery.data && apikeysQuery.data.length > 0 ? (
<ul className="min-w-full relative">
<li className="hidden sm:grid grid-cols-12 gap-4 mb-2 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
@ -69,7 +55,7 @@ function APISettings() {
</div>
</li>
{data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
{apikeysQuery.data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
</ul>
) : (
<EmptySimple
@ -96,8 +82,8 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
const deleteMutation = useMutation({
mutationFn: (key: string) => APIClient.apikeys.delete(key),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
queryClient.invalidateQueries({ queryKey: apiKeys.detail(apikey.key) });
queryClient.invalidateQueries({ queryKey: ApiKeys.lists() });
queryClient.invalidateQueries({ queryKey: ApiKeys.detail(apikey.key) });
toast.custom((t) => (
<Toast

View file

@ -3,10 +3,13 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { SettingsIndexRoute } from "@app/routes";
import { APIClient } from "@api/APIClient";
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
import { SettingsKeys } from "@api/query_keys";
import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
@ -17,34 +20,23 @@ import { Section, RowItem } from "./_components";
function ApplicationSettings() {
const [settings, setSettings] = SettingsContext.use();
const { isError:isConfigError, error: configError, data } = useQuery({
queryKey: ["config"],
queryFn: APIClient.config.get,
retry: false,
refetchOnWindowFocus: false
});
const ctx = SettingsIndexRoute.useRouteContext()
const queryClient = ctx.queryClient
const { isError:isConfigError, error: configError, data } = useQuery(ConfigQueryOptions());
if (isConfigError) {
console.log(configError);
}
const { isError, error, data: updateData } = useQuery({
queryKey: ["updates"],
queryFn: APIClient.updates.getLatestRelease,
retry: false,
refetchOnWindowFocus: false,
enabled: data?.check_for_updates === true
});
const { isError, error, data: updateData } = useQuery(UpdatesQueryOptions(data?.check_for_updates === true));
if (isError) {
console.log(error);
}
const queryClient = useQueryClient();
const checkUpdateMutation = useMutation({
mutationFn: APIClient.updates.check,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["updates"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.updates() });
}
});
@ -52,7 +44,7 @@ function ApplicationSettings() {
mutationFn: (value: boolean) => APIClient.config.update({ check_for_updates: value }).then(() => value),
onSuccess: (_, value: boolean) => {
toast.custom(t => <Toast type="success" body={`${value ? "You will now be notified of new updates." : "You will no longer be notified of new updates."}`} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.config() });
checkUpdateMutation.mutate();
}
});

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
@ -12,20 +12,14 @@ import { useToggle } from "@hooks/hooks";
import { DownloadClientAddForm, DownloadClientUpdateForm } from "@forms";
import { EmptySimple } from "@components/emptystates";
import { APIClient } from "@api/APIClient";
import { DownloadClientKeys } from "@api/query_keys";
import { DownloadClientsQueryOptions } from "@api/queries";
import { ActionTypeNameMap } from "@domain/constants";
import Toast from "@components/notifications/Toast";
import { Checkbox } from "@components/Checkbox";
import { Section } from "./_components";
export const clientKeys = {
all: ["download_clients"] as const,
lists: () => [...clientKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...clientKeys.all, "detail"] as const,
detail: (id: number) => [...clientKeys.details(), id] as const
};
interface DLSettingsItemProps {
client: DownloadClient;
}
@ -97,7 +91,7 @@ function ListItem({ client }: DLSettingsItemProps) {
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client).then(() => client),
onSuccess: (client: DownloadClient) => {
toast.custom(t => <Toast type="success" body={`${client.name} was ${client.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
}
});
@ -140,17 +134,9 @@ function ListItem({ client }: DLSettingsItemProps) {
function DownloadClientSettings() {
const [addClientIsOpen, toggleAddClient] = useToggle(false);
const { error, data } = useSuspenseQuery({
queryKey: clientKeys.lists(),
queryFn: APIClient.download_clients.getAll,
refetchOnWindowFocus: false
});
const downloadClientsQuery = useSuspenseQuery(DownloadClientsQueryOptions())
const sortedClients = useSort(data || []);
if (error) {
return <p>Failed to fetch download clients</p>;
}
const sortedClients = useSort(downloadClientsQuery.data || []);
return (
<Section

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, useRef, useState, useMemo } from "react";
import { Fragment, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { Menu, Transition } from "@headlessui/react";
import { toast } from "react-hot-toast";
@ -11,12 +11,14 @@ import {
ArrowsRightLeftIcon,
DocumentTextIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
ForwardIcon,
PencilSquareIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { FeedsQueryOptions } from "@api/queries";
import { FeedKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils";
import Toast from "@components/notifications/Toast";
@ -29,14 +31,6 @@ import { ExternalLink } from "@components/ExternalLink";
import { Section } from "./_components";
import { Checkbox } from "@components/Checkbox";
export const feedKeys = {
all: ["feeds"] as const,
lists: () => [...feedKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...feedKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...feedKeys.all, "detail"] as const,
detail: (id: number) => [...feedKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["feed"] | "enabled";
direction: "ascending" | "descending";
@ -97,20 +91,16 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
}
function FeedSettings() {
const { data } = useSuspenseQuery({
queryKey: feedKeys.lists(),
queryFn: APIClient.feeds.find,
refetchOnWindowFocus: false
});
const feedsQuery = useSuspenseQuery(FeedsQueryOptions())
const sortedFeeds = useSort(data || []);
const sortedFeeds = useSort(feedsQuery.data || []);
return (
<Section
title="Feeds"
description="Manage RSS, Newznab, and Torznab feeds."
>
{data && data.length > 0 ? (
{feedsQuery.data && feedsQuery.data.length > 0 ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">
<div
@ -163,8 +153,8 @@ function ListItem({ feed }: ListItemProps) {
const updateMutation = useMutation({
mutationFn: (status: boolean) => APIClient.feeds.toggleEnable(feed.id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`${feed.name} was ${!enabled ? "disabled" : "enabled"} successfully.`} t={t} />);
}
@ -240,8 +230,8 @@ const FeedItemDropdown = ({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t} />);
}
@ -257,7 +247,7 @@ const FeedItemDropdown = ({
const forceRunMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.forceRun(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was force run successfully.`} t={t} />);
toggleForceRunModal();
},

View file

@ -3,13 +3,15 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { IndexerKeys } from "@api/query_keys";
import { IndexersQueryOptions } from "@api/queries";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
import { EmptySimple } from "@components/emptystates";
@ -18,14 +20,6 @@ import { componentMapType } from "@forms/settings/DownloadClientForms";
import { Section } from "./_components";
export const indexerKeys = {
all: ["indexers"] as const,
lists: () => [...indexerKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...indexerKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...indexerKeys.all, "detail"] as const,
detail: (id: number) => [...indexerKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["indexer"] | "enabled";
direction: "ascending" | "descending";
@ -123,7 +117,7 @@ const ListItem = ({ indexer }: ListItemProps) => {
const updateMutation = useMutation({
mutationFn: (enabled: boolean) => APIClient.indexers.toggleEnable(indexer.id, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t} />);
}
});
@ -169,17 +163,13 @@ const ListItem = ({ indexer }: ListItemProps) => {
function IndexerSettings() {
const [addIndexerIsOpen, toggleAddIndexer] = useToggle(false);
const { error, data } = useSuspenseQuery({
queryKey: indexerKeys.lists(),
queryFn: APIClient.indexers.getAll,
refetchOnWindowFocus: false
});
const indexersQuery = useSuspenseQuery(IndexersQueryOptions())
const indexers = indexersQuery.data
const sortedIndexers = useSort(indexers || []);
const sortedIndexers = useSort(data || []);
if (error) {
return (<p>An error has occurred</p>);
}
// if (error) {
// return (<p>An error has occurred</p>);
// }
return (
<Section

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, useRef, useState, useMemo, useEffect, MouseEvent } from "react";
import { Fragment, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
import { Menu, Transition } from "@headlessui/react";
@ -22,23 +22,16 @@ import { classNames, IsEmptyDate, simplifyDate } from "@utils";
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { IrcKeys } from "@api/query_keys";
import { IrcQueryOptions } from "@api/queries";
import { EmptySimple } from "@components/emptystates";
import { DeleteModal } from "@components/modals";
import Toast from "@components/notifications/Toast";
import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox";
// import { useForm } from "react-hook-form";
import { Section } from "./_components";
export const ircKeys = {
all: ["irc_networks"] as const,
lists: () => [...ircKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...ircKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...ircKeys.all, "detail"] as const,
detail: (id: number) => [...ircKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["network"] | "enabled";
direction: "ascending" | "descending";
@ -98,14 +91,9 @@ const IrcSettings = () => {
const [expandNetworks, toggleExpand] = useToggle(false);
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false);
const { data } = useSuspenseQuery({
queryKey: ircKeys.lists(),
queryFn: APIClient.irc.getNetworks,
refetchOnWindowFocus: false,
refetchInterval: 3000 // Refetch every 3 seconds
});
const ircQuery = useSuspenseQuery(IrcQueryOptions())
const sortedNetworks = useSort(data || []);
const sortedNetworks = useSort(ircQuery.data || []);
return (
<Section
@ -168,7 +156,7 @@ const IrcSettings = () => {
</div>
</div>
{data && data.length > 0 ? (
{ircQuery.data && ircQuery.data.length > 0 ? (
<ul className="mt-6 min-w-full relative text-sm">
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700 text-xs font-medium text-gray-500 dark:text-gray-400">
<div className="flex col-span-2 md:col-span-1 pl-2 sm:px-3 py-3 text-left uppercase tracking-wider cursor-pointer"
@ -218,7 +206,7 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
const updateMutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network).then(() => network),
onSuccess: (network: IrcNetwork) => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom(t => <Toast type="success" body={`${network.name} was ${network.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
}
});
@ -431,8 +419,8 @@ const ListItemDropdown = ({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => <Toast type="success" body={`Network ${network.name} was deleted`} t={t} />);
@ -443,8 +431,8 @@ const ListItemDropdown = ({
const restartMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.restartNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => <Toast type="success" body={`${network.name} was successfully restarted`} t={t} />);
}

View file

@ -3,12 +3,15 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
import Select from "react-select";
import { APIClient } from "@api/APIClient";
import { ConfigQueryOptions } from "@api/queries";
import { SettingsKeys } from "@api/query_keys";
import { SettingsLogRoute } from "@app/routes";
import Toast from "@components/notifications/Toast";
import { LogLevelOptions, SelectOption } from "@domain/constants";
@ -56,25 +59,19 @@ const SelectWrapper = ({ id, value, onChange, options }: SelectWrapperProps) =>
);
function LogSettings() {
const { isError, error, isLoading, data } = useSuspenseQuery({
queryKey: ["config"],
queryFn: APIClient.config.get,
retry: false,
refetchOnWindowFocus: false
});
const ctx = SettingsLogRoute.useRouteContext()
const queryClient = ctx.queryClient
if (isError) {
console.log(error);
}
const configQuery = useSuspenseQuery(ConfigQueryOptions())
const queryClient = useQueryClient();
const config = configQuery.data
const setLogLevelUpdateMutation = useMutation({
mutationFn: (value: string) => APIClient.config.update({ log_level: value }),
onSuccess: () => {
toast.custom((t) => <Toast type="success" body={"Config successfully updated!"} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.config() });
}
});
@ -86,7 +83,7 @@ function LogSettings() {
Configure log level, log size rotation, etc. You can download your old log files
{" "}
<Link
to="/logs"
to="/settings/logs"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-blue-500 decoration hover:text-black hover:dark:text-gray-100"
>
on the Logs page
@ -96,9 +93,9 @@ function LogSettings() {
>
<div className="-mx-4 lg:col-span-9">
<div className="divide-y divide-gray-200 dark:divide-gray-750">
{!isLoading && data && (
{!configQuery.isLoading && config && (
<form className="divide-y divide-gray-200 dark:divide-gray-750" action="#" method="POST">
<RowItem label="Path" value={data?.log_path} title="Set in config.toml" emptyText="Not set!"/>
<RowItem label="Path" value={config?.log_path} title="Set in config.toml" emptyText="Not set!"/>
<RowItem
className="sm:col-span-1"
label="Level"
@ -106,14 +103,14 @@ function LogSettings() {
value={
<SelectWrapper
id="log_level"
value={data?.log_level}
value={config?.log_level}
options={LogLevelOptions}
onChange={(value: SelectOption) => setLogLevelUpdateMutation.mutate(value.value)}
/>
}
/>
<RowItem label="Max Size" value={data?.log_max_size} title="Set in config.toml" rightSide="MB"/>
<RowItem label="Max Backups" value={data?.log_max_backups} title="Set in config.toml"/>
<RowItem label="Max Size" value={config?.log_max_size} title="Set in config.toml" rightSide="MB"/>
<RowItem label="Max Backups" value={config?.log_max_backups} title="Set in config.toml"/>
</form>
)}

View file

@ -4,35 +4,33 @@
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { NotificationKeys } from "@api/query_keys";
import { NotificationsQueryOptions } from "@api/queries";
import { EmptySimple } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms";
import { componentMapType } from "@forms/settings/DownloadClientForms";
import Toast from "@components/notifications/Toast";
import toast from "react-hot-toast";
import { Section } from "./_components";
import { PlusIcon } from "@heroicons/react/24/solid";
import {
DiscordIcon,
GotifyIcon,
LunaSeaIcon,
NotifiarrIcon,
NtfyIcon,
PushoverIcon,
Section,
TelegramIcon
} from "./_components";
import { Checkbox } from "@components/Checkbox";
import { DiscordIcon, GotifyIcon, LunaSeaIcon, NotifiarrIcon, NtfyIcon, PushoverIcon, TelegramIcon } from "./_components";
export const notificationKeys = {
all: ["notifications"] as const,
lists: () => [...notificationKeys.all, "list"] as const,
details: () => [...notificationKeys.all, "detail"] as const,
detail: (id: number) => [...notificationKeys.details(), id] as const
};
function NotificationSettings() {
const [addNotificationsIsOpen, toggleAddNotifications] = useToggle(false);
const { data } = useSuspenseQuery({
queryKey: notificationKeys.lists(),
queryFn: APIClient.notifications.getAll,
refetchOnWindowFocus: false
}
);
const notificationsQuery = useSuspenseQuery(NotificationsQueryOptions())
return (
<Section
@ -51,7 +49,7 @@ function NotificationSettings() {
>
<NotificationAddForm isOpen={addNotificationsIsOpen} toggle={toggleAddNotifications} />
{data && data.length > 0 ? (
{notificationsQuery.data && notificationsQuery.data.length > 0 ? (
<ul className="min-w-full">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-2 sm:col-span-1 pl-1 sm:pl-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Enabled</div>
@ -60,7 +58,7 @@ function NotificationSettings() {
<div className="hidden md:flex col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Events</div>
</li>
{data.map((n) => <ListItem key={n.id} notification={n} />)}
{notificationsQuery.data.map((n) => <ListItem key={n.id} notification={n} />)}
</ul>
) : (
<EmptySimple title="No notifications" subtitle="" buttonText="Create new notification" buttonAction={toggleAddNotifications} />
@ -94,7 +92,7 @@ function ListItem({ notification }: ListItemProps) {
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification).then(() => notification),
onSuccess: (notification: ServiceNotification) => {
toast.custom(t => <Toast type="success" body={`${notification.name} was ${notification.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
}
});

View file

@ -8,8 +8,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { ReleaseKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { releaseKeys } from "@screens/releases/ReleaseTable";
import { useToggle } from "@hooks/hooks";
import { DeleteModal } from "@components/modals";
import { Section } from "./_components";
@ -74,7 +74,7 @@ function DeleteReleases() {
}
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
queryClient.invalidateQueries({ queryKey: ReleaseKeys.lists() });
}
});

View file

@ -3,13 +3,8 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { newRidgeState } from "react-ridge-state";
import type { StateWithValue } from "react-ridge-state";
interface AuthInfo {
username: string;
isLoggedIn: boolean;
}
import { newRidgeState } from "react-ridge-state";
interface SettingsType {
debug: boolean;
@ -26,11 +21,16 @@ export type FilterListState = {
status: string;
};
// interface AuthInfo {
// username: string;
// isLoggedIn: boolean;
// }
// Default values
const AuthContextDefaults: AuthInfo = {
username: "",
isLoggedIn: false
};
// const AuthContextDefaults: AuthInfo = {
// username: "",
// isLoggedIn: false
// };
const SettingsContextDefaults: SettingsType = {
debug: false,
@ -53,7 +53,7 @@ function ContextMerger<T extends {}>(
defaults: T,
ctxState: StateWithValue<T>
) {
let values = defaults;
let values = structuredClone(defaults);
const storage = localStorage.getItem(key);
if (storage) {
@ -62,25 +62,28 @@ function ContextMerger<T extends {}>(
if (json === null) {
console.warn(`JSON localStorage value for '${key}' context state is null`);
} else {
values = { ...defaults, ...json };
values = { ...values, ...json };
}
} catch (e) {
console.error(`Failed to merge ${key} context state: ${e}`);
}
}
ctxState.set(values);
}
const SettingsKey = "autobrr_settings";
const FilterListKey = "autobrr_filter_list";
export const InitializeGlobalContext = () => {
ContextMerger<AuthInfo>("auth", AuthContextDefaults, AuthContext);
// ContextMerger<AuthInfo>(localStorageUserKey, AuthContextDefaults, AuthContextt);
ContextMerger<SettingsType>(
"settings",
SettingsKey,
SettingsContextDefaults,
SettingsContext
);
ContextMerger<FilterListState>(
"filterList",
FilterListKey,
FilterListContextDefaults,
FilterListContext
);
@ -98,16 +101,16 @@ function DefaultSetter<T>(name: string, newState: T, prevState: T) {
}
}
export const AuthContext = newRidgeState<AuthInfo>(AuthContextDefaults, {
onSet: (newState, prevState) => DefaultSetter("auth", newState, prevState)
});
// export const AuthContextt = newRidgeState<AuthInfo>(AuthContextDefaults, {
// onSet: (newState, prevState) => DefaultSetter(localStorageUserKey, newState, prevState)
// });
export const SettingsContext = newRidgeState<SettingsType>(
SettingsContextDefaults,
{
onSet: (newState, prevState) => {
document.documentElement.classList.toggle("dark", newState.darkTheme);
DefaultSetter("settings", newState, prevState);
DefaultSetter(SettingsKey, newState, prevState);
}
}
);
@ -115,6 +118,32 @@ export const SettingsContext = newRidgeState<SettingsType>(
export const FilterListContext = newRidgeState<FilterListState>(
FilterListContextDefaults,
{
onSet: (newState, prevState) => DefaultSetter("filterList", newState, prevState)
onSet: (newState, prevState) => DefaultSetter(FilterListKey, newState, prevState)
}
);
export type AuthCtx = {
isLoggedIn: boolean
username?: string
login: (username: string) => void
logout: () => void
}
export const localStorageUserKey = "autobrr_user_auth"
export const AuthContext: AuthCtx = {
isLoggedIn: false,
username: undefined,
login: (username: string) => {
AuthContext.isLoggedIn = true
AuthContext.username = username
localStorage.setItem(localStorageUserKey, JSON.stringify(AuthContext));
},
logout: () => {
AuthContext.isLoggedIn = false
AuthContext.username = undefined
localStorage.removeItem(localStorageUserKey);
},
}

View file

@ -12,7 +12,7 @@ export function sleep(ms: number) {
// get baseUrl sent from server rendered index template
export function baseUrl() {
let baseUrl = "";
let baseUrl = "/";
if (window.APP.baseUrl) {
if (window.APP.baseUrl === "{{.BaseUrl}}") {
baseUrl = "/";
@ -23,6 +23,20 @@ export function baseUrl() {
return baseUrl;
}
// get routerBasePath sent from server rendered index template
// routerBasePath is used for RouterProvider and does not need work with trailing slash
export function routerBasePath() {
let baseUrl = "";
if (window.APP.baseUrl) {
if (window.APP.baseUrl === "{{.BaseUrl}}") {
baseUrl = "";
} else {
baseUrl = window.APP.baseUrl;
}
}
return baseUrl;
}
// get sseBaseUrl for SSE
export function sseBaseUrl() {
if (process.env.NODE_ENV === "development")