Merge pull request #15 from DaKheera47/settings-page-refactor

Settings page refactor
This commit is contained in:
Shaheer Sarfaraz 2026-01-21 21:46:53 +00:00 committed by GitHub
commit b4d60549c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1345 additions and 817 deletions

View File

@ -8,6 +8,7 @@
"name": "job-ops-orchestrator",
"version": "1.0.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
@ -27,6 +28,7 @@
"express": "^4.18.2",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"react-transition-group": "^4.4.5",
"remark-gfm": "^4.0.1",
@ -58,6 +60,7 @@
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.2",
"tailwindcss": "^4.1.18",
"tsc-alias": "^1.8.16",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^6.0.3",
@ -1420,6 +1423,18 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -1470,6 +1485,44 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@petamoriken/float16": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
@ -2761,6 +2814,12 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@ -3556,6 +3615,33 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@ -3582,6 +3668,16 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -3684,6 +3780,19 @@
"prebuild-install": "^7.1.1"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@ -3743,6 +3852,19 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@ -3951,6 +4073,31 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@ -4032,6 +4179,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@ -4278,6 +4435,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@ -4779,6 +4949,33 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -4803,6 +5000,19 @@
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -5031,6 +5241,40 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -5235,6 +5479,16 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@ -5292,6 +5546,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-decimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
@ -5301,6 +5568,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -5311,6 +5588,19 @@
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
@ -5320,6 +5610,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@ -6026,6 +6326,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@ -6570,6 +6880,33 @@
}
]
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@ -6645,6 +6982,20 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mylas": {
"version": "2.1.14",
"resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz",
"integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/raouldeheer"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -6719,6 +7070,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
@ -6836,6 +7197,16 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@ -6862,6 +7233,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/plimit-lit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
"integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"queue-lit": "^1.5.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -7021,6 +7405,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-lit": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz",
"integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -7085,6 +7500,22 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.71.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -7277,6 +7708,32 @@
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -7372,6 +7829,17 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
@ -7420,6 +7888,30 @@
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"dev": true
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@ -7788,6 +8280,16 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@ -8089,6 +8591,19 @@
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -8150,6 +8665,28 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/tsc-alias": {
"version": "1.8.16",
"resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz",
"integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.3",
"commander": "^9.0.0",
"get-tsconfig": "^4.10.0",
"globby": "^11.0.4",
"mylas": "^2.1.9",
"normalize-path": "^3.0.0",
"plimit-lit": "^1.2.6"
},
"bin": {
"tsc-alias": "dist/bin/index.js"
},
"engines": {
"node": ">=16.20.2"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@ -9,7 +9,7 @@
"dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite --host",
"build": "npm run build:client && npm run build:server",
"build:server": "tsc -p tsconfig.server.json",
"build:server": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json",
"build:client": "vite build",
"start": "node dist/server/index.js",
"db:migrate": "tsx src/server/db/migrate.ts",
@ -20,6 +20,7 @@
"test:run": "vitest run"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
@ -39,6 +40,7 @@
"express": "^4.18.2",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"react-transition-group": "^4.4.5",
"remark-gfm": "^4.0.1",
@ -70,6 +72,7 @@
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.2",
"tailwindcss": "^4.1.18",
"tsc-alias": "^1.8.16",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^6.0.3",

View File

@ -141,6 +141,28 @@ describe("SettingsPage", () => {
expect(toast.success).toHaveBeenCalledWith("Settings saved")
})
it("shows validation error for too long model override", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
const modelInput = within(modelField).getByRole("textbox")
// Change to > 200 chars
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } })
// Should see error message
expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument()
// Save button should be disabled due to validation error (isValid will be false)
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeDisabled()
})
it("clears jobs by status and summarizes results", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })

View File

