From 22548217b39ea399ea6de1c9fb6db948db5738f1 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 1 Apr 2026 02:02:14 +1100 Subject: [PATCH 01/26] feat(sign-in and application tracking): added sign in and sign up with google and email and password added application tracking. local applications are stored in the browser's localStorage until they sign in where they are synced to the database. added a new page for my applications where they can view and manage their applications. --- frontend/.env.example | 11 + frontend/package-lock.json | 346 +++++++++++++++++- frontend/package.json | 3 + .../src/app/api/auth/[...nextauth]/route.ts | 7 + frontend/src/app/layout.tsx | 35 +- frontend/src/app/my-applications/actions.ts | 90 +++++ frontend/src/app/my-applications/page.tsx | 13 + frontend/src/app/sign-in/page.tsx | 88 +++++ frontend/src/app/sign-up/actions.ts | 43 +++ frontend/src/app/sign-up/page.tsx | 85 +++++ .../applications/my-applications-client.tsx | 191 ++++++++++ .../src/components/auth/session-provider.tsx | 9 + frontend/src/components/jobs/job-details.tsx | 43 ++- .../src/components/layout/nav-bar-mobile.tsx | 1 + frontend/src/components/layout/nav-links.tsx | 6 + frontend/src/lib/auth.ts | 73 ++++ frontend/src/lib/local-applications.ts | 55 +++ frontend/src/lib/mongodb.ts | 30 ++ frontend/src/types/application.ts | 31 ++ package-lock.json | 6 + 20 files changed, 1134 insertions(+), 32 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/app/api/auth/[...nextauth]/route.ts create mode 100644 frontend/src/app/my-applications/actions.ts create mode 100644 frontend/src/app/my-applications/page.tsx create mode 100644 frontend/src/app/sign-in/page.tsx create mode 100644 frontend/src/app/sign-up/actions.ts create mode 100644 frontend/src/app/sign-up/page.tsx create mode 100644 frontend/src/components/applications/my-applications-client.tsx create mode 100644 frontend/src/components/auth/session-provider.tsx create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/local-applications.ts create mode 100644 frontend/src/lib/mongodb.ts create mode 100644 frontend/src/types/application.ts create mode 100644 package-lock.json diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..6403b41 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +MONGODB_URI= +MONGODB_DATABASE=default + +# Auth.js (NextAuth) +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 + +# OAuth providers +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbf255c..9fd7831 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@auth/mongodb-adapter": "^3.11.1", "@mantine/core": "^7.17.0", "@mantine/hooks": "^7.16.1", "@mantine/notifications": "^7.17.1", @@ -16,13 +17,14 @@ "@tailwindcss/typography": "^0.5.16", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "bcryptjs": "^3.0.3", "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", - "mongodb": "^6.14.2", "next": "15.1.7", + "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", "react": "^19.0.0", @@ -76,6 +78,155 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/@auth/core": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", + "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/core/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@auth/mongodb-adapter": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@auth/mongodb-adapter/-/mongodb-adapter-3.11.1.tgz", + "integrity": "sha512-xY+VUkC3CNXct8UwQgBAQqXASqolSlIARg6oAm1378CtRN2650tQUCOEnGLNLmroVefUeP73M6t+TpGXq72vwQ==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.1" + }, + "peerDependencies": { + "mongodb": "^6" + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/@auth/core": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", + "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/mongodb-adapter/node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/@babel/runtime": { "version": "7.26.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", @@ -1155,6 +1306,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1248,6 +1408,14 @@ "node": ">=4" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1979,6 +2147,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2268,6 +2445,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2593,18 +2779,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/es-abstract": { "version": "1.23.9", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", @@ -4369,6 +4543,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4846,6 +5029,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4889,6 +5104,23 @@ "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", "license": "MIT" }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5019,6 +5251,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -5037,6 +5278,42 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5499,6 +5776,28 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5525,6 +5824,12 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -7052,6 +7357,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -7350,6 +7664,12 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 13f22ab..d3fb225 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "format": "npx prettier . --write" }, "dependencies": { + "@auth/mongodb-adapter": "^3.11.1", "@mantine/core": "^7.17.0", "@mantine/hooks": "^7.16.1", "@mantine/notifications": "^7.17.1", @@ -18,12 +19,14 @@ "@tailwindcss/typography": "^0.5.16", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "bcryptjs": "^3.0.3", "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", "mongodb": "^6.14.2", "next": "15.1.7", + "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", "react": "^19.0.0", diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..6d4ddf6 --- /dev/null +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,7 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; + diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index dbaf664..eec2cb2 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -22,6 +22,7 @@ import FeedbackButton from "@/components/ui/feedback-button"; import { Notifications } from "@mantine/notifications"; import FirstVisitNotification from "@/components/ui/first-visit-notification"; +import AuthSessionProvider from "@/components/auth/session-provider"; export const metadata: Metadata = { title: { @@ -53,22 +54,24 @@ export default function RootLayout({ children }: PropsWithChildren) { - - -
- - -
- {children} - - - - - -
-
-
-
+ + + +
+ + +
+ {children} + + + + + +
+
+
+
+
diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts new file mode 100644 index 0000000..f09b17a --- /dev/null +++ b/frontend/src/app/my-applications/actions.ts @@ -0,0 +1,90 @@ +"use server"; + +import clientPromise from "@/lib/mongodb"; +import { authOptions } from "@/lib/auth"; +import { getServerSession } from "next-auth"; +import { ObjectId } from "mongodb"; +import { ApplicationStatus, DbApplication, LocalApplication } from "@/types/application"; + +function requireUserId(session: Awaited>) { + const id = (session?.user as unknown as { id?: string } | undefined)?.id; + if (!id) throw new Error("Not authenticated"); + return id; +} + +export async function syncLocalApplications(apps: LocalApplication[]) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + if (!apps.length) return { ok: true, upserted: 0 }; + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const collection = db.collection("applications"); + + let upserted = 0; + + for (const app of apps) { + await collection.updateOne( + { userId: new ObjectId(userId), jobId: app.jobId }, + { + $setOnInsert: { + startedAt: new Date(app.startedAt), + }, + $set: { + updatedAt: new Date(app.updatedAt), + status: app.status, + jobSnapshot: app.jobSnapshot, + }, + }, + { upsert: true }, + ); + upserted += 1; + } + + return { ok: true, upserted }; +} + +export async function listApplications(): Promise { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + const docs = await db + .collection("applications") + .find({ userId: new ObjectId(userId) }) + .sort({ updatedAt: -1 }) + .limit(500) + .toArray(); + + return docs.map((d) => ({ + _id: d._id.toString(), + jobId: d.jobId, + status: d.status, + startedAt: new Date(d.startedAt).toISOString(), + updatedAt: new Date(d.updatedAt).toISOString(), + jobSnapshot: d.jobSnapshot, + })) as DbApplication[]; +} + +export async function updateApplicationStatus(jobId: string, status: ApplicationStatus) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + await db.collection("applications").updateOne( + { userId: new ObjectId(userId), jobId }, + { + $set: { status, updatedAt: new Date() }, + $setOnInsert: { startedAt: new Date() }, + }, + { upsert: true }, + ); + + return { ok: true }; +} + diff --git a/frontend/src/app/my-applications/page.tsx b/frontend/src/app/my-applications/page.tsx new file mode 100644 index 0000000..460493c --- /dev/null +++ b/frontend/src/app/my-applications/page.tsx @@ -0,0 +1,13 @@ +import MyApplicationsClient from "@/components/applications/my-applications-client"; +import { listApplications } from "./actions"; + +export default async function MyApplicationsPage() { + const apps = await listApplications().catch(() => []); + + return ( +
+ +
+ ); +} + diff --git a/frontend/src/app/sign-in/page.tsx b/frontend/src/app/sign-in/page.tsx new file mode 100644 index 0000000..91a0837 --- /dev/null +++ b/frontend/src/app/sign-in/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Alert, Button, Card, PasswordInput, TextInput } from "@mantine/core"; +import Link from "next/link"; +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; + +export default function SignInPage() { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") || "/my-applications"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + const res = await signIn("credentials", { + redirect: false, + email, + password, + callbackUrl, + }); + + if (res?.error) { + setError("Invalid email or password"); + setIsLoading(false); + return; + } + + if (res?.url) { + window.location.href = res.url; + } else { + window.location.href = callbackUrl; + } + }; + + return ( +
+ +

Sign in

+

+ Don’t have an account?{" "} + + Sign up + +

+ + {error && ( + + {error} + + )} + +
+ setEmail(e.currentTarget.value)} + required + /> + setPassword(e.currentTarget.value)} + required + /> + + + +
+
+ ); +} + diff --git a/frontend/src/app/sign-up/actions.ts b/frontend/src/app/sign-up/actions.ts new file mode 100644 index 0000000..c588052 --- /dev/null +++ b/frontend/src/app/sign-up/actions.ts @@ -0,0 +1,43 @@ +"use server"; + +import clientPromise from "@/lib/mongodb"; +import { hash } from "bcryptjs"; + +export async function registerUser(input: { + email: string; + password: string; + name?: string; +}) { + const email = input.email.toLowerCase().trim(); + const password = input.password; + const name = input.name?.trim(); + + if (!email || !password) { + throw new Error("Email and password are required"); + } + + if (password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + const existing = await db.collection("users").findOne({ email }); + if (existing) { + throw new Error("An account with that email already exists"); + } + + const passwordHash = await hash(password, 12); + + await db.collection("users").insertOne({ + email, + passwordHash, + name: name || null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + return { ok: true }; +} + diff --git a/frontend/src/app/sign-up/page.tsx b/frontend/src/app/sign-up/page.tsx new file mode 100644 index 0000000..ab85c20 --- /dev/null +++ b/frontend/src/app/sign-up/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Alert, Button, Card, PasswordInput, TextInput } from "@mantine/core"; +import Link from "next/link"; +import { useState } from "react"; +import { registerUser } from "./actions"; +import { signIn } from "next-auth/react"; + +export default function SignUpPage() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + try { + await registerUser({ name, email, password }); + await signIn("credentials", { + email, + password, + callbackUrl: "/my-applications", + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Sign up failed"); + setIsLoading(false); + } + }; + + return ( +
+ +

Create account

+

+ Already have an account?{" "} + + Sign in + +

+ + {error && ( + + {error} + + )} + +
+ setName(e.currentTarget.value)} + placeholder="Optional" + /> + setEmail(e.currentTarget.value)} + required + /> + setPassword(e.currentTarget.value)} + required + description="At least 8 characters" + /> + + + +
+
+ ); +} + diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx new file mode 100644 index 0000000..5f0b297 --- /dev/null +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { + Alert, + Badge, + Card, + Group, + Select, + Table, + Text, + Title, +} from "@mantine/core"; +import Link from "next/link"; +import { + APPLICATION_STATUSES, + ApplicationStatus, + DbApplication, +} from "@/types/application"; +import { + clearLocalApplications, + getLocalApplications, +} from "@/lib/local-applications"; +import { syncLocalApplications, updateApplicationStatus } from "@/app/my-applications/actions"; + +function statusColor(status: ApplicationStatus) { + switch (status) { + case "STARTED": + return "gray"; + case "APPLIED": + return "blue"; + case "INTERVIEW": + return "yellow"; + case "OFFER": + return "teal"; + case "ACCEPTED": + return "green"; + case "REJECTED": + return "red"; + case "WITHDREW": + return "orange"; + default: + return "gray"; + } +} + +export default function MyApplicationsClient({ initial }: { initial: DbApplication[] }) { + const { data: session, status: sessionStatus } = useSession(); + const [apps, setApps] = useState(initial); + const [syncMessage, setSyncMessage] = useState(null); + + useEffect(() => { + if (sessionStatus !== "authenticated") return; + const local = getLocalApplications(); + if (!local.length) return; + + (async () => { + try { + const res = await syncLocalApplications(local); + clearLocalApplications(); + setSyncMessage(`Synced ${res.upserted} application${res.upserted === 1 ? "" : "s"} from this device.`); + } catch { + setSyncMessage("Couldn’t sync local applications yet. Try refreshing."); + } + })(); + }, [sessionStatus]); + + const summary = useMemo(() => { + const counts = Object.fromEntries(APPLICATION_STATUSES.map((s) => [s, 0])) as Record< + ApplicationStatus, + number + >; + for (const a of apps) counts[a.status] += 1; + const total = apps.length; + const mostRecent = apps[0]?.updatedAt; + return { counts, total, mostRecent }; + }, [apps]); + + if (sessionStatus === "unauthenticated") { + return ( + + You’re not signed in.{" "} + + Sign in + {" "} + to view and manage your applications across devices. + + ); + } + + return ( +
+ {syncMessage && {syncMessage}} + + + +
+ Summary + + Total: {summary.total} + {summary.mostRecent ? ` · Updated ${new Date(summary.mostRecent).toLocaleString()}` : ""} + +
+ + {APPLICATION_STATUSES.map((s) => ( + + {s}: {summary.counts[s]} + + ))} + +
+
+ + + + My Applications + {session?.user ? ( + + Signed in as {session.user.email} + + ) : null} + + + + + + Role + Company + Status + Started + Updated + Link + + + + {apps.map((a) => ( + + {a.jobSnapshot.title} + {a.jobSnapshot.companyName} + +
+
+
+ ); +} + diff --git a/frontend/src/components/auth/session-provider.tsx b/frontend/src/components/auth/session-provider.tsx new file mode 100644 index 0000000..8d766c3 --- /dev/null +++ b/frontend/src/components/auth/session-provider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; +import { PropsWithChildren } from "react"; + +export default function AuthSessionProvider({ children }: PropsWithChildren) { + return {children}; +} + diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index 55aaa78..999d19d 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -1,19 +1,24 @@ // frontend/src/components/jobs/job-details.tsx "use client"; import { useEffect, useRef, useState } from "react"; -import { ActionIcon, Button, Card, ScrollArea } from "@mantine/core"; +import { ActionIcon, Button, Card, Modal, ScrollArea, Text } from "@mantine/core"; import { IconCheck, IconCopy, IconExternalLink } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; import JobDescription from "@/components/jobs/job-description"; import JobHeader from "@/components/jobs/job-header"; import JobDetailsLoading from "@/components/layout/job-details-loading"; import JobSummary from "@/components/jobs/job-summary"; +import { upsertLocalStartedApplication } from "@/lib/local-applications"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; export default function JobDetails() { const { selectedJob, isLoading } = useFilterContext(); const scrollRef = useRef(null); const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef(null); + const [showSigninModal, setShowSigninModal] = useState(false); + const { data: session } = useSession(); // Scroll to top whenever a new job is selected useEffect(() => { @@ -35,6 +40,10 @@ export default function JobDetails() { } const handleApplyClick = () => { + if (!session?.user) { + upsertLocalStartedApplication(selectedJob); + setShowSigninModal(true); + } window.open(selectedJob.application_url, "_blank"); }; @@ -57,7 +66,34 @@ export default function JobDetails() { }; return ( - + <> + setShowSigninModal(false)} + title="Track your applications" + centered + > + + Sign in to track all your applications across devices and manage + statuses like Applied, Rejected, Accepted, etc. + +
+ + +
+
+ + - +
+ ); } diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index ae0fcbc..a8eb5ac 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -14,6 +14,7 @@ export const NavBarMobile = () => { const menuItems = [ { href: "/", label: "Home" }, { href: "/jobs", label: "Jobs" }, + { href: "/sign-in", label: "Sign in" }, ]; return ( diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index e3b11c0..876826f 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -19,6 +19,12 @@ export default function NavLinks() { > Jobs + + Sign in + ); } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..9f80950 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,73 @@ +import type { NextAuthOptions } from "next-auth"; +import GoogleProvider from "next-auth/providers/google"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { MongoDBAdapter } from "@auth/mongodb-adapter"; +import clientPromise from "@/lib/mongodb"; +import { compare } from "bcryptjs"; +import { ObjectId } from "mongodb"; + +type DbUser = { + _id: ObjectId; + email?: string; + passwordHash?: string; + name?: string | null; +}; + +export const authOptions: NextAuthOptions = { + adapter: MongoDBAdapter(clientPromise, { + databaseName: process.env.MONGODB_DATABASE || "default", + }), + session: { + strategy: "jwt", + }, + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + }), + CredentialsProvider({ + name: "Email and password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const email = (credentials?.email || "").toLowerCase().trim(); + const password = credentials?.password || ""; + + if (!email || !password) return null; + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const user = (await db.collection("users").findOne({ email })) as DbUser | null; + if (!user?.passwordHash) return null; + + const ok = await compare(password, user.passwordHash); + if (!ok) return null; + + return { + id: user._id.toString(), + email: user.email, + name: user.name || undefined, + }; + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user?.id) token.id = user.id; + return token; + }, + async session({ session, token }) { + if (session.user && token?.id) { + // @ts-expect-error - add id onto session user + session.user.id = token.id; + } + return session; + }, + }, + pages: { + signIn: "/sign-in", + }, +}; + diff --git a/frontend/src/lib/local-applications.ts b/frontend/src/lib/local-applications.ts new file mode 100644 index 0000000..3badee4 --- /dev/null +++ b/frontend/src/lib/local-applications.ts @@ -0,0 +1,55 @@ +import { LocalApplication } from "@/types/application"; +import { Job } from "@/types/job"; + +const STORAGE_KEY = "mploy_applications_v1"; + +function safeParse(json: string | null): unknown { + if (!json) return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} + +export function getLocalApplications(): LocalApplication[] { + if (typeof window === "undefined") return []; + const raw = window.localStorage.getItem(STORAGE_KEY); + const parsed = safeParse(raw); + if (!Array.isArray(parsed)) return []; + return parsed as LocalApplication[]; +} + +export function setLocalApplications(apps: LocalApplication[]) { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(apps)); +} + +export function upsertLocalStartedApplication(job: Job): LocalApplication { + const now = new Date().toISOString(); + const apps = getLocalApplications(); + const existing = apps.find((a) => a.jobId === job.id); + + const next: LocalApplication = { + jobId: job.id, + status: existing?.status || "STARTED", + startedAt: existing?.startedAt || now, + updatedAt: now, + jobSnapshot: { + jobId: job.id, + title: job.title, + companyName: job.company?.name || "Unknown", + applicationUrl: job.application_url, + }, + }; + + const merged = [next, ...apps.filter((a) => a.jobId !== job.id)]; + setLocalApplications(merged); + return next; +} + +export function clearLocalApplications() { + if (typeof window === "undefined") return; + window.localStorage.removeItem(STORAGE_KEY); +} + diff --git a/frontend/src/lib/mongodb.ts b/frontend/src/lib/mongodb.ts new file mode 100644 index 0000000..fd13e4e --- /dev/null +++ b/frontend/src/lib/mongodb.ts @@ -0,0 +1,30 @@ +import { MongoClient } from "mongodb"; + +declare global { + // eslint-disable-next-line no-var + var __mongoClientPromise: Promise | undefined; +} + +const uri = process.env.MONGODB_URI; +if (!uri) { + throw new Error("Missing MONGODB_URI environment variable"); +} + +const options = {}; + +let client: MongoClient; +let clientPromise: Promise; + +if (process.env.NODE_ENV === "development") { + if (!global.__mongoClientPromise) { + client = new MongoClient(uri, options); + global.__mongoClientPromise = client.connect(); + } + clientPromise = global.__mongoClientPromise; +} else { + client = new MongoClient(uri, options); + clientPromise = client.connect(); +} + +export default clientPromise; + diff --git a/frontend/src/types/application.ts b/frontend/src/types/application.ts new file mode 100644 index 0000000..a588a3d --- /dev/null +++ b/frontend/src/types/application.ts @@ -0,0 +1,31 @@ +export const APPLICATION_STATUSES = [ + "STARTED", + "APPLIED", + "REJECTED", + "ACCEPTED", + "INTERVIEW", + "OFFER", + "WITHDREW", +] as const; + +export type ApplicationStatus = (typeof APPLICATION_STATUSES)[number]; + +export type ApplicationJobSnapshot = { + jobId: string; + title: string; + companyName: string; + applicationUrl?: string; +}; + +export type LocalApplication = { + jobId: string; + status: ApplicationStatus; + startedAt: string; + updatedAt: string; + jobSnapshot: ApplicationJobSnapshot; +}; + +export type DbApplication = LocalApplication & { + _id: string; +}; + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ec8ce84 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "mploy-app", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 297735689ee42b0dd460916ae15acb69a11f1d77 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 2 Apr 2026 13:05:06 +1100 Subject: [PATCH 02/26] feat(adding job): add a new job to the applications page --- frontend/src/app/my-applications/actions.ts | 20 ++++++ frontend/src/components/jobs/job-details.tsx | 13 +++- .../src/components/layout/nav-bar-mobile.tsx | 24 ++++++- frontend/src/components/layout/nav-links.tsx | 69 +++++++++++++++---- 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index f09b17a..e661d88 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -69,6 +69,26 @@ export async function listApplications(): Promise { })) as DbApplication[]; } +export async function addApplication(jobId: string, jobSnapshot: import("@/types/application").ApplicationJobSnapshot) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const now = new Date(); + + await db.collection("applications").updateOne( + { userId: new ObjectId(userId), jobId }, + { + $set: { updatedAt: now, jobSnapshot }, + $setOnInsert: { startedAt: now, status: "STARTED" }, + }, + { upsert: true }, + ); + + return { ok: true }; +} + export async function updateApplicationStatus(jobId: string, status: ApplicationStatus) { const session = await getServerSession(authOptions); const userId = requireUserId(session); diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index 999d19d..0639506 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -9,6 +9,7 @@ import JobHeader from "@/components/jobs/job-header"; import JobDetailsLoading from "@/components/layout/job-details-loading"; import JobSummary from "@/components/jobs/job-summary"; import { upsertLocalStartedApplication } from "@/lib/local-applications"; +import { addApplication } from "@/app/my-applications/actions"; import Link from "next/link"; import { useSession } from "next-auth/react"; @@ -40,11 +41,19 @@ export default function JobDetails() { } const handleApplyClick = () => { - if (!session?.user) { + window.open(selectedJob.application_url, "_blank"); + + if (session?.user) { + addApplication(selectedJob.id, { + jobId: selectedJob.id, + title: selectedJob.title, + companyName: selectedJob.company?.name || "Unknown", + applicationUrl: selectedJob.application_url, + }); + } else { upsertLocalStartedApplication(selectedJob); setShowSigninModal(true); } - window.open(selectedJob.application_url, "_blank"); }; const handleCopyLink = () => { diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index a8eb5ac..db9abe1 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -1,20 +1,24 @@ "use client"; import Link from "next/link"; import { Button, Menu } from "@mantine/core"; -import { IconMenu2, IconSearch } from "@tabler/icons-react"; +import { IconMenu2, IconSearch, IconLogout, IconClipboardList } from "@tabler/icons-react"; import Logo from "@/components/layout/logo"; import SearchBar from "@/components/search/search-bar"; import { useState } from "react"; import { usePathname } from "next/navigation"; +import { useSession, signOut } from "next-auth/react"; export const NavBarMobile = () => { const [showSearch, setShowSearch] = useState(false); const pathname = usePathname(); + const { data: session, status } = useSession(); const menuItems = [ { href: "/", label: "Home" }, { href: "/jobs", label: "Jobs" }, - { href: "/sign-in", label: "Sign in" }, + ...(status === "authenticated" + ? [{ href: "/my-applications", label: "My Applications" }] + : [{ href: "/sign-in", label: "Sign in" }]), ]; return ( @@ -50,16 +54,32 @@ export const NavBarMobile = () => { + {session?.user?.email && ( + {session.user.email} + )} {menuItems.map((item) => ( : undefined} > {item.label} ))} + {status === "authenticated" && ( + <> + + } + onClick={() => signOut({ callbackUrl: "/" })} + > + Sign out + + + )} diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index 876826f..5cc6a6f 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -1,30 +1,71 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useSession, signOut } from "next-auth/react"; +import { Avatar, Menu } from "@mantine/core"; +import { IconUser, IconLogout, IconClipboardList } from "@tabler/icons-react"; export default function NavLinks() { const pathname = usePathname(); + const { data: session, status } = useSession(); + + const linkClass = (href: string) => + `text-lg ${pathname === href ? "font-bold underline-fancy" : ""}`; return ( <> - + Home - + Jobs - - Sign in - + + {status === "authenticated" ? ( + + + + {session.user?.name + ? session.user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + : } + + + + {session.user?.email} + } + > + My Applications + + + } + onClick={() => signOut({ callbackUrl: "/" })} + > + Sign out + + + + ) : ( + + Sign in + + )} ); } From 5cd22240316f6c03da431a77385ce6dac01b0710 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 2 Apr 2026 13:39:20 +1100 Subject: [PATCH 03/26] feat(quickadd and delete): add quick add and delete buttons to job details page --- frontend/src/app/globals.css | 3 +- frontend/src/app/my-applications/actions.ts | 15 + frontend/src/app/my-applications/page.tsx | 6 +- .../applications/my-applications-client.tsx | 296 +++++++++++------- .../src/components/layout/nav-bar-mobile.tsx | 2 +- frontend/src/components/layout/nav-links.tsx | 17 +- frontend/src/types/application.ts | 1 - 7 files changed, 206 insertions(+), 134 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c40b579..fa79548 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -39,8 +39,7 @@ } html, -body, -main { +body { height: 100svh; margin: 0; padding: 0; diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index e661d88..9c3ebd8 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -89,6 +89,21 @@ export async function addApplication(jobId: string, jobSnapshot: import("@/types return { ok: true }; } +export async function deleteApplication(jobId: string) { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + await db.collection("applications").deleteOne({ + userId: new ObjectId(userId), + jobId, + }); + + return { ok: true }; +} + export async function updateApplicationStatus(jobId: string, status: ApplicationStatus) { const session = await getServerSession(authOptions); const userId = requireUserId(session); diff --git a/frontend/src/app/my-applications/page.tsx b/frontend/src/app/my-applications/page.tsx index 460493c..57bac1b 100644 --- a/frontend/src/app/my-applications/page.tsx +++ b/frontend/src/app/my-applications/page.tsx @@ -5,8 +5,10 @@ export default async function MyApplicationsPage() { const apps = await listApplications().catch(() => []); return ( -
- +
+
+ +
); } diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index 5f0b297..0cc448d 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -3,15 +3,19 @@ import { useEffect, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import { + ActionIcon, Alert, Badge, + Button, Card, Group, + MultiSelect, Select, Table, Text, Title, } from "@mantine/core"; +import { IconTrash } from "@tabler/icons-react"; import Link from "next/link"; import { APPLICATION_STATUSES, @@ -22,33 +26,42 @@ import { clearLocalApplications, getLocalApplications, } from "@/lib/local-applications"; -import { syncLocalApplications, updateApplicationStatus } from "@/app/my-applications/actions"; +import { + deleteApplication, + syncLocalApplications, + updateApplicationStatus, +} from "@/app/my-applications/actions"; + +const STATUS_ORDER: ApplicationStatus[] = [ + "STARTED", + "INTERVIEW", + "APPLIED", + "OFFER", + "ACCEPTED", + "REJECTED", +]; function statusColor(status: ApplicationStatus) { switch (status) { - case "STARTED": - return "gray"; - case "APPLIED": - return "blue"; - case "INTERVIEW": - return "yellow"; - case "OFFER": - return "teal"; - case "ACCEPTED": - return "green"; - case "REJECTED": - return "red"; - case "WITHDREW": - return "orange"; - default: - return "gray"; + case "STARTED": return "gray"; + case "APPLIED": return "blue"; + case "INTERVIEW": return "yellow"; + case "OFFER": return "teal"; + case "ACCEPTED": return "green"; + case "REJECTED": return "red"; + default: return "gray"; } } +function capitalize(s: string) { + return s.charAt(0) + s.slice(1).toLowerCase(); +} + export default function MyApplicationsClient({ initial }: { initial: DbApplication[] }) { const { data: session, status: sessionStatus } = useSession(); const [apps, setApps] = useState(initial); const [syncMessage, setSyncMessage] = useState(null); + const [selectedStatuses, setSelectedStatuses] = useState(STATUS_ORDER as string[]); useEffect(() => { if (sessionStatus !== "authenticated") return; @@ -61,26 +74,51 @@ export default function MyApplicationsClient({ initial }: { initial: DbApplicati clearLocalApplications(); setSyncMessage(`Synced ${res.upserted} application${res.upserted === 1 ? "" : "s"} from this device.`); } catch { - setSyncMessage("Couldn’t sync local applications yet. Try refreshing."); + setSyncMessage("Couldn't sync local applications yet. Try refreshing."); } })(); }, [sessionStatus]); - const summary = useMemo(() => { - const counts = Object.fromEntries(APPLICATION_STATUSES.map((s) => [s, 0])) as Record< - ApplicationStatus, - number - >; - for (const a of apps) counts[a.status] += 1; - const total = apps.length; - const mostRecent = apps[0]?.updatedAt; - return { counts, total, mostRecent }; + const grouped = useMemo(() => { + const map = new Map(); + for (const s of STATUS_ORDER) map.set(s, []); + for (const a of apps) { + const group = map.get(a.status); + if (group) group.push(a); + } + return map; }, [apps]); + const total = apps.length; + + async function handleStatusChange( + appId: string, + jobId: string, + oldStatus: ApplicationStatus, + next: ApplicationStatus, + ) { + setApps((prev) => prev.map((p) => (p._id === appId ? { ...p, status: next } : p))); + try { + await updateApplicationStatus(jobId, next); + } catch { + setApps((prev) => prev.map((p) => (p._id === appId ? { ...p, status: oldStatus } : p))); + } + } + + async function handleDelete(appId: string, jobId: string) { + const removed = apps.find((a) => a._id === appId); + setApps((prev) => prev.filter((a) => a._id !== appId)); + try { + await deleteApplication(jobId); + } catch { + if (removed) setApps((prev) => [removed, ...prev]); + } + } + if (sessionStatus === "unauthenticated") { return ( - You’re not signed in.{" "} + You're not signed in.{" "} Sign in {" "} @@ -93,99 +131,119 @@ export default function MyApplicationsClient({ initial }: { initial: DbApplicati
{syncMessage && {syncMessage}} - - -
- Summary - - Total: {summary.total} - {summary.mostRecent ? ` · Updated ${new Date(summary.mostRecent).toLocaleString()}` : ""} - -
- - {APPLICATION_STATUSES.map((s) => ( - - {s}: {summary.counts[s]} + {/* Summary + filter row */} +
+ + + {total} total{session?.user?.email ? ` · ${session.user.email}` : ""} + +
+ {STATUS_ORDER.map((s) => ( + + {capitalize(s)}: {grouped.get(s)?.length ?? 0} ))} - - - - - - - My Applications - {session?.user ? ( - - Signed in as {session.user.email} - - ) : null} - - - - - - Role - Company - Status - Started - Updated - Link - - - - {apps.map((a) => ( - - {a.jobSnapshot.title} - {a.jobSnapshot.companyName} - -
-
+
+
+ + + ({ value: s, label: capitalize(s) }))} + value={selectedStatuses} + onChange={setSelectedStatuses} + w={220} + size="sm" + /> + +
+ + {/* Per-status sections */} + {STATUS_ORDER.filter((s) => selectedStatuses.includes(s)).map((status) => { + const statusApps = grouped.get(status) ?? []; + return ( + + + {capitalize(status)} + + {statusApps.length} + + + + + + + Role + Company + Status + Updated + + + + + {statusApps.map((a) => { + const url = a.jobSnapshot.applicationUrl; + return ( + url && window.open(url, "_blank", "noreferrer")} + > + {a.jobSnapshot.title} + {a.jobSnapshot.companyName} + e.stopPropagation()}> +
+
+ ); + })}
); } - diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index db9abe1..61d70d5 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -47,7 +47,7 @@ export const NavBarMobile = () => { > - + + + + + + {STATUS_ORDER.map((s) => ( + + + {capitalize(s)} + + } + /> + ))} + + + +
{/* Per-status sections */} @@ -195,11 +242,18 @@ export default function MyApplicationsClient({ initial }: { initial: DbApplicati ({ value: s, label: capitalize(s) }))} - value={a.status} - leftSection={} - renderOption={({ option }) => ( - - - {option.label} - - )} - onChange={async (value) => { - if (!value) return; - await handleStatusChange(a._id, a.jobId, a.status, value as ApplicationStatus); - }} - w={155} - /> - - {new Date(a.updatedAt).toLocaleDateString()} - e.stopPropagation()}> - - {status === "STARTED" && ( - - {/* Filter popover */} - - - + + - Show sections - - - - - - - {STATUS_ORDER.map((s) => ( - - - {capitalize(s)} - - } - /> - ))} - - - - - {/* end header actions */} + + + {STATUS_ORDER.map((s) => ( + + + {capitalize(s)} + + } + /> + ))} + + + + + + {/* end header actions */} {/* Add custom application modal */} setAddOpen(false)} - title={Add application} + title={ + + Add application + + } styles={{ - content: { backgroundColor: "#2e2e2e", border: "2px solid #3a3a3a", borderRadius: "1rem" }, + content: { + backgroundColor: "#2e2e2e", + border: "2px solid #3a3a3a", + borderRadius: "1rem", + }, header: { backgroundColor: "#2e2e2e" }, overlay: { backdropFilter: "blur(2px)" }, }} @@ -354,8 +398,16 @@ export default function MyApplicationsClient({ value={customTitle} onChange={(e) => setCustomTitle(e.currentTarget.value)} styles={{ - input: { backgroundColor: "#3a3a3a", border: "none", borderRadius: "0.5rem", color: "white" }, - label: { color: "rgba(255,255,255,0.65)", marginBottom: "0.25rem" }, + input: { + backgroundColor: "#3a3a3a", + border: "none", + borderRadius: "0.5rem", + color: "white", + }, + label: { + color: "rgba(255,255,255,0.65)", + marginBottom: "0.25rem", + }, }} /> setCustomCompany(e.currentTarget.value)} styles={{ - input: { backgroundColor: "#3a3a3a", border: "none", borderRadius: "0.5rem", color: "white" }, - label: { color: "rgba(255,255,255,0.65)", marginBottom: "0.25rem" }, + input: { + backgroundColor: "#3a3a3a", + border: "none", + borderRadius: "0.5rem", + color: "white", + }, + label: { + color: "rgba(255,255,255,0.65)", + marginBottom: "0.25rem", + }, }} /> } renderOption={({ option }) => ( - + {option.label} )} onChange={async (value) => { if (!value) return; - await handleStatusChange(a._id, a.jobId, a.status, value as ApplicationStatus); + await handleStatusChange( + a._id, + a.jobId, + a.status, + value as ApplicationStatus, + ); }} styles={{ - input: { backgroundColor: "#3a3a3a", border: "none", borderRadius: "0.5rem", minWidth: "9rem" }, - dropdown: { backgroundColor: "#2e2e2e", border: "2px solid #3a3a3a", borderRadius: "0.75rem", minWidth: "9rem" }, + input: { + backgroundColor: "#3a3a3a", + border: "none", + borderRadius: "0.5rem", + minWidth: "9rem", + }, + dropdown: { + backgroundColor: "#2e2e2e", + border: "2px solid #3a3a3a", + borderRadius: "0.75rem", + minWidth: "9rem", + }, }} /> - + {formatISODate(a.updatedAt)} @@ -539,8 +695,20 @@ export default function MyApplicationsClient({ {status === "STARTED" && ( @@ -566,7 +734,11 @@ export default function MyApplicationsClient({ {/* Mobile: separate cards — hidden on sm+ */}
{statusApps.length === 0 ? ( - + No {capitalize(status).toLowerCase()} applications @@ -581,7 +753,9 @@ export default function MyApplicationsClient({ bd="2px solid selected" className="rounded-xl p-3" style={{ cursor: url ? "pointer" : "default" }} - onClick={() => url && window.open(url, "_blank", "noreferrer")} + onClick={() => + url && window.open(url, "_blank", "noreferrer") + } > {/* Row 1: company + date */}
@@ -596,7 +770,11 @@ export default function MyApplicationsClient({ {a.jobSnapshot.companyName}
- + {formatISODate(a.updatedAt)}
@@ -620,25 +798,52 @@ export default function MyApplicationsClient({ leftSection={} renderOption={({ option }) => ( - + {option.label} )} onChange={async (value) => { if (!value) return; - await handleStatusChange(a._id, a.jobId, a.status, value as ApplicationStatus); + await handleStatusChange( + a._id, + a.jobId, + a.status, + value as ApplicationStatus, + ); }} styles={{ - input: { backgroundColor: "#3a3a3a", border: "none", borderRadius: "0.5rem" }, - dropdown: { backgroundColor: "#2e2e2e", border: "2px solid #3a3a3a", borderRadius: "0.75rem" }, + input: { + backgroundColor: "#3a3a3a", + border: "none", + borderRadius: "0.5rem", + }, + dropdown: { + backgroundColor: "#2e2e2e", + border: "2px solid #3a3a3a", + borderRadius: "0.75rem", + }, }} /> {status === "STARTED" && ( @@ -660,7 +865,7 @@ export default function MyApplicationsClient({ ); - } + }, )} ); diff --git a/frontend/src/components/auth/session-provider.tsx b/frontend/src/components/auth/session-provider.tsx index 8d766c3..2d3fdac 100644 --- a/frontend/src/components/auth/session-provider.tsx +++ b/frontend/src/components/auth/session-provider.tsx @@ -6,4 +6,3 @@ import { PropsWithChildren } from "react"; export default function AuthSessionProvider({ children }: PropsWithChildren) { return {children}; } - diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index 018ec6d..def2d4e 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -1,7 +1,14 @@ // frontend/src/components/jobs/job-details.tsx "use client"; import { useEffect, useRef, useState } from "react"; -import { ActionIcon, Button, Card, Modal, ScrollArea, Text } from "@mantine/core"; +import { + ActionIcon, + Button, + Card, + Modal, + ScrollArea, + Text, +} from "@mantine/core"; import { IconCheck, IconCopy, IconExternalLink } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; import JobDescription from "@/components/jobs/job-description"; @@ -104,52 +111,52 @@ export default function JobDetails() { - - - {selectedJob && selectedJob.one_liner && ( - - )} - {selectedJob && selectedJob.description && ( - - )} - - -
- - - {isCopied ? : } - - -
+ + {selectedJob && selectedJob.one_liner && ( + + )} + {selectedJob && selectedJob.description && ( + + )} + + +
+ + + {isCopied ? : } + + +
); diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index 17b6fef..e69de29 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -1,90 +0,0 @@ -"use client"; -import Link from "next/link"; -import { Button, Menu } from "@mantine/core"; -import { IconMenu2, IconSearch, IconLogout, IconClipboardList } from "@tabler/icons-react"; -import Logo from "@/components/layout/logo"; -import SearchBar from "@/components/search/search-bar"; -import { useState } from "react"; -import { usePathname } from "next/navigation"; -import { useSession, signOut } from "next-auth/react"; - -export const NavBarMobile = () => { - const [showSearch, setShowSearch] = useState(false); - const pathname = usePathname(); - const { data: session, status } = useSession(); - - const menuItems = [ - { href: "/", label: "Home" }, - { href: "/jobs", label: "Jobs" }, - ...(status === "authenticated" - ? [{ href: "/my-applications", label: "Applications" }] - : [{ href: "/sign-in", label: "Sign in" }]), - ]; - - return ( - <> - {showSearch ? ( -
- - -
- ) : ( - <> -
- -
-
- - - - - - - {session?.user?.email && ( - {session.user.email} - )} - {menuItems.map((item) => ( - : undefined} - > - {item.label} - - ))} - {status === "authenticated" && ( - <> - - } - onClick={() => signOut({ callbackUrl: "/" })} - > - Sign out - - - )} - - -
- - )} - - ); -}; diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index bf62cae..09f434a 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -38,14 +38,16 @@ export default function NavLinks() { size="sm" className="cursor-pointer" > - {session.user?.name - ? session.user.name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2) - : } + {session.user?.name ? ( + session.user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + ) : ( + + )} @@ -61,7 +63,10 @@ export default function NavLinks() { ) : ( - + Sign in )} diff --git a/frontend/src/components/search/search-bar.tsx b/frontend/src/components/search/search-bar.tsx index 9d347ee..36e922f 100644 --- a/frontend/src/components/search/search-bar.tsx +++ b/frontend/src/components/search/search-bar.tsx @@ -12,7 +12,10 @@ export default function SearchBar() { // Sync input DOM value when context changes externally (e.g. filters cleared) useEffect(() => { - if (inputRef.current && inputRef.current.value !== (filters.filters.search || "")) { + if ( + inputRef.current && + inputRef.current.value !== (filters.filters.search || "") + ) { inputRef.current.value = filters.filters.search || ""; } }, [filters.filters.search]); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 9f80950..5c93483 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -39,7 +39,9 @@ export const authOptions: NextAuthOptions = { const client = await clientPromise; const db = client.db(process.env.MONGODB_DATABASE || "default"); - const user = (await db.collection("users").findOne({ email })) as DbUser | null; + const user = (await db + .collection("users") + .findOne({ email })) as DbUser | null; if (!user?.passwordHash) return null; const ok = await compare(password, user.passwordHash); @@ -70,4 +72,3 @@ export const authOptions: NextAuthOptions = { signIn: "/sign-in", }, }; - diff --git a/frontend/src/lib/local-applications.ts b/frontend/src/lib/local-applications.ts index 3cf1dab..4c587fc 100644 --- a/frontend/src/lib/local-applications.ts +++ b/frontend/src/lib/local-applications.ts @@ -53,4 +53,3 @@ export function clearLocalApplications() { if (typeof window === "undefined") return; window.localStorage.removeItem(STORAGE_KEY); } - diff --git a/frontend/src/lib/mongodb.ts b/frontend/src/lib/mongodb.ts index fd13e4e..dcd4c33 100644 --- a/frontend/src/lib/mongodb.ts +++ b/frontend/src/lib/mongodb.ts @@ -27,4 +27,3 @@ if (process.env.NODE_ENV === "development") { } export default clientPromise; - diff --git a/frontend/src/types/application.ts b/frontend/src/types/application.ts index 1ae69ca..824115e 100644 --- a/frontend/src/types/application.ts +++ b/frontend/src/types/application.ts @@ -27,4 +27,3 @@ export type LocalApplication = { export type DbApplication = LocalApplication & { _id: string; }; - diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index b575f7d..19c51c8 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -35,7 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } From 6abdcd4b944f923bc754bf693166168b0535dbb2 Mon Sep 17 00:00:00 2001 From: steven Date: Tue, 14 Apr 2026 01:21:29 +1000 Subject: [PATCH 21/26] fix: added nav bar mobile back --- frontend/next-env.d.ts | 2 +- .../src/components/layout/nav-bar-mobile.tsx | 99 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818..9edff1c 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index e69de29..a82726a 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -0,0 +1,99 @@ +"use client"; +import Link from "next/link"; +import { Button, Menu } from "@mantine/core"; +import { + IconMenu2, + IconSearch, + IconLogout, + IconClipboardList, +} from "@tabler/icons-react"; +import Logo from "@/components/layout/logo"; +import SearchBar from "@/components/search/search-bar"; +import { useState } from "react"; +import { usePathname } from "next/navigation"; +import { useSession, signOut } from "next-auth/react"; + +export const NavBarMobile = () => { + const [showSearch, setShowSearch] = useState(false); + const pathname = usePathname(); + const { data: session, status } = useSession(); + + const menuItems = [ + { href: "/", label: "Home" }, + { href: "/jobs", label: "Jobs" }, + ...(status === "authenticated" + ? [{ href: "/my-applications", label: "Applications" }] + : [{ href: "/sign-in", label: "Sign in" }]), + ]; + + return ( + <> + {showSearch ? ( +
+ + +
+ ) : ( + <> +
+ +
+
+ + + + + + + {session?.user?.email && ( + {session.user.email} + )} + {menuItems.map((item) => ( + + ) : undefined + } + > + {item.label} + + ))} + {status === "authenticated" && ( + <> + + } + onClick={() => signOut({ callbackUrl: "/" })} + > + Sign out + + + )} + + +
+ + )} + + ); +}; From 9845a492de68df2108dcc7f283d14686a325d11f Mon Sep 17 00:00:00 2001 From: steven Date: Tue, 14 Apr 2026 01:47:54 +1000 Subject: [PATCH 22/26] fix: make application dynamic so it isn't statically collected at build time --- frontend/src/app/my-applications/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/app/my-applications/page.tsx b/frontend/src/app/my-applications/page.tsx index 0981764..cef83e3 100644 --- a/frontend/src/app/my-applications/page.tsx +++ b/frontend/src/app/my-applications/page.tsx @@ -1,6 +1,8 @@ import MyApplicationsClient from "@/components/applications/my-applications-client"; import { listApplications } from "./actions"; +export const dynamic = "force-dynamic"; + export default async function MyApplicationsPage() { const apps = await listApplications().catch(() => []); From ce2b6f1b8f3c0b3facf8950122a1cd459c3f1506 Mon Sep 17 00:00:00 2001 From: steven Date: Tue, 14 Apr 2026 02:01:24 +1000 Subject: [PATCH 23/26] fix: lazily initialize MongoDB to prevent CI build failures Move MongoDB client creation behind a getMongoClientPromise() function so the missing MONGODB_URI env var only throws at runtime, not at module evaluation during next build. Co-Authored-By: Claude Opus 4.6 --- .../src/app/api/auth/[...nextauth]/route.ts | 6 +- frontend/src/app/my-applications/actions.ts | 28 ++--- frontend/src/app/sign-up/actions.ts | 4 +- frontend/src/lib/auth.ts | 112 ++++++++++-------- frontend/src/lib/mongodb.ts | 36 +++--- 5 files changed, 99 insertions(+), 87 deletions(-) diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts index 7b38c1b..f05c0e9 100644 --- a/frontend/src/app/api/auth/[...nextauth]/route.ts +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,8 @@ import NextAuth from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getAuthOptions } from "@/lib/auth"; -const handler = NextAuth(authOptions); +function handler(...args: Parameters>) { + return NextAuth(getAuthOptions())(...args); +} export { handler as GET, handler as POST }; diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index dea12e3..b57048e 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -1,7 +1,7 @@ "use server"; -import clientPromise from "@/lib/mongodb"; -import { authOptions } from "@/lib/auth"; +import { getMongoClientPromise } from "@/lib/mongodb"; +import { getAuthOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { ObjectId } from "mongodb"; import { @@ -19,12 +19,12 @@ function requireUserId(session: any) { } export async function syncLocalApplications(apps: LocalApplication[]) { - const session = await getServerSession(authOptions); + const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); if (!apps.length) return { ok: true, upserted: 0 }; - const client = await clientPromise; + const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const collection = db.collection("applications"); @@ -52,10 +52,10 @@ export async function syncLocalApplications(apps: LocalApplication[]) { } export async function listApplications(): Promise { - const session = await getServerSession(authOptions); + const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); - const client = await clientPromise; + const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const docs = await db @@ -109,10 +109,10 @@ export async function addApplication( jobId: string, jobSnapshot: import("@/types/application").ApplicationJobSnapshot, ) { - const session = await getServerSession(authOptions); + const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); - const client = await clientPromise; + const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const now = new Date(); @@ -129,10 +129,10 @@ export async function addApplication( } export async function deleteApplication(jobId: string) { - const session = await getServerSession(authOptions); + const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); - const client = await clientPromise; + const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); await db.collection("applications").deleteOne({ @@ -149,10 +149,10 @@ export async function createCustomApplication( status: ApplicationStatus, date: string, // "YYYY-MM-DD" ): Promise { - const session = await getServerSession(authOptions); + const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); - const client = await clientPromise; + const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const jobId = `custom_${new ObjectId().toString()}`; @@ -182,10 +182,10 @@ export async function updateApplicationStatus( jobId: string, status: ApplicationStatus, ) { - const session = await getServerSession(authOptions); + const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); - const client = await clientPromise; + const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); await db.collection("applications").updateOne( diff --git a/frontend/src/app/sign-up/actions.ts b/frontend/src/app/sign-up/actions.ts index c41f2c1..aa1345a 100644 --- a/frontend/src/app/sign-up/actions.ts +++ b/frontend/src/app/sign-up/actions.ts @@ -1,6 +1,6 @@ "use server"; -import clientPromise from "@/lib/mongodb"; +import { getMongoClientPromise } from "@/lib/mongodb"; import { hash } from "bcryptjs"; export async function registerUser(input: { @@ -20,7 +20,7 @@ export async function registerUser(input: { throw new Error("Password must be at least 8 characters"); } - const client = await clientPromise; + const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const existing = await db.collection("users").findOne({ email }); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 5c93483..c546d98 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -2,7 +2,7 @@ import type { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import CredentialsProvider from "next-auth/providers/credentials"; import { MongoDBAdapter } from "@auth/mongodb-adapter"; -import clientPromise from "@/lib/mongodb"; +import { getMongoClientPromise } from "@/lib/mongodb"; import { compare } from "bcryptjs"; import { ObjectId } from "mongodb"; @@ -13,62 +13,70 @@ type DbUser = { name?: string | null; }; -export const authOptions: NextAuthOptions = { - adapter: MongoDBAdapter(clientPromise, { - databaseName: process.env.MONGODB_DATABASE || "default", - }), - session: { - strategy: "jwt", - }, - providers: [ - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID || "", - clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", +let _authOptions: NextAuthOptions | undefined; + +export function getAuthOptions(): NextAuthOptions { + if (_authOptions) return _authOptions; + + _authOptions = { + adapter: MongoDBAdapter(getMongoClientPromise(), { + databaseName: process.env.MONGODB_DATABASE || "default", }), - CredentialsProvider({ - name: "Email and password", - credentials: { - email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" }, - }, - async authorize(credentials) { - const email = (credentials?.email || "").toLowerCase().trim(); - const password = credentials?.password || ""; + session: { + strategy: "jwt", + }, + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + }), + CredentialsProvider({ + name: "Email and password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const email = (credentials?.email || "").toLowerCase().trim(); + const password = credentials?.password || ""; - if (!email || !password) return null; + if (!email || !password) return null; - const client = await clientPromise; - const db = client.db(process.env.MONGODB_DATABASE || "default"); - const user = (await db - .collection("users") - .findOne({ email })) as DbUser | null; - if (!user?.passwordHash) return null; + const client = await getMongoClientPromise(); + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const user = (await db + .collection("users") + .findOne({ email })) as DbUser | null; + if (!user?.passwordHash) return null; - const ok = await compare(password, user.passwordHash); - if (!ok) return null; + const ok = await compare(password, user.passwordHash); + if (!ok) return null; - return { - id: user._id.toString(), - email: user.email, - name: user.name || undefined, - }; + return { + id: user._id.toString(), + email: user.email, + name: user.name || undefined, + }; + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user?.id) token.id = user.id; + return token; + }, + async session({ session, token }) { + if (session.user && token?.id) { + // @ts-expect-error - add id onto session user + session.user.id = token.id; + } + return session; }, - }), - ], - callbacks: { - async jwt({ token, user }) { - if (user?.id) token.id = user.id; - return token; }, - async session({ session, token }) { - if (session.user && token?.id) { - // @ts-expect-error - add id onto session user - session.user.id = token.id; - } - return session; + pages: { + signIn: "/sign-in", }, - }, - pages: { - signIn: "/sign-in", - }, -}; + }; + + return _authOptions; +} diff --git a/frontend/src/lib/mongodb.ts b/frontend/src/lib/mongodb.ts index dcd4c33..30b8885 100644 --- a/frontend/src/lib/mongodb.ts +++ b/frontend/src/lib/mongodb.ts @@ -5,25 +5,27 @@ declare global { var __mongoClientPromise: Promise | undefined; } -const uri = process.env.MONGODB_URI; -if (!uri) { - throw new Error("Missing MONGODB_URI environment variable"); -} - const options = {}; -let client: MongoClient; -let clientPromise: Promise; +let _clientPromise: Promise | undefined; -if (process.env.NODE_ENV === "development") { - if (!global.__mongoClientPromise) { - client = new MongoClient(uri, options); - global.__mongoClientPromise = client.connect(); +export function getMongoClientPromise(): Promise { + const uri = process.env.MONGODB_URI; + if (!uri) { + throw new Error("Missing MONGODB_URI environment variable"); } - clientPromise = global.__mongoClientPromise; -} else { - client = new MongoClient(uri, options); - clientPromise = client.connect(); -} -export default clientPromise; + if (process.env.NODE_ENV === "development") { + if (!global.__mongoClientPromise) { + const client = new MongoClient(uri, options); + global.__mongoClientPromise = client.connect(); + } + return global.__mongoClientPromise; + } + + if (!_clientPromise) { + const client = new MongoClient(uri, options); + _clientPromise = client.connect(); + } + return _clientPromise; +} From b93d8ae2fe9eb9da0a9f3b275e3e22afe9ee5b97 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 27 Apr 2026 18:12:57 +1000 Subject: [PATCH 24/26] add claude skills to gitignore --- .gitignore | 3 +++ frontend/next-env.d.ts | 1 - frontend/tsconfig.json | 16 ++++++++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index e3ae6fc..e358319 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ pnpm-lock.yaml # Typescript build info frontend/tsconfig.tsbuildinfo + +# Local Claude Code skills +.claude/skills/ diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..1b3be08 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 19c51c8..5d606a9 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -29,5 +35,7 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } From 719af591ecc28821b23180f9d8964aaa4e7cd178 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 27 Apr 2026 18:39:39 +1000 Subject: [PATCH 25/26] added .claude/skills/ to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e3ae6fc..a8fdb95 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ pnpm-lock.yaml # Typescript build info frontend/tsconfig.tsbuildinfo + +# Claude skills +.claude/skills/ From 5d8126e17566432d47a5eeda4495c3d1ac60be70 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 27 Apr 2026 19:31:44 +1000 Subject: [PATCH 26/26] prettier linting --- .github/dependabot.yml | 2 +- .github/workflows/lint-checker.yml | 10 ++-- DESIGN.md | 91 +++++++++++++++++------------- frontend/tsconfig.json | 14 +---- 4 files changed, 61 insertions(+), 56 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4bf823e..d89ad8b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,4 +22,4 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "monthly" \ No newline at end of file + interval: "monthly" diff --git a/.github/workflows/lint-checker.yml b/.github/workflows/lint-checker.yml index 9ea83d6..43e7199 100644 --- a/.github/workflows/lint-checker.yml +++ b/.github/workflows/lint-checker.yml @@ -3,7 +3,7 @@ name: Frontend Lint Checker on: pull_request: paths: - - 'frontend/**' + - "frontend/**" jobs: verify: @@ -18,9 +18,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' - cache-dependency-path: './frontend/package-lock.json' + node-version: "20" + cache: "npm" + cache-dependency-path: "./frontend/package-lock.json" - name: Install dependencies run: npm ci @@ -35,4 +35,4 @@ jobs: run: npx prettier --check . - name: Build - run: npm run build \ No newline at end of file + run: npm run build diff --git a/DESIGN.md b/DESIGN.md index 6918b05..4c96ace 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,80 +1,91 @@ # Design System Document: High-End Editorial Job Board ## 1. Overview & Creative North Star + **Creative North Star: "The Architectural Curator"** -This design system rejects the "commodity" look of traditional job boards. It is built on the principle of **Architectural Curation**—treating job listings not as database entries, but as editorial features. By utilizing high-contrast typography, intentional asymmetry, and deep tonal layering, we create a workspace that feels authoritative, premium, and calm. +This design system rejects the "commodity" look of traditional job boards. It is built on the principle of **Architectural Curation**—treating job listings not as database entries, but as editorial features. By utilizing high-contrast typography, intentional asymmetry, and deep tonal layering, we create a workspace that feels authoritative, premium, and calm. The system breaks the "template" aesthetic by favoring breathing room over density and using light as a functional tool rather than just an aesthetic choice. It is designed for the high-performing professional who values efficiency through visual clarity. --- ## 2. Colors + Our palette is anchored in deep obsidian tones, punctuated by a high-energy primary yellow that acts as a beacon for action. -* **Primary Palette:** - * `primary`: #ffdd73 (High-energy yellow for core actions) - * `on-primary`: #624e00 (Deep contrast for legibility on yellow) - * `background`: #0e0e0e (Deep charcoal/black foundation) -* **Surface Hierarchy:** - * `surface-container-low`: #131313 - * `surface-container`: #1a1a1a - * `surface-container-high`: #20201f -* **Status Indicators:** - * `Applied`: Blue (via `tertiary` logic) - * `Accepted`: Green - * `Error`: #ff7351 (`error`) +- **Primary Palette:** + - `primary`: #ffdd73 (High-energy yellow for core actions) + - `on-primary`: #624e00 (Deep contrast for legibility on yellow) + - `background`: #0e0e0e (Deep charcoal/black foundation) +- **Surface Hierarchy:** + - `surface-container-low`: #131313 + - `surface-container`: #1a1a1a + - `surface-container-high`: #20201f +- **Status Indicators:** + - `Applied`: Blue (via `tertiary` logic) + - `Accepted`: Green + - `Error`: #ff7351 (`error`) ### The "No-Line" Rule + Standard 1px borders are strictly prohibited for sectioning. Structural boundaries must be achieved through **Background Color Shifts**. For example, a `surface-container-high` card sits on a `surface-container-low` background. This creates a "shadow-less" depth that feels modern and integrated. ### The "Glass & Gradient" Rule + To elevate the experience, floating headers or navigation bars should use **Glassmorphism**. Use semi-transparent surface colors with a `backdrop-blur(20px)`. Main CTAs should utilize a subtle linear gradient from `primary` to `primary-dim` to provide a tactile, "lit-from-within" feel. --- ## 3. Typography + We use a dual-typeface system to balance editorial authority with functional utility. -* **Display & Headlines (Manrope):** These are the "Editorial" voice. Use `display-lg` (3.5rem) for hero sections and `headline-md` (1.75rem) for section titles. Bold weights are mandatory for headers to maintain the high-contrast signature look. -* **Body & Labels (Inter):** These are the "Functional" voice. `body-md` (0.875rem) is the workhorse for job descriptions. Inter’s high x-height ensures maximum legibility against dark backgrounds. -* **Hierarchy as Identity:** Use `title-lg` (1.375rem) for job titles in lists. The scale jump between headlines and body text is intentional—it mimics luxury magazine layouts, guiding the eye to the most critical information first. +- **Display & Headlines (Manrope):** These are the "Editorial" voice. Use `display-lg` (3.5rem) for hero sections and `headline-md` (1.75rem) for section titles. Bold weights are mandatory for headers to maintain the high-contrast signature look. +- **Body & Labels (Inter):** These are the "Functional" voice. `body-md` (0.875rem) is the workhorse for job descriptions. Inter’s high x-height ensures maximum legibility against dark backgrounds. +- **Hierarchy as Identity:** Use `title-lg` (1.375rem) for job titles in lists. The scale jump between headlines and body text is intentional—it mimics luxury magazine layouts, guiding the eye to the most critical information first. --- ## 4. Elevation & Depth + Depth in this system is a result of light physics, not artificial decoration. -* **Tonal Layering:** Always stack from darkest to lightest. - * *Level 0:* `surface` (Main background) - * *Level 1:* `surface-container-low` (Content sections) - * *Level 2:* `surface-container-highest` (Interactive cards/modals) -* **Ambient Shadows:** If an element must "float" (like a Toast or Floating Action Button), use a shadow with a blur radius of at least `24px` and an opacity no higher than `8%`. Use a tinted shadow (blending `on-surface` with the background) to avoid a "dirty" grey appearance. -* **The "Ghost Border" Fallback:** If high-density data requires containment, use a **Ghost Border**. Apply `outline-variant` at 15% opacity. It should be felt, not seen. +- **Tonal Layering:** Always stack from darkest to lightest. + - _Level 0:_ `surface` (Main background) + - _Level 1:_ `surface-container-low` (Content sections) + - _Level 2:_ `surface-container-highest` (Interactive cards/modals) +- **Ambient Shadows:** If an element must "float" (like a Toast or Floating Action Button), use a shadow with a blur radius of at least `24px` and an opacity no higher than `8%`. Use a tinted shadow (blending `on-surface` with the background) to avoid a "dirty" grey appearance. +- **The "Ghost Border" Fallback:** If high-density data requires containment, use a **Ghost Border**. Apply `outline-variant` at 15% opacity. It should be felt, not seen. --- ## 5. Components ### Buttons -* **Primary:** Solid `primary` fill, `on-primary` text. Radius: `md` (0.75rem). Bold `label-md` text. -* **Secondary:** Ghost style. `outline` border (20% opacity) with `on-surface` text. -* **Glass Action:** Semi-transparent `surface-bright` with backdrop blur for secondary utility actions. + +- **Primary:** Solid `primary` fill, `on-primary` text. Radius: `md` (0.75rem). Bold `label-md` text. +- **Secondary:** Ghost style. `outline` border (20% opacity) with `on-surface` text. +- **Glass Action:** Semi-transparent `surface-bright` with backdrop blur for secondary utility actions. ### Chips (Status & Tags) -* **Status Chips:** Use a muted version of the status color for the background (e.g., 20% opacity) with a high-contrast and solid text. -* **Filter Chips:** Use `surface-variant` with a radius of `full` (9999px) for a soft, pill-shaped aesthetic. + +- **Status Chips:** Use a muted version of the status color for the background (e.g., 20% opacity) with a high-contrast and solid text. +- **Filter Chips:** Use `surface-variant` with a radius of `full` (9999px) for a soft, pill-shaped aesthetic. ### Cards & Lists -* **Rule:** Forbid the use of divider lines. -* **Separation:** Use `spacing-8` (2rem) of vertical white space or shift the `surface-container` tier. -* **Interactive Cards:** On hover, a card should transition from `surface-container-low` to `surface-container-high`. + +- **Rule:** Forbid the use of divider lines. +- **Separation:** Use `spacing-8` (2rem) of vertical white space or shift the `surface-container` tier. +- **Interactive Cards:** On hover, a card should transition from `surface-container-low` to `surface-container-high`. ### Input Fields -* **Style:** Minimalist. No bottom border. Use a `surface-container-highest` background with a subtle radius of `sm` (0.25rem). -* **Active State:** The border should only appear on focus, using a 1px `primary` line. + +- **Style:** Minimalist. No bottom border. Use a `surface-container-highest` background with a subtle radius of `sm` (0.25rem). +- **Active State:** The border should only appear on focus, using a 1px `primary` line. ### Job Specific: "Quick-Action" Bar + A specialized component for the job board—a sticky footer or side-rail for "Apply Now" and "Save" actions using the **Glassmorphism** rule to keep the user grounded in the content while providing immediate utility. --- @@ -82,12 +93,14 @@ A specialized component for the job board—a sticky footer or side-rail for "Ap ## 6. Do's and Don'ts ### Do: -* **Do** use asymmetrical spacing to create visual interest (e.g., more padding on the left than the right in editorial headers). -* **Do** use `primary` yellow sparingly. It is a "laser pointer," not a bucket of paint. -* **Do** ensure all text on dark backgrounds meets a minimum 4.5:1 contrast ratio for accessibility. + +- **Do** use asymmetrical spacing to create visual interest (e.g., more padding on the left than the right in editorial headers). +- **Do** use `primary` yellow sparingly. It is a "laser pointer," not a bucket of paint. +- **Do** ensure all text on dark backgrounds meets a minimum 4.5:1 contrast ratio for accessibility. ### Don't: -* **Don't** use 100% opaque, high-contrast borders. They "trap" the content and break the fluid editorial feel. -* **Don't** use standard "Drop Shadows" (0, 2, 4, 0). They look dated and cheap. -* **Don't** use dividers between list items. Trust the white space and background shifts to do the work. -* **Don't** mix the font roles. Never use Manrope for body copy; it’s too "loud" for long-form reading. \ No newline at end of file + +- **Don't** use 100% opaque, high-contrast borders. They "trap" the content and break the fluid editorial feel. +- **Don't** use standard "Drop Shadows" (0, 2, 4, 0). They look dated and cheap. +- **Don't** use dividers between list items. Trust the white space and background shifts to do the work. +- **Don't** mix the font roles. Never use Manrope for body copy; it’s too "loud" for long-form reading. diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 5d606a9..1a07130 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -35,7 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] }