diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e3ce7db --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(npx next:*)", + "Bash(npm run:*)", + "Bash(node -e \"console.log\\(require\\('eslint-config-next/package.json'\\).version\\)\")", + "Bash(npx tsc:*)" + ] + } +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..6918b05 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,93 @@ +# 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. + +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`) + +### 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. + +--- + +## 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. + +--- + +## 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. + +### 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. + +### 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`. + +### 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. + +### 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. + +--- + +## 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. + +### 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 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/eslint.config.mjs b/frontend/eslint.config.mjs index c85fb67..f5300a0 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,16 +1,6 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import coreWebVitals from "eslint-config-next/core-web-vitals"; +import typescript from "eslint-config-next/typescript"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), -]; +const eslintConfig = [...coreWebVitals, ...typescript]; export default eslintConfig; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +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/next.config.ts b/frontend/next.config.ts index 68a6c64..5be3493 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - output: "standalone", + serverExternalPackages: ["mongodb", "pino", "pino-pretty"], }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c25d7e9..4af8192 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,12 +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": "^16.2.3", + "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", "react": "^19.0.0", @@ -75,6 +78,74 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "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/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -840,9 +911,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -859,9 +927,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -878,9 +943,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -897,9 +959,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -916,9 +975,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -935,9 +991,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -954,9 +1007,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -973,9 +1023,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -992,9 +1039,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1017,9 +1061,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1042,9 +1083,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1067,9 +1105,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1092,9 +1127,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1117,9 +1149,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1142,9 +1171,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1167,9 +1193,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1441,9 +1464,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1460,9 +1480,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1479,9 +1496,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1498,9 +1512,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1600,6 +1611,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", @@ -2472,6 +2492,15 @@ "node": ">=6.0.0" } }, + "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", @@ -2766,6 +2795,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", @@ -4936,6 +4974,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", @@ -5425,6 +5472,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", @@ -5475,6 +5554,12 @@ "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/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5605,6 +5690,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", @@ -5623,6 +5717,48 @@ "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/openid-client/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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6085,6 +6221,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", @@ -6111,6 +6269,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", @@ -7742,6 +7906,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", diff --git a/frontend/package.json b/frontend/package.json index f07d174..5887bed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,10 +6,11 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "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": "^16.2.3", + "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/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/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..4aa55ec --- /dev/null +++ b/frontend/src/app/my-applications/actions.ts @@ -0,0 +1,182 @@ +"use server"; + +import clientPromise from "@/lib/mongodb"; +import { authOptions } from "@/lib/auth"; +import { getServerSession } from "next-auth"; +import { ObjectId } from "mongodb"; +import { ApplicationJobSnapshot, ApplicationStatus, DbApplication, LocalApplication } from "@/types/application"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function requireUserId(session: any) { + const id = (session?.user 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(); + + if (!docs.length) return []; + + // Bulk-fetch logos from active_jobs for snapshots that don't have one + const jobIds = docs + .map((d) => { try { return new ObjectId(d.jobId); } catch { return null; } }) + .filter((id): id is ObjectId => id !== null); + + const logoMap = new Map(); + if (jobIds.length) { + const jobs = await db + .collection("active_jobs") + .find({ _id: { $in: jobIds } }, { projection: { _id: 1, "company.logo": 1 } }) + .toArray(); + for (const job of jobs) { + logoMap.set(job._id.toString(), job.company?.logo); + } + } + + 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, + logo: d.jobSnapshot.logo ?? logoMap.get(d.jobId), + }, + })) 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 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 createCustomApplication( + title: string, + companyName: string, + status: ApplicationStatus, + date: string, // "YYYY-MM-DD" +): Promise { + const session = await getServerSession(authOptions); + const userId = requireUserId(session); + + const client = await clientPromise; + const db = client.db(process.env.MONGODB_DATABASE || "default"); + + const jobId = `custom_${new ObjectId().toString()}`; + const jobSnapshot: ApplicationJobSnapshot = { jobId, title, companyName }; + const parsedDate = new Date(date); + + const result = await db.collection("applications").insertOne({ + userId: new ObjectId(userId), + jobId, + status, + startedAt: parsedDate, + updatedAt: parsedDate, + jobSnapshot, + }); + + return { + _id: result.insertedId.toString(), + jobId, + status, + startedAt: parsedDate.toISOString(), + updatedAt: parsedDate.toISOString(), + jobSnapshot, + }; +} + +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..57bac1b --- /dev/null +++ b/frontend/src/app/my-applications/page.tsx @@ -0,0 +1,15 @@ +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..00112e2 --- /dev/null +++ b/frontend/src/app/sign-in/page.tsx @@ -0,0 +1,96 @@ +"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") || "/"; + + 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..314e85c --- /dev/null +++ b/frontend/src/app/sign-up/page.tsx @@ -0,0 +1,93 @@ +"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..6fbe90b --- /dev/null +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -0,0 +1,667 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { + ActionIcon, + Box, + Button, + Checkbox, + Group, + Modal, + Popover, + Select, + Stack, + Table, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { IconCheck, IconChevronDown, IconPlus, IconTrash } from "@tabler/icons-react"; +import Link from "next/link"; +import { + APPLICATION_STATUSES, + ApplicationStatus, + DbApplication, +} from "@/types/application"; +import { + clearLocalApplications, + getLocalApplications, +} from "@/lib/local-applications"; +import { + createCustomApplication, + deleteApplication, + syncLocalApplications, + updateApplicationStatus, +} from "@/app/my-applications/actions"; +import CompanyLogo from "@/components/jobs/company-logo"; +import { formatISODate } from "@/lib/utils"; + +const STATUS_ORDER: ApplicationStatus[] = [ + "STARTED", + "APPLIED", + "ACCEPTED", + "REJECTED", + "INTERVIEW", +]; + +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 capitalize(s: string) { + return s.charAt(0) + s.slice(1).toLowerCase(); +} + +function StatusDot({ status }: { status: ApplicationStatus | string }) { + const { solid } = statusPalette(status); + 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, +}: { + initial: DbApplication[]; +}) { + const { status: sessionStatus } = useSession(); + const [apps, setApps] = useState(initial); + const [syncMessage, setSyncMessage] = useState(null); + const [selectedStatuses, setSelectedStatuses] = useState(() => + STATUS_ORDER.filter( + (s) => s !== "STARTED" || initial.some((a) => a.status === "STARTED") + ) + ); + const [hoveredRow, setHoveredRow] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [customTitle, setCustomTitle] = useState(""); + const [customCompany, setCustomCompany] = useState(""); + const [customStatus, setCustomStatus] = useState("APPLIED"); + const [customDate, setCustomDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [customLoading, setCustomLoading] = useState(false); + + 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]); + + // 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) => { + 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 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; + + 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]); + } + } + + async function handleCreateCustom() { + if (!customTitle.trim() || !customCompany.trim()) return; + setCustomLoading(true); + try { + const newApp = await createCustomApplication(customTitle.trim(), customCompany.trim(), customStatus, customDate); + setApps((prev) => [newApp, ...prev]); + setAddOpen(false); + setCustomTitle(""); + setCustomCompany(""); + setCustomStatus("APPLIED"); + setCustomDate(new Date().toISOString().slice(0, 10)); + } finally { + setCustomLoading(false); + } + } + + if (sessionStatus === "unauthenticated") { + return ( + + You're not signed in.{" "} + + Sign in + {" "} + to view and manage your applications across devices. + + ); + } + + return ( +
+ {syncMessage && ( + + {syncMessage} + + )} + + {/* Page header */} +
+
+
+ + Applications + + {/* Desktop: inline chip row */} +
+ + {total} total + + {STATUS_ORDER.map((s) => ( + + ))} +
+
+ + {/* Mobile: 3+2 grid aligned right */} +
+ {STATUS_ORDER.map((s) => ( + + ))} +
+
+ + {/* Header actions */} +
+ + + {/* Filter popover */} + + + + + + + + {STATUS_ORDER.map((s) => ( + + + {capitalize(s)} + + } + /> + ))} + + + + +
{/* end header actions */} +
+ + {/* Add custom application modal */} + setAddOpen(false)} + title={Add application} + styles={{ + content: { backgroundColor: "#2e2e2e", border: "2px solid #3a3a3a", borderRadius: "1rem" }, + header: { backgroundColor: "#2e2e2e" }, + overlay: { backdropFilter: "blur(2px)" }, + }} + > + + 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" }, + }} + /> + 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" }, + }} + /> + ({ + 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); + }} + styles={{ + 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)} + + + e.stopPropagation()} + > + + {status === "STARTED" && ( + + )} + handleDelete(a._id, a.jobId)} + > + + + + + + ); + })} + + + )} + + + {/* Mobile: separate cards — hidden on sm+ */} +
+ {statusApps.length === 0 ? ( + + + No {capitalize(status).toLowerCase()} applications + + + ) : ( + statusApps.map((a) => { + const url = a.jobSnapshot.applicationUrl; + return ( + url && window.open(url, "_blank", "noreferrer")} + > + {/* Row 1: company + date */} +
+
+ + + {a.jobSnapshot.companyName} + +
+ + {formatISODate(a.updatedAt)} + +
+ + {/* Row 2: role title */} +
+ {a.jobSnapshot.title} +
+ + {/* Row 3: status select + actions */} +
e.stopPropagation()} + > + } placeholder="Search company or role..." - onChange={(e) => handleInputChange(e.currentTarget.value)} + onChange={(e) => handleSearch(e.currentTarget.value)} radius="lg" variant="filled" className="w-full" diff --git a/frontend/src/context/filter/filter-provider.tsx b/frontend/src/context/filter/filter-provider.tsx index e51cdc7..b67b292 100644 --- a/frontend/src/context/filter/filter-provider.tsx +++ b/frontend/src/context/filter/filter-provider.tsx @@ -1,7 +1,7 @@ // frontend/src/context/jobs/jobs-provider.tsx "use client"; -import { ReactNode, useEffect, useState } from "react"; +import { ReactNode, useState } from "react"; import { FilterContext } from "./filter-context"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CreateQueryString } from "@/lib/utils"; @@ -74,6 +74,15 @@ export function FilterProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(false); const [totalJobs, setTotalJobs] = useState(0); + // Wrapper for SelectedJob to validate attributes first + const setSelectedJob = (job: Job | null) => { + // Remove duplicates from working_rights + if (job?.working_rights && job.working_rights.length > 0) { + job.working_rights = [...new Set(job.working_rights)]; + } + setSelectedJobInternal(job); + }; + const updateFilters = (newFilters: Partial) => { setIsLoading(true); setFilters((curr) => ({ ...curr, ...newFilters })); @@ -82,28 +91,23 @@ export function FilterProvider({ children }: { children: ReactNode }) { router.push(`/jobs?${params}`); }; - useEffect(() => { + // React-recommended render-time state adjustment: detect route changes + // and update state without effects (avoids cascading renders). + const searchParamsKey = searchParams.toString(); + const [prev, setPrev] = useState({ pathname, searchParamsKey }); + + if (pathname !== prev.pathname || searchParamsKey !== prev.searchParamsKey) { + setPrev({ pathname, searchParamsKey }); + if (pathname === "/jobs") { setIsLoading(false); - setSelectedJob(null); + setSelectedJobInternal(null); } - }, [pathname, searchParams]); - useEffect(() => { - // clear filters on return to homepage if (pathname === "/") { setFilters(emptyFilterState); } - }, [pathname]); - - // Wrapper for SelectedJob to validate attributes first - const setSelectedJob = (job: Job | null) => { - // Remove duplicates from working_rights - if (job?.working_rights && job.working_rights.length > 0) { - job.working_rights = [...new Set(job.working_rights)]; - } - setSelectedJobInternal(job); - }; + } const clearFilters = () => { setIsLoading(true); 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..3cf1dab --- /dev/null +++ b/frontend/src/lib/local-applications.ts @@ -0,0 +1,56 @@ +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, + logo: job.company?.logo, + }, + }; + + 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/lib/utils.ts b/frontend/src/lib/utils.ts index c598b5b..a5bf7d2 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -138,10 +138,10 @@ export function getPluralLabel(label: string) { export function formatISODate(isoDate: string): string { const date = new Date(isoDate); - const day = date.getDate(); - const month = date.toLocaleString("en-US", { month: "short" }); - const year = date.getFullYear(); - return `${day} ${month} ${year}`; + const day = String(date.getUTCDate()).padStart(2, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const year = date.getUTCFullYear(); + return `${day}/${month}/${year}`; } export const formatWorkingRights = (rights: WorkingRight[]): string => { diff --git a/frontend/src/types/application.ts b/frontend/src/types/application.ts new file mode 100644 index 0000000..1ae69ca --- /dev/null +++ b/frontend/src/types/application.ts @@ -0,0 +1,30 @@ +export const APPLICATION_STATUSES = [ + "STARTED", + "APPLIED", + "ACCEPTED", + "REJECTED", + "INTERVIEW", +] as const; + +export type ApplicationStatus = (typeof APPLICATION_STATUSES)[number]; + +export type ApplicationJobSnapshot = { + jobId: string; + title: string; + companyName: string; + applicationUrl?: string; + logo?: string; +}; + +export type LocalApplication = { + jobId: string; + status: ApplicationStatus; + startedAt: string; + updatedAt: string; + jobSnapshot: ApplicationJobSnapshot; +}; + +export type DbApplication = LocalApplication & { + _id: string; +}; +