@ -1,52 +1,195 @@
/**
* Settings page.
*/
import React, { useEffect, useMemo, useState } from "react"
import React, { useEffect, useState } from "react"
import { Settings } from "lucide-react"
import { toast } from "sonner"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { PageHeader } from "../components/layout"
import { PageHeader } from "@client/components/layout"
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types"
import * as api from "../api"
import type { AppSettings, JobStatus } from "@shared/types"
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
import * as api from "@client/api"
import { arraysEqual } from "@/lib/utils"
import { resumeProjectsEqual } from "./settings/utils"
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "./settings/components/DisplaySettingsSection"
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
import { JobspySection } from "./settings/components/JobspySection"
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
import { resumeProjectsEqual } from "@client/pages/settings/utils"
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "@client/pages/settings/components/JobCompleteWebhookSection"
import { JobspySection } from "@client/pages/settings/components/JobspySection"
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
import { PipelineWebhookSection } from "@client/pages/settings/components/PipelineWebhookSection"
import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
model: "",
modelScorer: "",
modelTailoring: "",
modelProjectSelection: "",
pipelineWebhookUrl: "",
jobCompleteWebhookUrl: "",
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
}
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
model: null,
modelScorer: null,
modelTailoring: null,
modelProjectSelection: null,
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
}
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
model: data.overrideModel ?? "",
modelScorer: data.overrideModelScorer ?? "",
modelTailoring: data.overrideModelTailoring ?? "",
modelProjectSelection: data.overrideModelProjectSelection ?? "",
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
resumeProjects: data.resumeProjects,
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
searchTerms: data.overrideSearchTerms,
jobspyLocation: data.overrideJobspyLocation,
jobspyResultsWanted: data.overrideJobspyResultsWanted,
jobspyHoursOld: data.overrideJobspyHoursOld,
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
jobspySites: data.overrideJobspySites,
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
showSponsorInfo: data.overrideShowSponsorInfo,
})
const normalizeString = (value: string | null | undefined) => {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
if (!left && !right) return true
if (!left || !right) return false
return arraysEqual(left, right)
}
const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
if (!left && !right) return true
if (!left || !right) return false
return arraysEqual(left.slice().sort(), right.slice().sort())
}
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
value === defaultValue ? null : value ?? null
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameStringList(value, defaultValue) ? null : value ?? null
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameSortedStringList(value, defaultValue) ? null : value ?? null
const getDerivedSettings = (settings: AppSettings | null) => {
const profileProjects = settings?.profileProjects ?? []
return {
model: {
effective: settings?.model ?? "",
default: settings?.defaultModel ?? "",
scorer: settings?.modelScorer ?? "",
tailoring: settings?.modelTailoring ?? "",
projectSelection: settings?.modelProjectSelection ?? "",
},
pipelineWebhook: {
effective: settings?.pipelineWebhookUrl ?? "",
default: settings?.defaultPipelineWebhookUrl ?? "",
},
jobCompleteWebhook: {
effective: settings?.jobCompleteWebhookUrl ?? "",
default: settings?.defaultJobCompleteWebhookUrl ?? "",
},
ukvisajobs: {
effective: settings?.ukvisajobsMaxJobs ?? 50,
default: settings?.defaultUkvisajobsMaxJobs ?? 50,
},
gradcracker: {
effective: settings?.gradcrackerMaxJobsPerTerm ?? 50,
default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50,
},
searchTerms: {
effective: settings?.searchTerms ?? [],
default: settings?.defaultSearchTerms ?? [],
},
jobspy: {
location: {
effective: settings?.jobspyLocation ?? "",
default: settings?.defaultJobspyLocation ?? "",
},
resultsWanted: {
effective: settings?.jobspyResultsWanted ?? 200,
default: settings?.defaultJobspyResultsWanted ?? 200,
},
hoursOld: {
effective: settings?.jobspyHoursOld ?? 72,
default: settings?.defaultJobspyHoursOld ?? 72,
},
countryIndeed: {
effective: settings?.jobspyCountryIndeed ?? "",
default: settings?.defaultJobspyCountryIndeed ?? "",
},
sites: {
effective: settings?.jobspySites ?? ["indeed", "linkedin"],
default: settings?.defaultJobspySites ?? ["indeed", "linkedin"],
},
linkedinFetchDescription: {
effective: settings?.jobspyLinkedinFetchDescription ?? true,
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
},
},
display: {
effective: settings?.showSponsorInfo ?? true,
default: settings?.defaultShowSponsorInfo ?? true,
},
defaultResumeProjects: settings?.defaultResumeProjects ?? null,
profileProjects,
maxProjectsTotal: profileProjects.length,
}
}
export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null)
const [modelDraft, setModelDraft] = useState("")
const [modelScorerDraft, setModelScorerDraft] = useState("")
const [modelTailoringDraft, setModelTailoringDraft] = useState("")
const [modelProjectSelectionDraft, setModelProjectSelectionDraft] = useState("")
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
const [resumeProjectsDraft, setResumeProjectsDraft] = useState<ResumeProjectsSettings | null>(null)
const [ukvisajobsMaxJobsDraft, setUkvisajobsMaxJobsDraft] = useState<number | null>(null)
const [gradcrackerMaxJobsPerTermDraft, setGradcrackerMaxJobsPerTermDraft] = useState<number | null>(null)
const [searchTermsDraft, setSearchTermsDraft] = useState<string[] | null>(null)
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const [showSponsorInfoDraft, setShowSponsorInfoDraft] = useState<boolean | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(updateSettingsSchema),
mode: "onChange",
defaultValues: DEFAULT_FORM_VALUES,
})
const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid } } = methods
useEffect(() => {
let isMounted = true
setIsLoading(true)
@ -55,23 +198,7 @@ export const SettingsPage: React.FC = () => {
.then((data) => {
if (!isMounted) return
setSettings(data)
setModelDraft(data.overrideModel ?? "")
setModelScorerDraft(data.overrideModelScorer ?? "")
setModelTailoringDraft(data.overrideModelTailoring ?? "")
setModelProjectSelectionDraft(data.overrideModelProjectSelection ?? "")
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
setResumeProjectsDraft(data.resumeProjects)
setUkvisajobsMaxJobsDraft(data.overrideUkvisajobsMaxJobs)
setGradcrackerMaxJobsPerTermDraft(data.overrideGradcrackerMaxJobsPerTerm)
setSearchTermsDraft(data.overrideSearchTerms)
setJobspyLocationDraft(data.overrideJobspyLocation)
setJobspyResultsWantedDraft(data.overrideJobspyResultsWanted)
setJobspyHoursOldDraft(data.overrideJobspyHoursOld)
setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed)
setJobspySitesDraft(data.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(data.overrideShowSponsorInfo)
reset(mapSettingsToForm(data))
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings"
@ -85,190 +212,65 @@ export const SettingsPage: React.FC = () => {
return () => {
isMounted = false
}
}, [])
}, [reset])
const effectiveModel = settings?.model ?? ""
const defaultModel = settings?.defaultModel ?? ""
const overrideModel = settings?.overrideModel
const effectiveModelScorer = settings?.modelScorer ?? ""
const overrideModelScorer = settings?.overrideModelScorer
const effectiveModelTailoring = settings?.modelTailoring ?? ""
const overrideModelTailoring = settings?.overrideModelTailoring
const effectiveModelProjectSelection = settings?.modelProjectSelection ?? ""
const overrideModelProjectSelection = settings?.overrideModelProjectSelection
const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? ""
const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? ""
const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
const effectiveUkvisajobsMaxJobs = settings?.ukvisajobsMaxJobs ?? 50
const defaultUkvisajobsMaxJobs = settings?.defaultUkvisajobsMaxJobs ?? 50
const overrideUkvisajobsMaxJobs = settings?.overrideUkvisajobsMaxJobs
const effectiveGradcrackerMaxJobsPerTerm = settings?.gradcrackerMaxJobsPerTerm ?? 50
const defaultGradcrackerMaxJobsPerTerm = settings?.defaultGradcrackerMaxJobsPerTerm ?? 50
const overrideGradcrackerMaxJobsPerTerm = settings?.overrideGradcrackerMaxJobsPerTerm
const effectiveSearchTerms = settings?.searchTerms ?? []
const defaultSearchTerms = settings?.defaultSearchTerms ?? []
const overrideSearchTerms = settings?.overrideSearchTerms
const effectiveJobspyLocation = settings?.jobspyLocation ?? ""
const defaultJobspyLocation = settings?.defaultJobspyLocation ?? ""
const overrideJobspyLocation = settings?.overrideJobspyLocation
const effectiveJobspyResultsWanted = settings?.jobspyResultsWanted ?? 200
const defaultJobspyResultsWanted = settings?.defaultJobspyResultsWanted ?? 200
const overrideJobspyResultsWanted = settings?.overrideJobspyResultsWanted
const effectiveJobspyHoursOld = settings?.jobspyHoursOld ?? 72
const defaultJobspyHoursOld = settings?.defaultJobspyHoursOld ?? 72
const overrideJobspyHoursOld = settings?.overrideJobspyHoursOld
const effectiveJobspyCountryIndeed = settings?.jobspyCountryIndeed ?? ""
const defaultJobspyCountryIndeed = settings?.defaultJobspyCountryIndeed ?? ""
const overrideJobspyCountryIndeed = settings?.overrideJobspyCountryIndeed
const effectiveJobspySites = settings?.jobspySites ?? ["indeed", "linkedin"]
const defaultJobspySites = settings?.defaultJobspySites ?? ["indeed", "linkedin"]
const overrideJobspySites = settings?.overrideJobspySites
const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true
const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true
const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription
const effectiveShowSponsorInfo = settings?.showSponsorInfo ?? true
const defaultShowSponsorInfo = settings?.defaultShowSponsorInfo ?? true
const overrideShowSponsorInfo = settings?.overrideShowSponsorInfo
const profileProjects = settings?.profileProjects ?? []
const maxProjectsTotal = profileProjects.length
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
const derived = getDerivedSettings(settings)
const {
model,
pipelineWebhook,
jobCompleteWebhook,
ukvisajobs,
gradcracker,
searchTerms,
jobspy,
display,
defaultResumeProjects,
profileProjects,
maxProjectsTotal,
} = derived
const canSave = useMemo(() => {
if (!settings || !resumeProjectsDraft) return false
const next = modelDraft.trim()
const current = (overrideModel ?? "").trim()
const nextScorer = modelScorerDraft.trim()
const currentScorer = (overrideModelScorer ?? "").trim()
const nextTailoring = modelTailoringDraft.trim()
const currentTailoring = (overrideModelTailoring ?? "").trim()
const nextProjectSelection = modelProjectSelectionDraft.trim()
const currentProjectSelection = (overrideModelProjectSelection ?? "").trim()
const nextWebhook = pipelineWebhookUrlDraft.trim()
const currentWebhook = (overridePipelineWebhookUrl ?? "").trim()
const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim()
const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim()
const ukvisajobsChanged = ukvisajobsMaxJobsDraft !== (overrideUkvisajobsMaxJobs ?? null)
const gradcrackerChanged = gradcrackerMaxJobsPerTermDraft !== (overrideGradcrackerMaxJobsPerTerm ?? null)
const searchTermsChanged = JSON.stringify(searchTermsDraft) !== JSON.stringify(overrideSearchTerms ?? null)
return (
next !== current ||
nextScorer !== currentScorer ||
nextTailoring !== currentTailoring ||
nextProjectSelection !== currentProjectSelection ||
nextWebhook !== currentWebhook ||
nextJobCompleteWebhook !== currentJobCompleteWebhook ||
!resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) ||
ukvisajobsChanged ||
gradcrackerChanged ||
searchTermsChanged ||
jobspyLocationDraft !== (overrideJobspyLocation ?? null) ||
jobspyResultsWantedDraft !== (overrideJobspyResultsWanted ?? null) ||
jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) ||
jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) ||
JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) ||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ||
showSponsorInfoDraft !== (overrideShowSponsorInfo ?? null)
)
}, [
settings,
modelDraft,
modelScorerDraft,
modelTailoringDraft,
modelProjectSelectionDraft,
pipelineWebhookUrlDraft,
jobCompleteWebhookUrlDraft,
overrideModel,
overrideModelScorer,
overrideModelTailoring,
overrideModelProjectSelection,
overridePipelineWebhookUrl,
overrideJobCompleteWebhookUrl,
resumeProjectsDraft,
ukvisajobsMaxJobsDraft,
overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTermDraft,
overrideGradcrackerMaxJobsPerTerm,
searchTermsDraft,
overrideSearchTerms,
jobspyLocationDraft,
jobspyResultsWantedDraft,
jobspyHoursOldDraft,
jobspyCountryIndeedDraft,
jobspySitesDraft,
jobspyLinkedinFetchDescriptionDraft,
showSponsorInfoDraft,
overrideJobspyLocation,
overrideJobspyResultsWanted,
overrideJobspyHoursOld,
overrideJobspyCountryIndeed,
overrideJobspySites,
overrideJobspyLinkedinFetchDescription,
overrideShowSponsorInfo,
])
const watchedValues = watch()
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
const handleSave = async () => {
if (!settings || !resumeProjectsDraft) return
const canSave = isDirty && isValid
const onSave = async (data: UpdateSettingsInput) => {
if (!settings) return
try {
setIsSaving(true)
const trimmed = modelDraft.trim()
const trimmedScorer = modelScorerDraft.trim()
const trimmedTailoring = modelTailoringDraft.trim()
const trimmedProjectSelection = modelProjectSelectionDraft.trim()
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects)
// Prepare payload: nullify if equal to default
const resumeProjectsData = data.resumeProjects
const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects))
? null
: resumeProjectsDraft
const ukvisajobsMaxJobsOverride = ukvisajobsMaxJobsDraft === defaultUkvisajobsMaxJobs ? null : ukvisajobsMaxJobsDraft
const gradcrackerMaxJobsPerTermOverride = gradcrackerMaxJobsPerTermDraft === defaultGradcrackerMaxJobsPerTerm ? null : gradcrackerMaxJobsPerTermDraft
const searchTermsOverride = arraysEqual(searchTermsDraft ?? [], defaultSearchTerms) ? null : searchTermsDraft
const jobspyLocationOverride = jobspyLocationDraft === defaultJobspyLocation ? null : jobspyLocationDraft
const jobspyResultsWantedOverride = jobspyResultsWantedDraft === defaultJobspyResultsWanted ? null : jobspyResultsWantedDraft
const jobspyHoursOldOverride = jobspyHoursOldDraft === defaultJobspyHoursOld ? null : jobspyHoursOldDraft
const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft
const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft
const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft
const showSponsorInfoOverride = showSponsorInfoDraft === defaultShowSponsorInfo ? null : showSponsorInfoDraft
const updated = await api.updateSettings({
model: trimmed.length > 0 ? trimmed : null,
modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null,
modelTailoring: trimmedTailoring.length > 0 ? trimmedTailoring : null,
modelProjectSelection: trimmedProjectSelection.length > 0 ? trimmedProjectSelection : null,
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
: resumeProjectsData
const payload: UpdateSettingsInput = {
model: normalizeString(data.model),
modelScorer: normalizeString(data.modelScorer),
modelTailoring: normalizeString(data.modelTailoring),
modelProjectSelection: normalizeString(data.modelProjectSelection),
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride,
ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride,
gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride,
searchTerms: searchTermsOverride,
jobspyLocation: jobspyLocationOverride,
jobspyResultsWanted: jobspyResultsWantedOverride,
jobspyHoursOld: jobspyHoursOldOverride,
jobspyCountryIndeed: jobspyCountryIndeedOverride,
jobspySites: jobspySitesOverride,
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
showSponsorInfo: showSponsorInfoOverride,
})
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default),
jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default),
jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default),
jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default),
jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default),
jobspyLinkedinFetchDescription: nullIfSame(
data.jobspyLinkedinFetchDescription,
jobspy.linkedinFetchDescription.default
),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
}
const updated = await api.updateSettings(payload)
setSettings(updated)
setModelDraft(updated.overrideModel ?? "")
setModelScorerDraft(updated.overrideModelScorer ?? "")
setModelTailoringDraft(updated.overrideModelTailoring ?? "")
setModelProjectSelectionDraft(updated.overrideModelProjectSelection ?? "")
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
setResumeProjectsDraft(updated.resumeProjects)
setUkvisajobsMaxJobsDraft(updated.overrideUkvisajobsMaxJobs)
setGradcrackerMaxJobsPerTermDraft(updated.overrideGradcrackerMaxJobsPerTerm)
setSearchTermsDraft(updated.overrideSearchTerms)
setJobspyLocationDraft(updated.overrideJobspyLocation)
setJobspyResultsWantedDraft(updated.overrideJobspyResultsWanted)
setJobspyHoursOldDraft(updated.overrideJobspyHoursOld)
setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed)
setJobspySitesDraft(updated.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(updated.overrideShowSponsorInfo)
reset(mapSettingsToForm(updated))
toast.success("Settings saved")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings"
@ -277,6 +279,7 @@ export const SettingsPage: React.FC = () => {
setIsSaving(false)
}
}
const handleClearDatabase = async () => {
try {
setIsSaving(true)
@ -335,43 +338,9 @@ export const SettingsPage: React.FC = () => {
const handleReset = async () => {
try {
setIsSaving(true)
const updated = await api.updateSettings({
model: null,
modelScorer: null,
modelTailoring: null,
modelProjectSelection: null,
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
})
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD)
setSettings(updated)
setModelDraft("")
setModelScorerDraft("")
setModelTailoringDraft("")
setModelProjectSelectionDraft("")
setPipelineWebhookUrlDraft("")
setJobCompleteWebhookUrlDraft("")
setResumeProjectsDraft(updated.resumeProjects)
setUkvisajobsMaxJobsDraft(null)
setGradcrackerMaxJobsPerTermDraft(null)
setSearchTermsDraft(null)
setJobspyLocationDraft(null)
setJobspyResultsWantedDraft(null)
setJobspyHoursOldDraft(null)
setJobspyCountryIndeedDraft(null)
setJobspySitesDraft(null)
setJobspyLinkedinFetchDescriptionDraft(null)
setShowSponsorInfoDraft(null)
reset(mapSettingsToForm(updated))
toast.success("Reset to default")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings"
@ -382,7 +351,7 @@ export const SettingsPage: React.FC = () => {
}
return (
<>
<FormProvider {...methods}>
<PageHeader
icon={Settings}
title="Settings"
@ -392,93 +361,41 @@ export const SettingsPage: React.FC = () => {
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion type="multiple" className="w-full space-y-4">
<ModelSettingsSection
modelDraft={modelDraft}
setModelDraft={setModelDraft}
modelScorerDraft={modelScorerDraft}
setModelScorerDraft={setModelScorerDraft}
modelTailoringDraft={modelTailoringDraft}
setModelTailoringDraft={setModelTailoringDraft}
modelProjectSelectionDraft={modelProjectSelectionDraft}
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
effectiveModel={effectiveModel}
effectiveModelScorer={effectiveModelScorer}
effectiveModelTailoring={effectiveModelTailoring}
effectiveModelProjectSelection={effectiveModelProjectSelection}
defaultModel={defaultModel}
values={model}
isLoading={isLoading}
isSaving={isSaving}
/>
<PipelineWebhookSection
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
values={pipelineWebhook}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobCompleteWebhookSection
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
values={jobCompleteWebhook}
isLoading={isLoading}
isSaving={isSaving}
/>
<UkvisajobsSection
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
values={ukvisajobs}
isLoading={isLoading}
isSaving={isSaving}
/>
<GradcrackerSection
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
values={gradcracker}
isLoading={isLoading}
isSaving={isSaving}
/>
<SearchTermsSection
searchTermsDraft={searchTermsDraft}
setSearchTermsDraft={setSearchTermsDraft}
defaultSearchTerms={defaultSearchTerms}
effectiveSearchTerms={effectiveSearchTerms}
values={searchTerms}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={defaultJobspySites}
effectiveJobspySites={effectiveJobspySites}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation={defaultJobspyLocation}
effectiveJobspyLocation={effectiveJobspyLocation}
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={defaultJobspyHoursOld}
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
values={jobspy}
isLoading={isLoading}
isSaving={isSaving}
/>
<ResumeProjectsSection
resumeProjectsDraft={resumeProjectsDraft}
setResumeProjectsDraft={setResumeProjectsDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={maxProjectsTotal}
@ -486,10 +403,7 @@ export const SettingsPage: React.FC = () => {
isSaving={isSaving}
/>
<DisplaySettingsSection
showSponsorInfoDraft={showSponsorInfoDraft}
setShowSponsorInfoDraft={setShowSponsorInfoDraft}
defaultShowSponsorInfo={defaultShowSponsorInfo}
effectiveShowSponsorInfo={effectiveShowSponsorInfo}
values={display}
isLoading={isLoading}
isSaving={isSaving}
/>
@ -504,14 +418,19 @@ export const SettingsPage: React.FC = () => {
</Accordion>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
<Button onClick={handleSubmit(onSave)} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
{Object.keys(errors).length > 0 && (
<div className="text-destructive text-sm mt-2">
Please fix the errors before saving.
</div>
)}
</main>
</>
</FormProvider>
)
}

View File

@ -16,7 +16,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { JobStatus } from "@shared/types"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants"
type DangerZoneSectionProps = {
statusesToClear: JobStatus[]

View File

@ -1,27 +1,25 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { DisplayValues } from "@client/pages/settings/types"
type DisplaySettingsSectionProps = {
showSponsorInfoDraft: boolean | null
setShowSponsorInfoDraft: (value: boolean | null) => void
defaultShowSponsorInfo: boolean
effectiveShowSponsorInfo: boolean
values: DisplayValues
isLoading: boolean
isSaving: boolean
}
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
showSponsorInfoDraft,
setShowSponsorInfoDraft,
defaultShowSponsorInfo,
effectiveShowSponsorInfo,
values,
isLoading,
isSaving,
}) => {
const isChecked = showSponsorInfoDraft ?? defaultShowSponsorInfo
const { default: defaultShowSponsorInfo, effective: effectiveShowSponsorInfo } = values
const { control } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="display" className="border rounded-lg px-4">
@ -31,13 +29,19 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="showSponsorInfo"
checked={isChecked}
onCheckedChange={(checked) => {
setShowSponsorInfoDraft(checked === "indeterminate" ? null : checked === true)
}}
disabled={isLoading || isSaving}
<Controller
name="showSponsorInfo"
control={control}
render={({ field }) => (
<Checkbox
id="showSponsorInfo"
checked={field.value ?? defaultShowSponsorInfo}
onCheckedChange={(checked) => {
field.onChange(checked === "indeterminate" ? null : checked === true)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label

View File

@ -1,26 +1,26 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
type GradcrackerSectionProps = {
gradcrackerMaxJobsPerTermDraft: number | null
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
defaultGradcrackerMaxJobsPerTerm: number
effectiveGradcrackerMaxJobsPerTerm: number
values: NumericSettingValues
isLoading: boolean
isSaving: boolean
}
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
gradcrackerMaxJobsPerTermDraft,
setGradcrackerMaxJobsPerTermDraft,
defaultGradcrackerMaxJobsPerTerm,
effectiveGradcrackerMaxJobsPerTerm,
values,
isLoading,
isSaving,
}) => {
const { effective: effectiveGradcrackerMaxJobsPerTerm, default: defaultGradcrackerMaxJobsPerTerm } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -30,22 +30,29 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs per search term</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setGradcrackerMaxJobsPerTermDraft(null)
} else {
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="gradcrackerMaxJobsPerTerm"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.gradcrackerMaxJobsPerTerm && <p className="text-xs text-destructive">{errors.gradcrackerMaxJobsPerTerm.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
</div>

View File

@ -1,26 +1,26 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { WebhookValues } from "@client/pages/settings/types"
type JobCompleteWebhookSectionProps = {
jobCompleteWebhookUrlDraft: string
setJobCompleteWebhookUrlDraft: (value: string) => void
defaultJobCompleteWebhookUrl: string
effectiveJobCompleteWebhookUrl: string
values: WebhookValues
isLoading: boolean
isSaving: boolean
}
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
jobCompleteWebhookUrlDraft,
setJobCompleteWebhookUrlDraft,
defaultJobCompleteWebhookUrl,
effectiveJobCompleteWebhookUrl,
values,
isLoading,
isSaving,
}) => {
const { default: defaultJobCompleteWebhookUrl, effective: effectiveJobCompleteWebhookUrl } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -31,11 +31,11 @@ export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps>
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
{...register("jobCompleteWebhookUrl")}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.jobCompleteWebhookUrl && <p className="text-xs text-destructive">{errors.jobCompleteWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>

View File

@ -1,52 +1,44 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { useState } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { JobspySection } from "./JobspySection"
import { UpdateSettingsInput } from "@shared/settings-schema"
const JobspyHarness = () => {
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
jobspySites: ["indeed", "linkedin"],
jobspyLocation: "UK",
jobspyResultsWanted: 200,
jobspyHoursOld: 72,
jobspyCountryIndeed: "UK",
jobspyLinkedinFetchDescription: true,
}
})
return (
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={["indeed", "linkedin"]}
effectiveJobspySites={["indeed", "linkedin"]}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation="UK"
effectiveJobspyLocation="UK"
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={200}
effectiveJobspyResultsWanted={200}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={72}
effectiveJobspyHoursOld={72}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed="UK"
effectiveJobspyCountryIndeed="UK"
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={true}
effectiveJobspyLinkedinFetchDescription={true}
isLoading={false}
isSaving={false}
/>
</Accordion>
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
values={{
sites: { default: ["indeed", "linkedin"], effective: ["indeed", "linkedin"] },
location: { default: "UK", effective: "UK" },
resultsWanted: { default: 200, effective: 200 },
hoursOld: { default: 72, effective: 72 },
countryIndeed: { default: "UK", effective: "UK" },
linkedinFetchDescription: { default: true, effective: true },
}}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("JobspySection", () => {
it("toggles scraped sites and keeps checkboxes in sync", () => {
render(<JobspyHarness />)
@ -72,8 +64,8 @@ describe("JobspySection", () => {
const resultsWantedInput = numericInputs[0]
const hoursOldInput = numericInputs[1]
fireEvent.change(resultsWantedInput, { target: { value: "999" } })
expect(resultsWantedInput).toHaveValue(500)
fireEvent.change(resultsWantedInput, { target: { value: "1001" } })
expect(resultsWantedInput).toHaveValue(1000)
fireEvent.change(hoursOldInput, { target: { value: "0" } })
expect(hoursOldInput).toHaveValue(1)

View File

@ -1,67 +1,34 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { JobspyValues } from "@client/pages/settings/types"
type JobspySectionProps = {
jobspySitesDraft: string[] | null
setJobspySitesDraft: (value: string[] | null) => void
defaultJobspySites: string[]
effectiveJobspySites: string[]
jobspyLocationDraft: string | null
setJobspyLocationDraft: (value: string | null) => void
defaultJobspyLocation: string
effectiveJobspyLocation: string
jobspyResultsWantedDraft: number | null
setJobspyResultsWantedDraft: (value: number | null) => void
defaultJobspyResultsWanted: number
effectiveJobspyResultsWanted: number
jobspyHoursOldDraft: number | null
setJobspyHoursOldDraft: (value: number | null) => void
defaultJobspyHoursOld: number
effectiveJobspyHoursOld: number
jobspyCountryIndeedDraft: string | null
setJobspyCountryIndeedDraft: (value: string | null) => void
defaultJobspyCountryIndeed: string
effectiveJobspyCountryIndeed: string
jobspyLinkedinFetchDescriptionDraft: boolean | null
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
defaultJobspyLinkedinFetchDescription: boolean
effectiveJobspyLinkedinFetchDescription: boolean
values: JobspyValues
isLoading: boolean
isSaving: boolean
}
export const JobspySection: React.FC<JobspySectionProps> = ({
jobspySitesDraft,
setJobspySitesDraft,
defaultJobspySites,
effectiveJobspySites,
jobspyLocationDraft,
setJobspyLocationDraft,
defaultJobspyLocation,
effectiveJobspyLocation,
jobspyResultsWantedDraft,
setJobspyResultsWantedDraft,
defaultJobspyResultsWanted,
effectiveJobspyResultsWanted,
jobspyHoursOldDraft,
setJobspyHoursOldDraft,
defaultJobspyHoursOld,
effectiveJobspyHoursOld,
jobspyCountryIndeedDraft,
setJobspyCountryIndeedDraft,
defaultJobspyCountryIndeed,
effectiveJobspyCountryIndeed,
jobspyLinkedinFetchDescriptionDraft,
setJobspyLinkedinFetchDescriptionDraft,
defaultJobspyLinkedinFetchDescription,
effectiveJobspyLinkedinFetchDescription,
values,
isLoading,
isSaving,
}) => {
const {
sites,
location,
resultsWanted,
hoursOld,
countryIndeed,
linkedinFetchDescription,
} = values
const { control, register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -73,48 +40,61 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Checkbox
id="site-indeed"
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-indeed"
checked={field.value?.includes('indeed') ?? sites.default.includes('indeed')}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
field.onChange(next)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="site-linkedin"
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-linkedin"
checked={field.value?.includes('linkedin') ?? sites.default.includes('linkedin')}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
field.onChange(next)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
</div>
</div>
{errors.jobspySites && <p className="text-xs text-destructive">{errors.jobspySites.message}</p>}
<div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
<span>Effective: {(sites.effective || []).join(', ') || "None"}</span>
<span>Default: {(sites.default || []).join(', ')}</span>
</div>
</div>
@ -122,88 +102,102 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
value={jobspyLocationDraft ?? defaultJobspyLocation}
onChange={(event) => setJobspyLocationDraft(event.target.value)}
placeholder={defaultJobspyLocation || "UK"}
{...register("jobspyLocation")}
placeholder={location.default || "UK"}
disabled={isLoading || isSaving}
/>
{errors.jobspyLocation && <p className="text-xs text-destructive">{errors.jobspyLocation.message}</p>}
<div className="text-xs text-muted-foreground">
Location to search for jobs (e.g. "UK", "London", "Remote").
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLocation || "—"}</span>
<span>Default: {defaultJobspyLocation || "—"}</span>
<span>Effective: {location.effective || "—"}</span>
<span>Default: {location.default || "—"}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={500}
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyResultsWantedDraft(null)
} else {
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="jobspyResultsWanted"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? resultsWanted.default}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.jobspyResultsWanted && <p className="text-xs text-destructive">{errors.jobspyResultsWanted.message}</p>}
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 500.
Number of results to fetch per term per site. Max 1000.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyResultsWanted}</span>
<span>Default: {defaultJobspyResultsWanted}</span>
<span>Effective: {resultsWanted.effective}</span>
<span>Default: {resultsWanted.default}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={168}
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyHoursOldDraft(null)
} else {
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="jobspyHoursOld"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={720}
value={field.value ?? hoursOld.default}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(720, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.jobspyHoursOld && <p className="text-xs text-destructive">{errors.jobspyHoursOld.message}</p>}
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days).
Max age of jobs in hours (e.g. 72 for 3 days). Max 720 (30 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyHoursOld}h</span>
<span>Default: {defaultJobspyHoursOld}h</span>
<span>Effective: {hoursOld.effective}h</span>
<span>Default: {hoursOld.default}h</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
placeholder={defaultJobspyCountryIndeed || "UK"}
{...register("jobspyCountryIndeed")}
placeholder={countryIndeed.default || "UK"}
disabled={isLoading || isSaving}
/>
{errors.jobspyCountryIndeed && <p className="text-xs text-destructive">{errors.jobspyCountryIndeed.message}</p>}
<div className="text-xs text-muted-foreground">
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
<span>Effective: {countryIndeed.effective || "—"}</span>
<span>Default: {countryIndeed.default || "—"}</span>
</div>
</div>
</div>
@ -211,11 +205,17 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="linkedin-desc"
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
disabled={isLoading || isSaving}
<Controller
name="jobspyLinkedinFetchDescription"
control={control}
render={({ field }) => (
<Checkbox
id="linkedin-desc"
checked={field.value ?? linkedinFetchDescription.default}
onCheckedChange={(checked) => field.onChange(!!checked)}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="grid gap-1.5 leading-none">
<label
@ -228,8 +228,8 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}</span>
<span>Default: {linkedinFetchDescription.default ? "Yes" : "No"}</span>
</div>
</div>
</div>

View File

@ -1,44 +1,26 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { ModelValues } from "@client/pages/settings/types"
type ModelSettingsSectionProps = {
modelDraft: string
setModelDraft: (value: string) => void
modelScorerDraft: string
setModelScorerDraft: (value: string) => void
modelTailoringDraft: string
setModelTailoringDraft: (value: string) => void
modelProjectSelectionDraft: string
setModelProjectSelectionDraft: (value: string) => void
effectiveModel: string
effectiveModelScorer: string
effectiveModelTailoring: string
effectiveModelProjectSelection: string
defaultModel: string
values: ModelValues
isLoading: boolean
isSaving: boolean
}
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
modelDraft,
setModelDraft,
modelScorerDraft,
setModelScorerDraft,
modelTailoringDraft,
setModelTailoringDraft,
modelProjectSelectionDraft,
setModelProjectSelectionDraft,
effectiveModel,
effectiveModelScorer,
effectiveModelTailoring,
effectiveModelProjectSelection,
defaultModel,
values,
isLoading,
isSaving,
}) => {
const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -49,11 +31,11 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
{...register("model")}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
{errors.model && <p className="text-xs text-destructive">{errors.model.message}</p>}
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
@ -68,39 +50,39 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="space-y-2">
<div className="text-sm">Scoring Model</div>
<Input
value={modelScorerDraft}
onChange={(event) => setModelScorerDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
{...register("modelScorer")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelScorer && <p className="text-xs text-destructive">{errors.modelScorer.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
Effective: <span className="font-mono">{scorer || effective}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
value={modelTailoringDraft}
onChange={(event) => setModelTailoringDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
{...register("modelTailoring")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelTailoring && <p className="text-xs text-destructive">{errors.modelTailoring.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
Effective: <span className="font-mono">{tailoring || effective}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
value={modelProjectSelectionDraft}
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
{...register("modelProjectSelection")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelProjectSelection && <p className="text-xs text-destructive">{errors.modelProjectSelection.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
Effective: <span className="font-mono">{projectSelection || effective}</span>
</div>
</div>
</div>
@ -111,7 +93,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Global Effective</div>
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
<div className="break-words font-mono text-xs">{effective || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>

View File

@ -1,26 +1,26 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { WebhookValues } from "@client/pages/settings/types"
type PipelineWebhookSectionProps = {
pipelineWebhookUrlDraft: string
setPipelineWebhookUrlDraft: (value: string) => void
defaultPipelineWebhookUrl: string
effectivePipelineWebhookUrl: string
values: WebhookValues
isLoading: boolean
isSaving: boolean
}
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
pipelineWebhookUrlDraft,
setPipelineWebhookUrlDraft,
defaultPipelineWebhookUrl,
effectivePipelineWebhookUrl,
values,
isLoading,
isSaving,
}) => {
const { default: defaultPipelineWebhookUrl, effective: effectivePipelineWebhookUrl } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -31,11 +31,11 @@ export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
{...register("pipelineWebhookUrl")}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.pipelineWebhookUrl && <p className="text-xs text-destructive">{errors.pipelineWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>

View File

@ -1,10 +1,11 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { useState } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { ResumeProjectsSection } from "./ResumeProjectsSection"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { UpdateSettingsInput } from "@shared/settings-schema"
const profileProjects: ResumeProjectCatalogItem[] = [
{
@ -23,25 +24,31 @@ const profileProjects: ResumeProjectCatalogItem[] = [
},
]
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => {
const [draft, setDraft] = useState<ResumeProjectsSettings | null>(initialDraft)
const lockedCount = draft?.lockedProjectIds.length ?? 0
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
resumeProjects: initialDraft
}
})
const watched = methods.watch()
const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0
return (
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
resumeProjectsDraft={draft}
setResumeProjectsDraft={setDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("ResumeProjectsSection", () => {
it("clamps max projects to the locked count", async () => {
render(

View File

@ -1,16 +1,16 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { clampInt } from "@/lib/utils"
import { UpdateSettingsInput } from "@shared/settings-schema"
type ResumeProjectsSectionProps = {
resumeProjectsDraft: ResumeProjectsSettings | null
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
@ -19,14 +19,14 @@ type ResumeProjectsSectionProps = {
}
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
resumeProjectsDraft,
setResumeProjectsDraft,
profileProjects,
lockedCount,
maxProjectsTotal,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -36,113 +36,126 @@ export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsDraft?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsDraft) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !resumeProjectsDraft}
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
if (!field.value) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
field.onChange({ ...field.value, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !field.value}
/>
)}
/>
{errors.resumeProjects?.maxProjects && <p className="text-xs text-destructive">{errors.resumeProjects.maxProjects.message}</p>}
<div className="text-xs text-muted-foreground">
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length}
</div>
</div>
<Separator />
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
)
})}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const lockedIds = field.value.lockedProjectIds.slice()
const selectableIds = field.value.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
field.onChange({
...field.value,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(field.value.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
field.onChange({
...field.value,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const selectableIds = field.value.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -1,25 +1,25 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { SearchTermsValues } from "@client/pages/settings/types"
type SearchTermsSectionProps = {
searchTermsDraft: string[] | null
setSearchTermsDraft: (value: string[] | null) => void
defaultSearchTerms: string[]
effectiveSearchTerms: string[]
values: SearchTermsValues
isLoading: boolean
isSaving: boolean
}
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
searchTermsDraft,
setSearchTermsDraft,
defaultSearchTerms,
effectiveSearchTerms,
values,
isLoading,
isSaving,
}) => {
const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -29,24 +29,30 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
setSearchTermsDraft(terms)
}}
onBlur={() => {
// Clean up on blur
if (searchTermsDraft) {
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
<Controller
name="searchTerms"
control={control}
render={({ field }) => (
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value ? field.value.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n')
field.onChange(terms)
}}
onBlur={() => {
if (field.value) {
field.onChange(field.value.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
)}
/>
{errors.searchTerms && <p className="text-xs text-destructive">{errors.searchTerms.message}</p>}
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors.
</div>

View File

@ -1,26 +1,26 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
type UkvisajobsSectionProps = {
ukvisajobsMaxJobsDraft: number | null
setUkvisajobsMaxJobsDraft: (value: number | null) => void
defaultUkvisajobsMaxJobs: number
effectiveUkvisajobsMaxJobs: number
values: NumericSettingValues
isLoading: boolean
isSaving: boolean
}
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
ukvisajobsMaxJobsDraft,
setUkvisajobsMaxJobsDraft,
defaultUkvisajobsMaxJobs,
effectiveUkvisajobsMaxJobs,
values,
isLoading,
isSaving,
}) => {
const { effective: effectiveUkvisajobsMaxJobs, default: defaultUkvisajobsMaxJobs } = values
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -30,22 +30,29 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs to fetch</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setUkvisajobsMaxJobsDraft(null)
} else {
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="ukvisajobsMaxJobs"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.ukvisajobsMaxJobs && <p className="text-xs text-destructive">{errors.ukvisajobsMaxJobs.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
</div>

View File

@ -0,0 +1,24 @@
export type EffectiveDefault<T> = {
effective: T
default: T
}
export type ModelValues = EffectiveDefault<string> & {
scorer: string
tailoring: string
projectSelection: string
}
export type WebhookValues = EffectiveDefault<string>
export type NumericSettingValues = EffectiveDefault<number>
export type SearchTermsValues = EffectiveDefault<string[]>
export type DisplayValues = EffectiveDefault<boolean>
export type JobspyValues = {
sites: EffectiveDefault<string[]>
location: EffectiveDefault<string>
resultsWanted: EffectiveDefault<number>
hoursOld: EffectiveDefault<number>
countryIndeed: EffectiveDefault<string>
linkedinFetchDescription: EffectiveDefault<boolean>
}

View File

@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as settingsRepo from '../../repositories/settings.js';
import { updateSettingsSchema } from '@shared/settings-schema.js';
import * as settingsRepo from '@server/repositories/settings.js';
import {
extractProjectsFromProfile,
normalizeResumeProjectsSettings,
resolveResumeProjectsSettings,
} from '../../services/resumeProjects.js';
import { getProfile } from '../../services/profile.js';
} from '@server/services/resumeProjects.js';
import { getProfile } from '@server/services/profile.js';
export const settingsRouter = Router();
@ -154,30 +154,6 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
}
});
const updateSettingsSchema = z.object({
model: z.string().trim().min(1).max(200).nullable().optional(),
modelScorer: z.string().trim().min(1).max(200).nullable().optional(),
modelTailoring: z.string().trim().min(1).max(200).nullable().optional(),
modelProjectSelection: z.string().trim().min(1).max(200).nullable().optional(),
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
resumeProjects: z.object({
maxProjects: z.number().int().min(0).max(50),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
}).nullable().optional(),
ukvisajobsMaxJobs: z.number().int().min(1).max(200).nullable().optional(),
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(200).nullable().optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(50).nullable().optional(),
jobspyLocation: z.string().trim().min(1).max(100).nullable().optional(),
jobspyResultsWanted: z.number().int().min(1).max(500).nullable().optional(),
jobspyHoursOld: z.number().int().min(1).max(168).nullable().optional(),
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
});
/**
* PATCH /api/settings - Update settings overrides
*/

View File

@ -0,0 +1,30 @@
import { z } from "zod";
export const resumeProjectsSchema = z.object({
maxProjects: z.number().int().min(0).max(100),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
});
export const updateSettingsSchema = z.object({
model: z.string().trim().max(200).nullable().optional(),
modelScorer: z.string().trim().max(200).nullable().optional(),
modelTailoring: z.string().trim().max(200).nullable().optional(),
modelProjectSelection: z.string().trim().max(200).nullable().optional(),
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
resumeProjects: resumeProjectsSchema.nullable().optional(),
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(1000).nullable().optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(),
jobspyLocation: z.string().trim().max(100).nullable().optional(),
jobspyResultsWanted: z.number().int().min(1).max(1000).nullable().optional(),
jobspyHoursOld: z.number().int().min(1).max(720).nullable().optional(),
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
jobspySites: z.array(z.string().trim().min(1).max(50)).max(20).nullable().optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
});
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
export type ResumeProjectsSettingsInput = z.infer<typeof resumeProjectsSchema>;

View File

@ -14,6 +14,7 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
'@client': path.resolve(__dirname, './src/client'),
'@server': path.resolve(__dirname, './src/server'),
'@shared': path.resolve(__dirname, './src/shared'),
},
},

View File

@ -23,24 +23,10 @@ SKIP_DIRS_DEFAULT = {
"data",
}
SKIP_EXTS_DEFAULT = {
".png",
".jpg",
".jpeg",
".gif",
".bmp",
".ico",
".pdf",
".zip",
".gz",
".tar",
".tgz",
".7z",
".exe",
".dll",
".so",
".dylib",
".bin",
ALLOWED_EXTS = {
".py",
".ts",
".tsx",
}
SPEC_BY_EXT = {
@ -120,7 +106,9 @@ def count_file(path: Path):
comment += 1
continue
if line_comments and any(stripped.startswith(prefix) for prefix in line_comments):
if line_comments and any(
stripped.startswith(prefix) for prefix in line_comments
):
comment += 1
continue
@ -195,8 +183,18 @@ def main():
path = Path(dirpath) / name
ext = path.suffix.lower()
if ext in SKIP_EXTS_DEFAULT:
if ext not in ALLOWED_EXTS:
continue
stem = path.stem.lower()
if (
stem.startswith("test_")
or stem.endswith("_test")
or stem.endswith(".test")
or stem.endswith(".spec")
):
continue
if is_binary(str(path)):
continue