From 22548217b39ea399ea6de1c9fb6db948db5738f1 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 1 Apr 2026 02:02:14 +1100 Subject: [PATCH 01/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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"] } From a3f403c61907e140538aaf8cc153510a3acbdf49 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 14 May 2026 01:57:12 +1000 Subject: [PATCH 27/42] feat --- .claude/settings.local.json | 6 +- frontend/next-env.d.ts | 1 + frontend/package-lock.json | 139 +++ frontend/package.json | 4 + frontend/src/app/globals.css | 599 +++++++++++++ frontend/src/app/my-applications/actions.ts | 25 +- frontend/src/app/my-applications/page.tsx | 3 +- .../applications/applications-kanban.tsx | 456 ++++++++++ .../applications/applications-timeline.tsx | 402 +++++++++ .../applications/my-applications-client.tsx | 829 ++++++------------ .../components/applications/notes-modal.tsx | 99 +++ frontend/src/lib/role-palette.ts | 44 + frontend/src/lib/stages.ts | 44 + frontend/src/lib/utils.ts | 19 + frontend/src/types/application.ts | 17 +- frontend/tsconfig.json | 2 +- 16 files changed, 2135 insertions(+), 554 deletions(-) create mode 100644 frontend/src/components/applications/applications-kanban.tsx create mode 100644 frontend/src/components/applications/applications-timeline.tsx create mode 100644 frontend/src/components/applications/notes-modal.tsx create mode 100644 frontend/src/lib/role-palette.ts create mode 100644 frontend/src/lib/stages.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2d71d69..75ff34c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,11 @@ "Bash(npm run:*)", "Bash(node -e \"console.log\\(require\\('eslint-config-next/package.json'\\).version\\)\")", "Bash(npx tsc:*)", - "Bash(npx prettier:*)" + "Bash(npx prettier:*)", + "Bash(echo \"EXIT=$?\")", + "Bash(echo \"TSC=$?\")", + "Bash(npx eslint *)", + "PowerShell(Get-Item *)" ] } } diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 1b3be08..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/dev/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/package-lock.json b/frontend/package-lock.json index 4af8192..bf031eb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "dependencies": { "@auth/mongodb-adapter": "^3.11.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@mantine/core": "^7.17.0", "@mantine/hooks": "^7.16.1", "@mantine/notifications": "^7.17.1", @@ -23,6 +26,7 @@ "jsdom": "^26.0.0", "lru-cache": "^11.2.2", "mongodb": "^6.14.2", + "motion": "^12.38.0", "next": "^16.2.3", "next-auth": "^4.24.13", "pino": "^9.11.0", @@ -551,6 +555,73 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", @@ -4015,6 +4086,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5377,6 +5475,47 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5887bed..f827958 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,9 @@ }, "dependencies": { "@auth/mongodb-adapter": "^3.11.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@mantine/core": "^7.17.0", "@mantine/hooks": "^7.16.1", "@mantine/notifications": "^7.17.1", @@ -25,6 +28,7 @@ "jsdom": "^26.0.0", "lru-cache": "^11.2.2", "mongodb": "^6.14.2", + "motion": "^12.38.0", "next": "^16.2.3", "next-auth": "^4.24.13", "pino": "^9.11.0", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index fa79548..0b967a9 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -20,7 +20,23 @@ --selected: #3a3a3a; --background: #1f1f1f; --secondary: #2e2e2e; + --bg-soft: #262626; + --border: #3a3a3a; + --border-2: #4a4a4a; --accent: #ffe22f; + + --muted-1: rgba(255, 255, 255, 0.65); + --muted-2: rgba(255, 255, 255, 0.5); + --muted-3: rgba(255, 255, 255, 0.35); + + --role-neutral: rgba(255, 255, 255, 0.65); + --role-neutral-muted: rgba(255, 255, 255, 0.06); + --role-active: #ffe22f; + --role-active-muted: rgba(255, 226, 47, 0.14); + --role-win: #9ddfb0; + --role-win-muted: rgba(157, 223, 176, 0.14); + --role-loss: rgba(255, 115, 81, 0.9); + --role-loss-muted: rgba(255, 115, 81, 0.1); } .dark { --selected: #3a3a3a; @@ -50,3 +66,586 @@ body { max-width: none; word-break: break-word; } + +/* ========================================================================== + Applications redesign — shared atoms + Kanban + Timeline + ========================================================================== */ + +.apps-status-dot { + width: 8px; + height: 8px; + border-radius: 9999px; + display: inline-block; + flex: 0 0 auto; +} +.apps-count-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 22px; + padding: 0 8px; + border-radius: 9999px; + font-weight: 600; + font-size: 11.5px; + line-height: 1; + font-variant-numeric: tabular-nums; +} +.apps-icon-btn { + width: 26px; + height: 26px; + padding: 0; + border: 0; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + cursor: pointer; + position: relative; + color: rgba(255, 255, 255, 0.5); + transition: background-color 0.12s ease, color 0.12s ease; +} +.apps-icon-btn:hover { + background: rgba(255, 255, 255, 0.06); + color: white; +} +.apps-icon-btn.has-notes { + background: rgba(255, 226, 47, 0.08); + color: #ffe22f; +} +.apps-quick-applied { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + border: 0; + background: var(--accent); + color: #1f1f1f; + border-radius: 0.5rem; + font-weight: 600; + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +/* Notes hover popover */ +.apps-notes-pop { + position: absolute; + bottom: calc(100% + 8px); + right: -4px; + width: 260px; + background: #1a1a1a; + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 10px 12px; + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 226, 47, 0.18); + text-align: left; + z-index: 50; + pointer-events: none; + animation: apps-notes-in 0.12s ease; +} +@keyframes apps-notes-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.apps-notes-pop::after { + content: ""; + position: absolute; + top: 100%; + right: 12px; + border: 6px solid transparent; + border-top-color: #1a1a1a; +} +.apps-notes-pop-label { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--accent); + margin-bottom: 4px; +} +.apps-notes-pop-body { + font-size: 12.5px; + line-height: 1.45; + color: var(--muted-1); + white-space: normal; +} + +/* ========================================================================== + Kanban + ========================================================================== */ +.apps-stat-strip { + display: grid; + grid-template-columns: repeat(5, 1fr) 1.4fr; + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 0.75rem; + overflow: hidden; + margin-bottom: 22px; +} +.apps-stat-cell { + background: var(--secondary); + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 6px; +} +.apps-stat-label { + font-weight: 600; + font-size: 11px; + line-height: 1; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--muted-2); + display: flex; + align-items: center; + gap: 7px; +} +.apps-stat-value { + font-weight: 800; + font-size: 32px; + line-height: 1; + letter-spacing: -0.02em; + color: white; +} +.apps-stat-sub { + font-size: 12px; + color: var(--muted-3); +} +.apps-stat-spark { + gap: 8px; +} +.apps-mini-bars { + display: flex; + align-items: flex-end; + gap: 10px; + height: 40px; + padding-top: 4px; +} +.apps-mini-bar { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: pointer; +} +.apps-mini-bar-fill { + width: 100%; + border-radius: 3px 3px 0 0; + transition: opacity 0.12s ease; +} +.apps-mini-bar:hover .apps-mini-bar-fill { + opacity: 0.8; +} +.apps-mini-bar-count { + font-weight: 600; + font-size: 11px; + line-height: 1; + color: var(--muted-2); + font-variant-numeric: tabular-nums; +} + +.apps-kanban-board { + display: grid; + grid-template-columns: repeat(5, minmax(220px, 1fr)); + gap: 12px; + align-items: flex-start; +} +.apps-kanban-col { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 12px; + min-height: 420px; + transition: outline 0.12s ease; +} +.apps-kanban-col.is-drop-target { + outline: 2px dashed var(--accent); + outline-offset: -2px; +} +.apps-kc-col-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 4px 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; +} +.apps-kc-col-head-left { + display: flex; + align-items: center; + gap: 8px; +} +.apps-kc-col-name { + font-weight: 600; + font-size: 13px; + line-height: 1; + letter-spacing: 0.3px; + text-transform: uppercase; + color: white; +} +.apps-kc-col-body { + display: flex; + flex-direction: column; + gap: 10px; +} +.apps-kc-col-empty { + padding: 24px 12px; + border: 1px dashed var(--border); + border-radius: 0.5rem; + text-align: center; + font-size: 12.5px; + color: var(--muted-3); +} + +.apps-kanban-card { + background: var(--secondary); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 0.75rem; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + cursor: grab; + position: relative; + transition: border-color 0.12s ease, transform 0.12s ease; +} +.apps-kanban-card:hover { + border-color: var(--border); +} +.apps-kanban-card.is-dragging { + opacity: 0.3; +} +.apps-kc-head { + display: flex; + align-items: center; + gap: 9px; +} +.apps-kc-head-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +.apps-kc-company { + font-size: 12.5px; + color: white; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.apps-kc-date { + font-size: 11px; + color: var(--muted-3); +} +.apps-kc-role { + font-weight: 600; + font-size: 14px; + line-height: 1.35; + letter-spacing: -0.005em; + margin: 0; + color: white; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.apps-kc-foot { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 4px; +} +.apps-kc-foot-meta { + font-size: 11.5px; + color: var(--muted-3); + font-variant-numeric: tabular-nums; +} +.apps-kc-foot-icons { + display: flex; + gap: 2px; +} + +/* Kanban drag preview floating card */ +.apps-drag-preview { + background: var(--secondary); + border: 2px solid var(--accent); + border-radius: 0.75rem; + padding: 12px; + min-width: 240px; + max-width: 320px; + box-shadow: + 0 12px 28px rgba(0, 0, 0, 0.45), + 0 0 0 1px rgba(255, 226, 47, 0.2); + cursor: grabbing; +} + +/* ========================================================================== + Timeline + ========================================================================== */ +.apps-tl-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 24px; + align-items: flex-start; +} +.apps-tl-main { + display: flex; + flex-direction: column; + gap: 28px; +} +.apps-tl-group-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border); + padding-bottom: 10px; +} +.apps-tl-group-head h2 { + font-weight: 700; + font-size: 17px; + line-height: 1; + letter-spacing: -0.01em; + margin: 0; + color: white; +} +.apps-tl-group-count { + font-size: 12px; + color: var(--muted-3); +} +.apps-tl-list { + display: flex; + flex-direction: column; +} +.apps-tl-item { + display: grid; + grid-template-columns: 32px 1fr; + gap: 4px; + min-height: 110px; + position: relative; +} +.apps-tl-spine { + position: relative; + display: flex; + justify-content: center; +} +.apps-tl-spine::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + bottom: -10px; + width: 1px; + background: var(--border); + transform: translateX(-0.5px); +} +.apps-tl-item:last-child .apps-tl-spine::before { + bottom: 50%; +} +.apps-tl-dot { + position: relative; + z-index: 1; + width: 12px; + height: 12px; + border-radius: 9999px; + margin-top: 22px; + border: 2px solid var(--background); +} +.apps-tl-card { + background: var(--secondary); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 0.75rem; + padding: 14px 16px; + margin-bottom: 10px; + position: relative; + transition: border-color 0.12s ease; +} +.apps-tl-card:hover { + border-color: var(--border); +} +.apps-tl-card-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--muted-2); + margin-bottom: 6px; +} +.apps-tl-verb { + color: var(--muted-1); + font-weight: 500; +} +.apps-tl-company { + font-weight: 600; + color: white; +} +.apps-tl-date { + margin-left: auto; + font-size: 12px; + color: var(--muted-3); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.apps-tl-role { + font-weight: 700; + font-size: 17px; + line-height: 1.3; + letter-spacing: -0.01em; + margin: 0 0 10px; + color: white; +} +.apps-tl-card-foot { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.apps-tl-status-pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 8px; + border: 0; + border-radius: 0.5rem; + font-weight: 500; + font-size: 11.5px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + font-family: inherit; +} +.apps-tl-notes { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 9px; + background: rgba(255, 226, 47, 0.08); + border: 0; + border-radius: 0.5rem; + cursor: pointer; + color: var(--muted-1); + font-weight: 500; + font-size: 12px; + line-height: 1; + position: relative; + font-family: inherit; +} +.apps-tl-notes:hover { + background: rgba(255, 226, 47, 0.14); + color: white; +} +.apps-tl-spacer { + flex: 1; +} +.apps-notes-pop-tl { + bottom: calc(100% + 10px); + left: -4px; + right: auto; +} +.apps-notes-pop-tl::after { + right: auto; + left: 16px; +} + +/* Right rail */ +.apps-tl-rail { + display: flex; + flex-direction: column; + gap: 14px; + position: sticky; + top: 12px; +} +.apps-tl-rail-card { + background: var(--secondary); + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 18px; +} +.apps-tl-rail-head { + font-weight: 600; + font-size: 11px; + line-height: 1; + letter-spacing: 1.4px; + text-transform: uppercase; + color: var(--muted-1); + margin-bottom: 12px; +} +.apps-vsankey-svg { + width: 100%; + height: auto; + display: block; +} +.apps-tl-rail-stats { + display: flex; + flex-direction: column; + gap: 14px; +} +.apps-tl-stat { + display: flex; + flex-direction: column; + gap: 4px; +} +.apps-tl-stat-label { + font-weight: 600; + font-size: 10.5px; + line-height: 1; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--muted-2); +} +.apps-tl-stat-value { + font-weight: 800; + font-size: 28px; + line-height: 1; + letter-spacing: -0.02em; + color: white; +} +.apps-tl-stat-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.apps-tl-stat-row .apps-tl-stat-value { + font-size: 20px; +} + +/* ========================================================================== + Responsive collapses + ========================================================================== */ +@media (max-width: 1024px) { + .apps-kanban-board { + grid-template-columns: repeat(3, minmax(220px, 1fr)); + } + .apps-stat-strip { + grid-template-columns: repeat(3, 1fr); + } + .apps-tl-layout { + grid-template-columns: 1fr; + } + .apps-tl-rail { + position: static; + } +} +@media (max-width: 640px) { + .apps-kanban-board { + grid-template-columns: 1fr; + } + .apps-stat-strip { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index b57048e..fe0a14c 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -67,7 +67,6 @@ export async function listApplications(): Promise { if (!docs.length) return []; - // Bulk-fetch logos from active_jobs for snapshots that don't have one const jobIds = docs .map((d) => { try { @@ -102,12 +101,13 @@ export async function listApplications(): Promise { ...d.jobSnapshot, logo: d.jobSnapshot.logo ?? logoMap.get(d.jobId), }, + notes: d.notes ?? undefined, })) as DbApplication[]; } export async function addApplication( jobId: string, - jobSnapshot: import("@/types/application").ApplicationJobSnapshot, + jobSnapshot: ApplicationJobSnapshot, ) { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); @@ -147,7 +147,7 @@ export async function createCustomApplication( title: string, companyName: string, status: ApplicationStatus, - date: string, // "YYYY-MM-DD" + date: string, ): Promise { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); @@ -199,3 +199,22 @@ export async function updateApplicationStatus( return { ok: true }; } + +export async function updateApplicationNotes(jobId: string, notes: string) { + const session = await getServerSession(getAuthOptions()); + const userId = requireUserId(session); + + const client = await getMongoClientPromise(); + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + const trimmed = notes.trim(); + const update = trimmed + ? { $set: { notes: trimmed, updatedAt: new Date() } } + : { $unset: { notes: "" }, $set: { updatedAt: new Date() } }; + + await db + .collection("applications") + .updateOne({ userId: new ObjectId(userId), jobId }, update); + + return { ok: true, notes: trimmed || undefined }; +} diff --git a/frontend/src/app/my-applications/page.tsx b/frontend/src/app/my-applications/page.tsx index cef83e3..4d7dc30 100644 --- a/frontend/src/app/my-applications/page.tsx +++ b/frontend/src/app/my-applications/page.tsx @@ -1,5 +1,6 @@ import MyApplicationsClient from "@/components/applications/my-applications-client"; import { listApplications } from "./actions"; +import { STAGES } from "@/lib/stages"; export const dynamic = "force-dynamic"; @@ -9,7 +10,7 @@ export default async function MyApplicationsPage() { return (
- +
); diff --git a/frontend/src/components/applications/applications-kanban.tsx b/frontend/src/components/applications/applications-kanban.tsx new file mode 100644 index 0000000..a915bfb --- /dev/null +++ b/frontend/src/components/applications/applications-kanban.tsx @@ -0,0 +1,456 @@ +"use client"; + +import { CSSProperties, useMemo, useState } from "react"; +import { + CollisionDetection, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + pointerWithin, + rectIntersection, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { snapCenterToCursor } from "@dnd-kit/modifiers"; +import { useDraggable } from "@dnd-kit/core"; +import { IconCheck, IconNotes, IconTrash } from "@tabler/icons-react"; +import CompanyLogo from "@/components/jobs/company-logo"; +import { rolePalette } from "@/lib/role-palette"; +import { relativeDate } from "@/lib/utils"; +import { + ApplicationStatus, + DbApplication, + UserStage, +} from "@/types/application"; + +export type KanbanSort = "newest" | "oldest"; + +const cursorCollisionDetection: CollisionDetection = (args) => { + const pointer = pointerWithin(args); + if (pointer.length > 0) return pointer; + return rectIntersection(args); +}; + +type Props = { + apps: DbApplication[]; + stages: UserStage[]; + visibleStageNames: string[]; + sort: KanbanSort; + onStatusChange: ( + appId: string, + jobId: string, + oldStatus: ApplicationStatus, + next: ApplicationStatus, + ) => Promise; + onDelete: (appId: string, jobId: string) => void; + onOpenNotes: (appId: string) => void; +}; + +export default function ApplicationsKanban({ + apps, + stages, + visibleStageNames, + sort, + onStatusChange, + onDelete, + onOpenNotes, +}: Props) { + const [activeId, setActiveId] = useState(null); + + const grouped = useMemo(() => { + const map = new Map(); + for (const s of stages) map.set(s.name, []); + for (const a of apps) { + const arr = map.get(a.status); + if (arr) arr.push(a); + else { + const first = stages[0]?.name; + if (first) map.get(first)?.push(a); + } + } + for (const arr of map.values()) { + arr.sort((a, b) => { + const cmp = b.updatedAt.localeCompare(a.updatedAt); + return sort === "newest" ? cmp : -cmp; + }); + } + return map; + }, [apps, stages, sort]); + + const totalCount = apps.length; + const wins = apps.filter( + (a) => stages.find((s) => s.name === a.status)?.colorRole === "win", + ).length; + const losses = apps.filter( + (a) => stages.find((s) => s.name === a.status)?.colorRole === "loss", + ).length; + const active = apps.filter((a) => { + const s = stages.find((st) => st.name === a.status); + return s?.colorRole === "active"; + }).length; + const started = apps.filter((a) => a.status === "STARTED").length; + const winRate = totalCount > 0 ? Math.round((wins / totalCount) * 100) : 0; + + const statCells = [ + { label: "Total", value: totalCount, sub: "tracked" }, + { + label: "Active", + value: active, + sub: "in flight", + role: "active" as const, + }, + { + label: "Wins", + value: wins, + sub: `${winRate}% rate`, + role: "win" as const, + }, + { + label: "Losses", + value: losses, + sub: "rejected", + role: "loss" as const, + }, + { + label: "Drafted", + value: started, + sub: "not yet sent", + role: "neutral" as const, + }, + ]; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + ); + + function handleDragStart(e: DragStartEvent) { + setActiveId(String(e.active.id)); + } + + async function handleDragEnd(e: DragEndEvent) { + setActiveId(null); + const { active, over } = e; + if (!over) return; + const activeIdStr = String(active.id); + const overId = String(over.id); + + const sourceApp = apps.find((a) => a._id === activeIdStr); + if (!sourceApp) return; + const sourceStage = sourceApp.status; + + let targetStage: string | null = null; + + if (overId.startsWith("col:")) { + targetStage = overId.slice("col:".length); + } else { + const targetApp = apps.find((a) => a._id === overId); + if (!targetApp) return; + targetStage = targetApp.status; + } + if (!targetStage) return; + if (sourceStage === targetStage) return; + + await onStatusChange( + sourceApp._id, + sourceApp.jobId, + sourceStage, + targetStage as ApplicationStatus, + ); + } + + const activeApp = activeId + ? apps.find((a) => a._id === activeId) ?? null + : null; + + const visibleStages = stages.filter((s) => + visibleStageNames.includes(s.name), + ); + + return ( +
+ [s.name, (grouped.get(s.name) ?? []).length]), + )} + /> + setActiveId(null)} + > +
+ {visibleStages.map((stage) => { + const stageApps = grouped.get(stage.name) ?? []; + return ( + + ); + })} +
+ + {activeApp ? : null} + +
+
+ ); +} + +function StatStrip({ + cells, + stages, + countsByStage, +}: { + cells: Array<{ + label: string; + value: number; + sub: string; + role?: "neutral" | "active" | "win" | "loss"; + }>; + stages: UserStage[]; + countsByStage: Record; +}) { + const max = Math.max(1, ...stages.map((s) => countsByStage[s.name] ?? 0)); + return ( +
+ {cells.map((c) => { + const palette = c.role ? rolePalette(c.role) : null; + return ( +
+
+ {palette && ( + + )} + {c.label} +
+
+ {c.value} +
+
{c.sub}
+
+ ); + })} +
+
Pipeline
+
+ {stages.map((s) => { + const count = countsByStage[s.name] ?? 0; + const palette = rolePalette(s.colorRole); + const h = 4 + (count / max) * 28; + return ( +
+
+ {count} +
+ ); + })} +
+
+
+ ); +} + +function KanbanColumn({ + stage, + apps, + onStatusChange, + onDelete, + onOpenNotes, +}: { + stage: UserStage; + apps: DbApplication[]; + onStatusChange: Props["onStatusChange"]; + onDelete: Props["onDelete"]; + onOpenNotes: Props["onOpenNotes"]; +}) { + const palette = rolePalette(stage.colorRole); + const { setNodeRef, isOver } = useDroppable({ + id: `col:${stage.name}`, + data: { stageName: stage.name }, + }); + + return ( +
+
+
+ + {stage.displayName} + + {apps.length} + +
+
+
+ {apps.length === 0 ? ( +
+ No {stage.displayName.toLowerCase()} +
+ ) : ( + apps.map((a) => ( + + )) + )} +
+
+ ); +} + +function KanbanCard({ + app, + onStatusChange, + onDelete, + onOpenNotes, +}: { + app: DbApplication; + onStatusChange: Props["onStatusChange"]; + onDelete: Props["onDelete"]; + onOpenNotes: Props["onOpenNotes"]; +}) { + const [hover, setHover] = useState(false); + const { attributes, listeners, setNodeRef, transform, isDragging } = + useDraggable({ id: app._id, data: { status: app.status } }); + + const style: CSSProperties = transform + ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` } + : {}; + + const url = app.jobSnapshot.applicationUrl; + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + onClick={() => url && window.open(url, "_blank", "noreferrer")} + {...attributes} + {...listeners} + > +
+ +
+
{app.jobSnapshot.companyName}
+
{relativeDate(app.updatedAt)}
+
+
+

{app.jobSnapshot.title}

+
+ {app.status === "STARTED" ? ( + + ) : ( + + {relativeDate(app.updatedAt)} + + )} +
+ + +
+
+
+ ); +} + +function DragPreview({ app }: { app: DbApplication }) { + return ( +
+
+ +
+
{app.jobSnapshot.companyName}
+
{relativeDate(app.updatedAt)}
+
+
+

{app.jobSnapshot.title}

+
+ ); +} diff --git a/frontend/src/components/applications/applications-timeline.tsx b/frontend/src/components/applications/applications-timeline.tsx new file mode 100644 index 0000000..344705b --- /dev/null +++ b/frontend/src/components/applications/applications-timeline.tsx @@ -0,0 +1,402 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Menu } from "@mantine/core"; +import { IconCheck, IconExternalLink, IconNotes } from "@tabler/icons-react"; +import CompanyLogo from "@/components/jobs/company-logo"; +import { rolePalette } from "@/lib/role-palette"; +import { formatISODate, relativeDate } from "@/lib/utils"; +import { + ApplicationStatus, + DbApplication, + UserStage, +} from "@/types/application"; + +function weekBucket(iso: string) { + const today = new Date(); + const d = new Date(iso); + const days = Math.round((today.getTime() - d.getTime()) / 86400000); + if (days <= 2) return "This week"; + if (days <= 9) return "Last week"; + if (days <= 30) return "Earlier this month"; + const month = d.toLocaleString("en-US", { month: "long" }); + return month; +} + +const STAGE_VERB: Record = { + STARTED: "Drafted application for", + APPLIED: "Applied to", + INTERVIEW: "Interviewed with", + ACCEPTED: "Received offer from", + REJECTED: "Rejected by", +}; + +type Props = { + apps: DbApplication[]; + stages: UserStage[]; + onStatusChange: ( + appId: string, + jobId: string, + oldStatus: ApplicationStatus, + next: ApplicationStatus, + ) => Promise; + onOpenNotes: (appId: string) => void; +}; + +export default function ApplicationsTimeline({ + apps, + stages, + onStatusChange, + onOpenNotes, +}: Props) { + const { groups, order } = useMemo(() => { + const sorted = [...apps].sort((a, b) => + b.updatedAt.localeCompare(a.updatedAt), + ); + const groups: Record = {}; + const order: string[] = []; + for (const app of sorted) { + const key = weekBucket(app.updatedAt); + if (!groups[key]) { + groups[key] = []; + order.push(key); + } + groups[key].push(app); + } + return { groups, order }; + }, [apps]); + + const total = apps.length; + const wins = apps.filter( + (a) => stages.find((s) => s.name === a.status)?.colorRole === "win", + ).length; + const losses = apps.filter( + (a) => stages.find((s) => s.name === a.status)?.colorRole === "loss", + ).length; + const active = apps.filter((a) => { + const s = stages.find((st) => st.name === a.status); + return s?.colorRole === "active"; + }).length; + + return ( +
+
+ {order.length === 0 ? ( +
+ No applications yet. +
+ ) : ( + order.map((g) => ( +
+
+

{g}

+ + {groups[g].length}{" "} + {groups[g].length === 1 ? "event" : "events"} + +
+
+ {groups[g].map((app) => ( + + ))} +
+
+ )) + )} +
+ + +
+ ); +} + +function TimelineItem({ + app, + stages, + onStatusChange, + onOpenNotes, +}: { + app: DbApplication; + stages: UserStage[]; + onStatusChange: Props["onStatusChange"]; + onOpenNotes: Props["onOpenNotes"]; +}) { + const [hover, setHover] = useState(false); + const stage = stages.find((s) => s.name === app.status); + if (!stage) return null; + const palette = rolePalette(stage.colorRole); + const verb = STAGE_VERB[app.status] ?? `Moved to ${stage.displayName}`; + const url = app.jobSnapshot.applicationUrl; + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > +
+ +
+
+
+ {verb} + + {app.jobSnapshot.companyName} + + {relativeDate(app.updatedAt)} · {formatISODate(app.updatedAt)} + +
+

{app.jobSnapshot.title}

+
+ + + + + + {stages.map((s) => { + const p = rolePalette(s.colorRole); + return ( + + } + onClick={() => + onStatusChange(app._id, app.jobId, app.status, s.name) + } + > + {s.displayName} + + ); + })} + + + {app.notes && ( + + )} + {app.status === "STARTED" && ( + + )} + {!app.notes && ( + + )} + + {url && ( + + )} +
+
+
+ ); +} + +function VerticalSankey({ + apps, + stages, +}: { + apps: DbApplication[]; + stages: UserStage[]; +}) { + const counts = stages.map( + (s) => apps.filter((a) => a.status === s.name).length, + ); + const W = 280; + const H = 480; + const padX = 28; + const padY = 18; + const slotH = (H - padY * 2) / Math.max(stages.length, 1); + const maxCount = Math.max(1, ...counts); + const widthFor = (c: number) => + 30 + (W - padX * 2 - 30) * (c / maxCount); + + return ( + + + {stages.slice(0, -1).map((s, i) => { + const a = rolePalette(s.colorRole).dot; + const b = rolePalette(stages[i + 1].colorRole).dot; + return ( + + + + + ); + })} + + {stages.slice(0, -1).map((s, i) => { + const cx = W / 2; + const y1 = padY + slotH * i + slotH * 0.6; + const y2 = padY + slotH * (i + 1) + slotH * 0.4; + const w1 = widthFor(counts[i]) / 2; + const w2 = widthFor(counts[i + 1]) / 2; + const ymid = (y1 + y2) / 2; + const d = `M ${cx - w1} ${y1} C ${cx - w1} ${ymid} ${cx - w2} ${ymid} ${cx - w2} ${y2} L ${cx + w2} ${y2} C ${cx + w2} ${ymid} ${cx + w1} ${ymid} ${cx + w1} ${y1} Z`; + return ; + })} + {stages.map((s, i) => { + const palette = rolePalette(s.colorRole); + const y = padY + slotH * i + slotH * 0.5; + const w = widthFor(counts[i]); + return ( + + + + {s.displayName} · {counts[i]} + + + ); + })} + + ); +} diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index 141891a..b7dc1ca 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -3,31 +3,30 @@ import React, { useEffect, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import { - ActionIcon, Box, Button, Checkbox, Group, Modal, Popover, + SegmentedControl, Select, Stack, - Table, Text, TextInput, - Title, } from "@mantine/core"; import { - IconCheck, IconChevronDown, + IconLayoutKanban, IconPlus, - IconTrash, + IconTimeline, } from "@tabler/icons-react"; import Link from "next/link"; import { - APPLICATION_STATUSES, ApplicationStatus, DbApplication, + StageColorRole, + UserStage, } from "@/types/application"; import { clearLocalApplications, @@ -37,112 +36,115 @@ import { createCustomApplication, deleteApplication, syncLocalApplications, + updateApplicationNotes, updateApplicationStatus, } from "@/app/my-applications/actions"; -import CompanyLogo from "@/components/jobs/company-logo"; -import { formatISODate } from "@/lib/utils"; +import NotesModal from "@/components/applications/notes-modal"; +import ApplicationsKanban, { + KanbanSort, +} from "@/components/applications/applications-kanban"; +import ApplicationsTimeline from "@/components/applications/applications-timeline"; +import { rolePalette } from "@/lib/role-palette"; -const STATUS_ORDER: ApplicationStatus[] = [ - "STARTED", - "APPLIED", - "ACCEPTED", - "REJECTED", - "INTERVIEW", -]; +type ViewMode = "kanban" | "timeline"; +const VIEW_STORAGE_KEY = "mp:apps:view:v1"; +const SORT_STORAGE_KEY = "mp:apps:kanban-sort:v1"; -function statusPalette(status: ApplicationStatus | string) { - switch (status) { - case "STARTED": - return { solid: "#9ca3af", muted: "rgba(156,163,175,0.18)" }; - case "APPLIED": - return { solid: "#60a5fa", muted: "rgba(96,165,250,0.18)" }; - case "INTERVIEW": - return { solid: "#ffe22f", muted: "rgba(255,226,47,0.18)" }; - case "ACCEPTED": - return { solid: "#4ade80", muted: "rgba(74,222,128,0.18)" }; - case "REJECTED": - return { solid: "#ff7351", muted: "rgba(255,115,81,0.18)" }; - default: - return { solid: "#9ca3af", muted: "rgba(156,163,175,0.18)" }; +function readView(): ViewMode { + if (typeof window === "undefined") return "kanban"; + try { + const raw = window.localStorage.getItem(VIEW_STORAGE_KEY); + if (raw === "kanban" || raw === "timeline") return raw; + } catch { + // ignore } + return "kanban"; } -function capitalize(s: string) { - return s.charAt(0) + s.slice(1).toLowerCase(); +function readSort(): KanbanSort { + if (typeof window === "undefined") return "newest"; + try { + const raw = window.localStorage.getItem(SORT_STORAGE_KEY); + if (raw === "newest" || raw === "oldest") return raw; + } catch { + // ignore + } + return "newest"; } -function StatusDot({ status }: { status: ApplicationStatus | string }) { - const { solid } = statusPalette(status); +function StatusDot({ role }: { role: StageColorRole }) { + const { dot } = rolePalette(role); return (
); } -function StatusChip({ - status, - count, - small, -}: { - status: ApplicationStatus; - count?: number; - small?: boolean; -}) { - const { solid, muted } = statusPalette(status); - return ( - - {capitalize(status)} - {count !== undefined ? `: ${count}` : ""} - - ); -} - export default function MyApplicationsClient({ initial, + initialStages, }: { initial: DbApplication[]; + initialStages: UserStage[]; }) { const { status: sessionStatus } = useSession(); const [apps, setApps] = useState(initial); + const stages = initialStages; const [syncMessage, setSyncMessage] = useState(null); - const [selectedStatuses, setSelectedStatuses] = useState(() => - STATUS_ORDER.filter( - (s) => s !== "STARTED" || initial.some((a) => a.status === "STARTED"), - ), + const [view, setView] = useState(readView); + const [sort, setSort] = useState(readSort); + const [visibleStages, setVisibleStages] = useState(() => + stages + .filter( + (s) => + s.name !== "STARTED" || initial.some((a) => a.status === "STARTED"), + ) + .map((s) => s.name), ); - const [hoveredRow, setHoveredRow] = useState(null); const [addOpen, setAddOpen] = useState(false); const [customTitle, setCustomTitle] = useState(""); const [customCompany, setCustomCompany] = useState(""); + const defaultCustomStatus = useMemo( + () => + stages.find((s) => s.name === "APPLIED")?.name ?? stages[0]?.name ?? "", + [stages], + ); const [customStatus, setCustomStatus] = - useState("APPLIED"); + useState(defaultCustomStatus); const [customDate, setCustomDate] = useState(() => new Date().toISOString().slice(0, 10), ); const [customLoading, setCustomLoading] = useState(false); + const [notesAppId, setNotesAppId] = useState(null); + + const stageRoleByName = useMemo(() => { + const m = new Map(); + stages.forEach((s) => m.set(s.name, s.colorRole)); + return (name: string) => m.get(name) ?? "neutral"; + }, [stages]); + + useEffect(() => { + try { + window.localStorage.setItem(VIEW_STORAGE_KEY, view); + } catch { + // ignore + } + }, [view]); + + useEffect(() => { + try { + window.localStorage.setItem(SORT_STORAGE_KEY, sort); + } catch { + // ignore + } + }, [sort]); useEffect(() => { if (sessionStatus !== "authenticated") return; @@ -162,29 +164,18 @@ export default function MyApplicationsClient({ })(); }, [sessionStatus]); - // Auto-untick STARTED when it becomes empty; re-tick when it gets apps again useEffect(() => { const startedCount = apps.filter((a) => a.status === "STARTED").length; - setSelectedStatuses((prev) => { + setVisibleStages((prev) => { const has = prev.includes("STARTED"); if (startedCount === 0 && has) return prev.filter((s) => s !== "STARTED"); if (startedCount > 0 && !has) - return STATUS_ORDER.filter((s) => prev.includes(s) || s === "STARTED"); + return stages + .filter((s) => prev.includes(s.name) || s.name === "STARTED") + .map((s) => s.name); return prev; }); - }, [apps]); - - 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; + }, [apps, stages]); async function handleStatusChange( appId: string, @@ -192,6 +183,7 @@ export default function MyApplicationsClient({ oldStatus: ApplicationStatus, next: ApplicationStatus, ) { + if (oldStatus === next) return; setApps((prev) => prev.map((p) => (p._id === appId ? { ...p, status: next } : p)), ); @@ -228,13 +220,37 @@ export default function MyApplicationsClient({ setAddOpen(false); setCustomTitle(""); setCustomCompany(""); - setCustomStatus("APPLIED"); + setCustomStatus(defaultCustomStatus); setCustomDate(new Date().toISOString().slice(0, 10)); } finally { setCustomLoading(false); } } + async function handleSaveNotes(jobId: string, notes: string) { + const trimmed = notes.trim(); + const previous = apps.find((a) => a.jobId === jobId); + setApps((prev) => + prev.map((p) => + p.jobId === jobId ? { ...p, notes: trimmed || undefined } : p, + ), + ); + try { + await updateApplicationNotes(jobId, trimmed); + } catch { + if (previous) + setApps((prev) => + prev.map((p) => + p.jobId === jobId ? { ...p, notes: previous.notes } : p, + ), + ); + } + } + + const notesApp = notesAppId + ? (apps.find((a) => a._id === notesAppId) ?? null) + : null; + if (sessionStatus === "unauthenticated") { return ( -
-
- - Applications - - {/* Desktop: inline chip row */} -
- - {total} total - - {STATUS_ORDER.map((s) => ( - - ))} -
-
- - {/* Mobile: 3+2 grid aligned right */} -
- {STATUS_ORDER.map((s) => ( - - ))} -
+
+

+ Applications +

+

+ {apps.length} tracked across {stages.length} stages +

- {/* Header actions */} -
+
+ setView(v as ViewMode)} + data={[ + { + value: "kanban", + label: ( + + + Kanban + + ), + }, + { + value: "timeline", + label: ( + + + Timeline + + ), + }, + ]} + styles={{ + root: { + backgroundColor: "transparent", + border: "2px solid #3a3a3a", + borderRadius: "0.75rem", + padding: 2, + }, + indicator: { + backgroundColor: "#3a3a3a", + borderRadius: "0.5rem", + }, + label: { + color: "rgba(255,255,255,0.65)", + padding: "4px 10px", + fontWeight: 600, + fontSize: 12.5, + }, + }} + /> + - {/* Filter popover */} - - -