ketannnn commited on
Commit
1768a66
·
1 Parent(s): aeacc04

feat: build Next.js frontend design system and upload flow

Browse files
frontend/src/app/globals.css CHANGED
@@ -1,26 +1,316 @@
1
- @import "tailwindcss";
2
 
3
  :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  }
7
 
8
- @theme inline {
9
- --color-background: var(--background);
10
- --color-foreground: var(--foreground);
11
- --font-sans: var(--font-geist-sans);
12
- --font-mono: var(--font-geist-mono);
 
 
 
 
 
 
13
  }
14
 
15
- @media (prefers-color-scheme: dark) {
16
- :root {
17
- --background: #0a0a0a;
18
- --foreground: #ededed;
19
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
 
 
 
 
 
 
 
21
 
22
- body {
23
- background: var(--background);
24
- color: var(--foreground);
25
- font-family: Arial, Helvetica, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
2
 
3
  :root {
4
+ --bg-primary: #0a0a0f;
5
+ --bg-secondary: #111118;
6
+ --bg-card: #16161f;
7
+ --bg-card-hover: #1c1c28;
8
+ --bg-glass: rgba(22, 22, 31, 0.8);
9
+ --border: rgba(255, 255, 255, 0.06);
10
+ --border-strong: rgba(255, 255, 255, 0.12);
11
+
12
+ --accent: #6c63ff;
13
+ --accent-light: #8b85ff;
14
+ --accent-dim: rgba(108, 99, 255, 0.15);
15
+ --accent-glow: rgba(108, 99, 255, 0.3);
16
+
17
+ --green: #22c55e;
18
+ --green-dim: rgba(34, 197, 94, 0.15);
19
+ --yellow: #eab308;
20
+ --yellow-dim: rgba(234, 179, 8, 0.15);
21
+ --red: #ef4444;
22
+ --red-dim: rgba(239, 68, 68, 0.15);
23
+ --blue: #3b82f6;
24
+ --blue-dim: rgba(59, 130, 246, 0.15);
25
+
26
+ --text-primary: #f0f0f8;
27
+ --text-secondary: #9494b0;
28
+ --text-muted: #5c5c78;
29
+
30
+ --radius: 12px;
31
+ --radius-lg: 16px;
32
+ --radius-xl: 20px;
33
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
34
+ --shadow-lg: 0 8px 48px rgba(0, 0, 0, 0.6);
35
+ --shadow-accent: 0 0 40px rgba(108, 99, 255, 0.15);
36
  }
37
 
38
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
39
+
40
+ html { font-size: 16px; scroll-behavior: smooth; }
41
+
42
+ body {
43
+ font-family: 'Inter', system-ui, sans-serif;
44
+ background: var(--bg-primary);
45
+ color: var(--text-primary);
46
+ line-height: 1.6;
47
+ min-height: 100vh;
48
+ -webkit-font-smoothing: antialiased;
49
  }
50
 
51
+ a { color: inherit; text-decoration: none; }
52
+
53
+ .container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
54
+
55
+ .page { min-height: 100vh; padding: 80px 0 48px; }
56
+
57
+ /* NAV */
58
+ .nav {
59
+ position: fixed; top: 0; left: 0; right: 0; z-index: 100;
60
+ background: rgba(10, 10, 15, 0.85);
61
+ backdrop-filter: blur(20px);
62
+ border-bottom: 1px solid var(--border);
63
+ height: 64px;
64
+ }
65
+ .nav-inner {
66
+ display: flex; align-items: center; justify-content: space-between;
67
+ height: 64px;
68
+ }
69
+ .nav-logo {
70
+ font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
71
+ background: linear-gradient(135deg, var(--accent-light), #a78bfa);
72
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
73
  }
74
+ .nav-links { display: flex; gap: 8px; }
75
+ .nav-link {
76
+ padding: 6px 14px; border-radius: 8px; font-size: 0.875rem;
77
+ color: var(--text-secondary); transition: all 0.2s;
78
+ }
79
+ .nav-link:hover { background: var(--bg-card); color: var(--text-primary); }
80
+ .nav-link.active { background: var(--accent-dim); color: var(--accent-light); }
81
 
82
+ /* CARDS */
83
+ .card {
84
+ background: var(--bg-card);
85
+ border: 1px solid var(--border);
86
+ border-radius: var(--radius-lg);
87
+ transition: border-color 0.2s, box-shadow 0.2s;
88
+ }
89
+ .card:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
90
+ .card-glass {
91
+ background: var(--bg-glass);
92
+ backdrop-filter: blur(20px);
93
+ border: 1px solid var(--border);
94
+ border-radius: var(--radius-lg);
95
+ }
96
+
97
+ /* BUTTONS */
98
+ .btn {
99
+ display: inline-flex; align-items: center; gap: 8px;
100
+ padding: 10px 20px; border-radius: 10px; font-size: 0.875rem;
101
+ font-weight: 500; border: none; cursor: pointer; transition: all 0.2s;
102
+ font-family: inherit; white-space: nowrap;
103
+ }
104
+ .btn-primary {
105
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
106
+ color: #fff;
107
+ box-shadow: 0 0 20px var(--accent-glow);
108
+ }
109
+ .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 24px var(--accent-glow); }
110
+ .btn-secondary {
111
+ background: var(--bg-card); border: 1px solid var(--border-strong);
112
+ color: var(--text-primary);
113
+ }
114
+ .btn-secondary:hover { background: var(--bg-card-hover); border-color: var(--accent); }
115
+ .btn-ghost { background: transparent; color: var(--text-secondary); }
116
+ .btn-ghost:hover { background: var(--bg-card); color: var(--text-primary); }
117
+ .btn-sm { padding: 6px 12px; font-size: 0.8125rem; }
118
+ .btn-lg { padding: 14px 28px; font-size: 1rem; }
119
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
120
+
121
+ /* BADGES */
122
+ .badge {
123
+ display: inline-flex; align-items: center; gap: 4px;
124
+ padding: 3px 10px; border-radius: 99px; font-size: 0.75rem; font-weight: 500;
125
+ }
126
+ .badge-green { background: var(--green-dim); color: var(--green); }
127
+ .badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
128
+ .badge-red { background: var(--red-dim); color: var(--red); }
129
+ .badge-blue { background: var(--blue-dim); color: var(--blue); }
130
+ .badge-purple { background: var(--accent-dim); color: var(--accent-light); }
131
+
132
+ /* INPUTS */
133
+ .input, .textarea {
134
+ width: 100%; background: var(--bg-secondary); border: 1px solid var(--border-strong);
135
+ border-radius: 10px; color: var(--text-primary); font-family: inherit;
136
+ font-size: 0.9rem; padding: 12px 16px; transition: border-color 0.2s, box-shadow 0.2s;
137
+ outline: none;
138
+ }
139
+ .input:focus, .textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
140
+ .textarea { resize: vertical; min-height: 120px; }
141
+ .input::placeholder, .textarea::placeholder { color: var(--text-muted); }
142
+
143
+ /* FORM */
144
+ .form-group { display: flex; flex-direction: column; gap: 6px; }
145
+ .label { font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary); }
146
+
147
+ /* SCORE BAR */
148
+ .score-bar-wrap { display: flex; flex-direction: column; gap: 6px; }
149
+ .score-bar-row { display: flex; align-items: center; gap: 10px; }
150
+ .score-bar-label { font-size: 0.75rem; color: var(--text-secondary); width: 70px; flex-shrink: 0; }
151
+ .score-bar-track { flex: 1; height: 6px; background: var(--bg-secondary); border-radius: 99px; overflow: hidden; }
152
+ .score-bar-fill { height: 100%; border-radius: 99px; transition: width 0.5s cubic-bezier(0.4,0,0.2,1); }
153
+ .score-bar-pct { font-size: 0.75rem; color: var(--text-muted); width: 36px; text-align: right; flex-shrink: 0; }
154
+
155
+ /* SLIDERS */
156
+ .slider { -webkit-appearance: none; width: 100%; height: 4px; border-radius: 99px; background: var(--bg-secondary); outline: none; }
157
+ .slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--accent); cursor: pointer; box-shadow: 0 0 8px var(--accent-glow); transition: transform 0.2s; }
158
+ .slider::-webkit-slider-thumb:hover { transform: scale(1.2); }
159
+
160
+ /* CHIP */
161
+ .chip {
162
+ display: inline-flex; align-items: center; padding: 3px 10px;
163
+ border-radius: 6px; font-size: 0.7rem; font-weight: 500;
164
+ background: var(--bg-secondary); border: 1px solid var(--border);
165
+ color: var(--text-secondary); white-space: nowrap;
166
+ }
167
+
168
+ /* HERO */
169
+ .hero {
170
+ text-align: center; padding: 56px 0 40px;
171
+ }
172
+ .hero-eyebrow {
173
+ display: inline-flex; align-items: center; gap: 6px;
174
+ padding: 4px 14px; border-radius: 99px;
175
+ background: var(--accent-dim); border: 1px solid var(--accent-glow);
176
+ color: var(--accent-light); font-size: 0.8rem; font-weight: 500; margin-bottom: 20px;
177
+ }
178
+ .hero-title {
179
+ font-size: clamp(2rem, 5vw, 3.25rem); font-weight: 800; letter-spacing: -0.04em;
180
+ line-height: 1.1; margin-bottom: 16px;
181
+ }
182
+ .hero-title span {
183
+ background: linear-gradient(135deg, var(--accent-light), #a78bfa, #38bdf8);
184
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
185
+ }
186
+ .hero-sub { font-size: 1.05rem; color: var(--text-secondary); max-width: 540px; margin: 0 auto 32px; }
187
+
188
+ /* GRID */
189
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
190
+ .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
191
+
192
+ /* STAT CARD */
193
+ .stat-card { padding: 20px; border-radius: var(--radius-lg); background: var(--bg-card); border: 1px solid var(--border); }
194
+ .stat-value { font-size: 2rem; font-weight: 700; letter-spacing: -0.03em; }
195
+ .stat-label { font-size: 0.8125rem; color: var(--text-secondary); margin-top: 2px; }
196
+
197
+ /* UPLOAD ZONE */
198
+ .upload-zone {
199
+ border: 2px dashed var(--border-strong); border-radius: var(--radius-xl);
200
+ padding: 40px; text-align: center; cursor: pointer; transition: all 0.25s;
201
+ background: var(--bg-secondary);
202
+ }
203
+ .upload-zone:hover, .upload-zone.drag-over {
204
+ border-color: var(--accent); background: var(--accent-dim); box-shadow: 0 0 32px var(--accent-dim);
205
+ }
206
+ .upload-icon { font-size: 2.5rem; margin-bottom: 12px; }
207
+ .upload-title { font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
208
+ .upload-sub { font-size: 0.8125rem; color: var(--text-secondary); }
209
+
210
+ /* RANK BADGE */
211
+ .rank-badge {
212
+ width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
213
+ font-size: 0.875rem; font-weight: 700; flex-shrink: 0;
214
+ }
215
+ .rank-1 { background: linear-gradient(135deg, #f59e0b, #d97706); color: #fff; box-shadow: 0 0 16px rgba(245,158,11,0.4); }
216
+ .rank-2 { background: linear-gradient(135deg, #94a3b8, #64748b); color: #fff; }
217
+ .rank-3 { background: linear-gradient(135deg, #cd7c39, #92400e); color: #fff; }
218
+ .rank-other { background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); }
219
+
220
+ /* CANDIDATE CARD */
221
+ .candidate-card {
222
+ display: flex; flex-direction: column; gap: 14px;
223
+ padding: 20px; border-radius: var(--radius-lg);
224
+ background: var(--bg-card); border: 1px solid var(--border);
225
+ cursor: pointer; transition: all 0.2s;
226
+ }
227
+ .candidate-card:hover { border-color: var(--accent); box-shadow: 0 0 24px var(--accent-dim); transform: translateY(-2px); }
228
+ .candidate-card-top { display: flex; align-items: center; gap: 12px; }
229
+ .candidate-card-info { flex: 1; min-width: 0; }
230
+ .candidate-name { font-weight: 600; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
231
+ .candidate-meta { font-size: 0.8rem; color: var(--text-secondary); display: flex; gap: 8px; flex-wrap: wrap; }
232
+
233
+ /* SCORE PILL */
234
+ .score-pill {
235
+ display: flex; align-items: center; justify-content: center; flex-direction: column;
236
+ min-width: 56px; padding: 6px 10px; border-radius: 10px;
237
+ background: var(--accent-dim); border: 1px solid var(--accent-glow);
238
+ }
239
+ .score-pill-value { font-size: 1.1rem; font-weight: 700; color: var(--accent-light); line-height: 1; }
240
+ .score-pill-label { font-size: 0.6rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
241
+
242
+ /* GAP ITEM */
243
+ .gap-item {
244
+ display: flex; align-items: flex-start; gap: 10px;
245
+ padding: 10px 14px; border-radius: 10px;
246
+ background: var(--bg-secondary); border: 1px solid var(--border);
247
+ }
248
+ .gap-icon { font-size: 1rem; flex-shrink: 0; margin-top: 2px; }
249
+ .gap-type { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
250
+ .gap-detail { font-size: 0.8125rem; color: var(--text-secondary); }
251
+
252
+ /* EXPLANATION BLOCK */
253
+ .explanation-block {
254
+ padding: 20px; border-radius: var(--radius-lg);
255
+ background: linear-gradient(135deg, rgba(108,99,255,0.05), rgba(167,139,250,0.05));
256
+ border: 1px solid var(--accent-glow);
257
+ }
258
+ .explanation-text { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.75; white-space: pre-wrap; }
259
+
260
+ /* PAGE HEADER */
261
+ .page-header { margin-bottom: 32px; }
262
+ .page-title { font-size: 1.625rem; font-weight: 700; letter-spacing: -0.03em; margin-bottom: 6px; }
263
+ .page-subtitle { color: var(--text-secondary); font-size: 0.9rem; }
264
+
265
+ /* WEIGHT SLIDER PANEL */
266
+ .weight-panel { padding: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); }
267
+ .weight-row { display: flex; flex-direction: column; gap: 6px; }
268
+ .weight-label-row { display: flex; justify-content: space-between; align-items: center; }
269
+ .weight-key { font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary); }
270
+ .weight-val { font-size: 0.8125rem; font-weight: 600; color: var(--accent-light); }
271
+
272
+ /* TABS */
273
+ .tabs { display: flex; gap: 4px; background: var(--bg-secondary); padding: 4px; border-radius: 10px; }
274
+ .tab { padding: 8px 14px; border-radius: 8px; font-size: 0.8125rem; font-weight: 500; cursor: pointer; color: var(--text-secondary); transition: all 0.2s; border: none; background: none; font-family: inherit; }
275
+ .tab.active { background: var(--bg-card); color: var(--text-primary); box-shadow: var(--shadow); }
276
+ .tab:hover:not(.active) { color: var(--text-primary); }
277
+
278
+ /* LOADING */
279
+ .spinner { width: 24px; height: 24px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; }
280
+ @keyframes spin { to { transform: rotate(360deg); } }
281
+
282
+ .skeleton { background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%); background-size: 200% 100%; animation: shimmer 1.4s infinite; border-radius: 6px; }
283
+ @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
284
+
285
+ /* TRAJECTORY */
286
+ .trajectory-bar { display: flex; align-items: center; gap: 10px; }
287
+ .trajectory-track { flex: 1; height: 8px; border-radius: 99px; background: var(--bg-secondary); overflow: hidden; }
288
+ .trajectory-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-light)); transition: width 0.6s cubic-bezier(0.4,0,0.2,1); }
289
+ .trajectory-label { font-size: 0.75rem; color: var(--text-secondary); width: 80px; }
290
+ .trajectory-val { font-size: 0.75rem; font-weight: 600; color: var(--accent-light); width: 36px; text-align: right; }
291
+
292
+ /* DIVIDER */
293
+ .divider { height: 1px; background: var(--border); margin: 24px 0; }
294
+
295
+ /* TOAST */
296
+ .toast {
297
+ position: fixed; bottom: 24px; right: 24px; z-index: 1000;
298
+ padding: 12px 20px; border-radius: 12px; min-width: 280px;
299
+ background: var(--bg-card); border: 1px solid var(--border);
300
+ box-shadow: var(--shadow-lg); animation: slide-up 0.3s ease;
301
+ }
302
+ @keyframes slide-up { from { transform: translateY(16px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
303
+ .toast-success { border-color: var(--green); }
304
+ .toast-error { border-color: var(--red); }
305
+
306
+ /* SCROLLBAR */
307
+ ::-webkit-scrollbar { width: 6px; }
308
+ ::-webkit-scrollbar-track { background: var(--bg-primary); }
309
+ ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 99px; }
310
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
311
+
312
+ /* RESPONSIVE */
313
+ @media (max-width: 768px) {
314
+ .grid-2, .grid-3 { grid-template-columns: 1fr; }
315
+ .hero-title { font-size: 1.875rem; }
316
  }
frontend/src/app/layout.tsx CHANGED
@@ -1,33 +1,27 @@
1
  import type { Metadata } from "next";
2
- import { Geist, Geist_Mono } from "next/font/google";
3
  import "./globals.css";
4
 
5
- const geistSans = Geist({
6
- variable: "--font-geist-sans",
7
- subsets: ["latin"],
8
- });
9
-
10
- const geistMono = Geist_Mono({
11
- variable: "--font-geist-mono",
12
- subsets: ["latin"],
13
- });
14
-
15
  export const metadata: Metadata = {
16
- title: "Create Next App",
17
- description: "Generated by create next app",
 
18
  };
19
 
20
- export default function RootLayout({
21
- children,
22
- }: Readonly<{
23
- children: React.ReactNode;
24
- }>) {
25
  return (
26
- <html
27
- lang="en"
28
- className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29
- >
30
- <body className="min-h-full flex flex-col">{children}</body>
 
 
 
 
 
 
 
 
31
  </html>
32
  );
33
  }
 
1
  import type { Metadata } from "next";
 
2
  import "./globals.css";
3
 
 
 
 
 
 
 
 
 
 
 
4
  export const metadata: Metadata = {
5
+ title: "TalentPulse AI Candidate Matching",
6
+ description: "Two-stage AI pipeline: vector retrieval + cross-encoder reranking + LLM explanations for recruiting at scale.",
7
+ keywords: ["recruiting", "AI matching", "candidate search", "talent pipeline"],
8
  };
9
 
10
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
 
 
 
 
11
  return (
12
+ <html lang="en">
13
+ <body>
14
+ <nav className="nav">
15
+ <div className="container nav-inner">
16
+ <a href="/" className="nav-logo">⚡ TalentPulse</a>
17
+ <div className="nav-links">
18
+ <a href="/" className="nav-link">Dashboard</a>
19
+ <a href="/jds" className="nav-link">Job Descriptions</a>
20
+ </div>
21
+ </div>
22
+ </nav>
23
+ {children}
24
+ </body>
25
  </html>
26
  );
27
  }
frontend/src/app/page.tsx CHANGED
@@ -1,65 +1,211 @@
1
- import Image from "next/image";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- export default function Home() {
4
  return (
5
- <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6
- <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7
- <Image
8
- className="dark:invert"
9
- src="/next.svg"
10
- alt="Next.js logo"
11
- width={100}
12
- height={20}
13
- priority
14
- />
15
- <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
16
- <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
17
- To get started, edit the page.tsx file.
18
  </h1>
19
- <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
20
- Looking for a starting point or more instructions? Head over to{" "}
21
- <a
22
- href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
23
- className="font-medium text-zinc-950 dark:text-zinc-50"
24
- >
25
- Templates
26
- </a>{" "}
27
- or the{" "}
28
- <a
29
- href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
30
- className="font-medium text-zinc-950 dark:text-zinc-50"
31
- >
32
- Learning
33
- </a>{" "}
34
- center.
35
  </p>
36
  </div>
37
- <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
38
- <a
39
- className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
40
- href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
41
- target="_blank"
42
- rel="noopener noreferrer"
43
- >
44
- <Image
45
- className="dark:invert"
46
- src="/vercel.svg"
47
- alt="Vercel logomark"
48
- width={16}
49
- height={16}
50
- />
51
- Deploy Now
52
- </a>
53
- <a
54
- className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
55
- href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
56
- target="_blank"
57
- rel="noopener noreferrer"
58
- >
59
- Documentation
60
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  </div>
62
- </main>
63
- </div>
64
  );
65
  }
 
1
+ "use client";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import { api, type JD } from "../lib/api";
4
+
5
+ export default function HomePage() {
6
+ const [jds, setJDs] = useState<JD[]>([]);
7
+ const [candidateCount, setCandidateCount] = useState<number | null>(null);
8
+ const [jdTitle, setJDTitle] = useState("");
9
+ const [jdText, setJDText] = useState("");
10
+ const [uploading, setUploading] = useState(false);
11
+ const [creatingJD, setCreatingJD] = useState(false);
12
+ const [dragOver, setDragOver] = useState(false);
13
+ const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
14
+ const [taskId, setTaskId] = useState<string | null>(null);
15
+ const [taskStatus, setTaskStatus] = useState<string | null>(null);
16
+
17
+ const showToast = (msg: string, type: "success" | "error" = "success") => {
18
+ setToast({ msg, type });
19
+ setTimeout(() => setToast(null), 3500);
20
+ };
21
+
22
+ const loadData = useCallback(async () => {
23
+ const [jdList, cnt] = await Promise.all([
24
+ api.listJDs().catch(() => []),
25
+ api.candidateCount().catch(() => ({ count: 0 })),
26
+ ]);
27
+ setJDs(jdList as JD[]);
28
+ setCandidateCount((cnt as { count: number }).count);
29
+ }, []);
30
+
31
+ useEffect(() => { loadData(); }, [loadData]);
32
+
33
+ useEffect(() => {
34
+ if (!taskId) return;
35
+ const interval = setInterval(async () => {
36
+ const s = await api.taskStatus(taskId);
37
+ setTaskStatus(s.status);
38
+ if (s.status === "SUCCESS" || s.status === "FAILURE") {
39
+ clearInterval(interval);
40
+ loadData();
41
+ showToast(s.status === "SUCCESS" ? "Candidates ingested successfully!" : "Ingestion failed", s.status === "SUCCESS" ? "success" : "error");
42
+ }
43
+ }, 2000);
44
+ return () => clearInterval(interval);
45
+ }, [taskId, loadData]);
46
+
47
+ const handleFileUpload = async (file: File) => {
48
+ setUploading(true);
49
+ try {
50
+ const res = await api.uploadCandidates(file);
51
+ setTaskId(res.task_id);
52
+ setTaskStatus("PENDING");
53
+ showToast(`Queued ${res.queued} candidates for ingestion`);
54
+ } catch (e: unknown) {
55
+ showToast((e as Error).message, "error");
56
+ } finally {
57
+ setUploading(false);
58
+ }
59
+ };
60
+
61
+ const handleDrop = (e: React.DragEvent) => {
62
+ e.preventDefault();
63
+ setDragOver(false);
64
+ const file = e.dataTransfer.files[0];
65
+ if (file) handleFileUpload(file);
66
+ };
67
+
68
+ const handleCreateJD = async () => {
69
+ if (!jdTitle.trim() || !jdText.trim()) return showToast("Title and description required", "error");
70
+ setCreatingJD(true);
71
+ try {
72
+ await api.createJD(jdTitle.trim(), jdText.trim());
73
+ setJDTitle(""); setJDText("");
74
+ loadData();
75
+ showToast("Job description created and queued for processing!");
76
+ } catch (e: unknown) {
77
+ showToast((e as Error).message, "error");
78
+ } finally {
79
+ setCreatingJD(false);
80
+ }
81
+ };
82
+
83
+ const qualityColor = (q: string) => q === "good" ? "badge-green" : q === "fair" ? "badge-yellow" : "badge-red";
84
 
 
85
  return (
86
+ <main className="page">
87
+ <div className="container">
88
+ <div className="hero">
89
+ <div className="hero-eyebrow">⚡ AI-Powered Recruiting</div>
90
+ <h1 className="hero-title">
91
+ Find the <span>perfect candidate</span><br />at any scale
 
 
 
 
 
 
 
92
  </h1>
93
+ <p className="hero-sub">
94
+ Two-stage retrieval + cross-encoder reranking + LLM explanations.
95
+ Upload candidates, post a JD, get ranked matches in seconds.
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  </p>
97
  </div>
98
+
99
+ <div className="grid-3" style={{ marginBottom: 40 }}>
100
+ <div className="stat-card">
101
+ <div className="stat-value" style={{ color: "var(--accent-light)" }}>
102
+ {candidateCount !== null ? candidateCount.toLocaleString() : ""}
103
+ </div>
104
+ <div className="stat-label">Candidates Indexed</div>
105
+ </div>
106
+ <div className="stat-card">
107
+ <div className="stat-value" style={{ color: "var(--green)" }}>{jds.length}</div>
108
+ <div className="stat-label">Job Descriptions</div>
109
+ </div>
110
+ <div className="stat-card">
111
+ <div className="stat-value" style={{ color: "var(--yellow)" }}>2-Stage</div>
112
+ <div className="stat-label">Ranking Pipeline</div>
113
+ </div>
114
+ </div>
115
+
116
+ <div className="grid-2" style={{ gap: 24, alignItems: "start" }}>
117
+ <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
118
+ <div className="card" style={{ padding: 24 }}>
119
+ <h2 style={{ fontSize: "1rem", fontWeight: 600, marginBottom: 16 }}>Upload Candidates</h2>
120
+ <div
121
+ id="upload-zone"
122
+ className={`upload-zone${dragOver ? " drag-over" : ""}`}
123
+ onClick={() => document.getElementById("file-input")?.click()}
124
+ onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
125
+ onDragLeave={() => setDragOver(false)}
126
+ onDrop={handleDrop}
127
+ >
128
+ <div className="upload-icon">{uploading ? "⏳" : "📂"}</div>
129
+ <div className="upload-title">
130
+ {uploading ? "Uploading…" : taskStatus && taskStatus !== "SUCCESS" ? `Processing… (${taskStatus})` : "Drop CSV or JSON file here"}
131
+ </div>
132
+ <div className="upload-sub">Supports any column schema — external_id, name, skills, work experience, etc.</div>
133
+ <input id="file-input" type="file" accept=".csv,.json,.jsonl" style={{ display: "none" }} onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0])} />
134
+ </div>
135
+ {taskStatus && taskStatus !== "SUCCESS" && taskStatus !== "FAILURE" && (
136
+ <div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
137
+ <div className="spinner" />
138
+ <span style={{ fontSize: "0.8125rem", color: "var(--text-secondary)" }}>
139
+ Ingesting candidates — embedding + indexing in background…
140
+ </span>
141
+ </div>
142
+ )}
143
+ </div>
144
+ </div>
145
+
146
+ <div className="card" style={{ padding: 24 }}>
147
+ <h2 style={{ fontSize: "1rem", fontWeight: 600, marginBottom: 16 }}>Post a Job Description</h2>
148
+ <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
149
+ <div className="form-group">
150
+ <label className="label">Job Title</label>
151
+ <input id="jd-title" className="input" placeholder="e.g. Senior Backend Engineer" value={jdTitle} onChange={(e) => setJDTitle(e.target.value)} />
152
+ </div>
153
+ <div className="form-group">
154
+ <label className="label">Job Description</label>
155
+ <textarea id="jd-text" className="textarea" style={{ minHeight: 160 }} placeholder="Paste full JD here. Include required skills, experience, and responsibilities for best match quality." value={jdText} onChange={(e) => setJDText(e.target.value)} />
156
+ </div>
157
+ <button id="create-jd-btn" className="btn btn-primary btn-lg" onClick={handleCreateJD} disabled={creatingJD}>
158
+ {creatingJD ? <><div className="spinner" style={{ width: 16, height: 16 }} /> Processing…</> : "⚡ Create & Match"}
159
+ </button>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ {jds.length > 0 && (
165
+ <div style={{ marginTop: 40 }}>
166
+ <div className="page-header">
167
+ <h2 className="page-title">Recent Job Descriptions</h2>
168
+ <p className="page-subtitle">Click a JD to view ranked candidates</p>
169
+ </div>
170
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
171
+ {jds.map((jd) => (
172
+ <a key={jd.id} href={`/jds/${jd.id}`} id={`jd-${jd.id}`}
173
+ style={{ display: "flex", alignItems: "center", gap: 16, padding: "16px 20px", borderRadius: "var(--radius-lg)", background: "var(--bg-card)", border: "1px solid var(--border)", transition: "all 0.2s", textDecoration: "none" }}
174
+ onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--accent)"; (e.currentTarget as HTMLElement).style.boxShadow = "0 0 20px var(--accent-dim)"; }}
175
+ onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.boxShadow = "none"; }}
176
+ >
177
+ <div style={{ flex: 1, minWidth: 0 }}>
178
+ <div style={{ fontWeight: 600, fontSize: "0.95rem", marginBottom: 4 }}>{jd.title}</div>
179
+ <div style={{ fontSize: "0.8rem", color: "var(--text-secondary)", display: "flex", gap: 10, flexWrap: "wrap" }}>
180
+ {jd.engineer_type && <span>🔧 {jd.engineer_type}</span>}
181
+ {jd.min_yoe && <span>📅 {jd.min_yoe}+ yrs</span>}
182
+ {jd.location && <span>📍 {jd.location}</span>}
183
+ {jd.required_skills?.length > 0 && <span>🛠 {jd.required_skills.slice(0, 4).join(", ")}</span>}
184
+ </div>
185
+ </div>
186
+ <div style={{ display: "flex", gap: 8, alignItems: "center", flexShrink: 0 }}>
187
+ {jd.jd_quality?.overall && (
188
+ <span className={`badge ${qualityColor(jd.jd_quality.overall)}`}>
189
+ {jd.jd_quality.overall === "good" ? "✓" : "⚠"} {jd.jd_quality.overall}
190
+ </span>
191
+ )}
192
+ <span className={`badge ${jd.status === "ready" ? "badge-green" : "badge-yellow"}`}>
193
+ {jd.status}
194
+ </span>
195
+ <span style={{ color: "var(--text-muted)", fontSize: "1rem" }}>→</span>
196
+ </div>
197
+ </a>
198
+ ))}
199
+ </div>
200
+ </div>
201
+ )}
202
+ </div>
203
+
204
+ {toast && (
205
+ <div className={`toast toast-${toast.type}`}>
206
+ <div style={{ fontSize: "0.875rem" }}>{toast.type === "success" ? "✅" : "❌"} {toast.msg}</div>
207
  </div>
208
+ )}
209
+ </main>
210
  );
211
  }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
2
+
3
+ async function request<T>(path: string, options?: RequestInit): Promise<T> {
4
+ const res = await fetch(`${API_BASE}${path}`, {
5
+ ...options,
6
+ headers: { "Content-Type": "application/json", ...(options?.headers ?? {}) },
7
+ });
8
+ if (!res.ok) {
9
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
10
+ throw new Error(err.detail || "Request failed");
11
+ }
12
+ return res.json();
13
+ }
14
+
15
+ export interface JD {
16
+ id: string;
17
+ title: string;
18
+ raw_text: string;
19
+ status: string;
20
+ min_yoe: number | null;
21
+ role_type: string | null;
22
+ engineer_type: string | null;
23
+ location: string | null;
24
+ required_skills: string[];
25
+ jd_quality: JDQuality;
26
+ created_at: string;
27
+ }
28
+
29
+ export interface JDQuality {
30
+ overall: "good" | "fair" | "poor";
31
+ vagueness_score: number;
32
+ breadth_score: number;
33
+ skill_count: number;
34
+ contradictions: string[];
35
+ warnings: string[];
36
+ }
37
+
38
+ export interface ComponentScores {
39
+ semantic: number;
40
+ skill: number;
41
+ yoe: number;
42
+ company: number;
43
+ growth: number;
44
+ education: number;
45
+ }
46
+
47
+ export interface GapItem {
48
+ type: string;
49
+ detail: string;
50
+ mitigated_by_remote?: boolean;
51
+ }
52
+
53
+ export interface MatchedCandidate {
54
+ candidate_id: string;
55
+ rank: number;
56
+ name: string | null;
57
+ email: string | null;
58
+ role_type: string | null;
59
+ engineer_type: string | null;
60
+ years_of_experience: number | null;
61
+ most_recent_company: string | null;
62
+ parsed_summary: string | null;
63
+ programming_languages: string[];
64
+ growth_velocity: number;
65
+ stage1_score: number;
66
+ stage2_score: number | null;
67
+ final_score: number;
68
+ component_scores: ComponentScores;
69
+ gaps: GapItem[];
70
+ }
71
+
72
+ export interface MatchResponse {
73
+ jd_id: string;
74
+ jd_title: string;
75
+ jd_quality: JDQuality;
76
+ total_matched: number;
77
+ results: MatchedCandidate[];
78
+ weights_used: Record<string, number>;
79
+ }
80
+
81
+ export interface CandidateDetail {
82
+ jd_id: string;
83
+ candidate_id: string;
84
+ rank: number | null;
85
+ final_score: number;
86
+ component_scores: ComponentScores;
87
+ gaps: GapItem[];
88
+ explanation: string | null;
89
+ candidate: Record<string, unknown>;
90
+ jd: Record<string, unknown>;
91
+ }
92
+
93
+ export const api = {
94
+ createJD: (title: string, raw_text: string) =>
95
+ request<JD>("/api/jds", { method: "POST", body: JSON.stringify({ title, raw_text }) }),
96
+
97
+ listJDs: () => request<JD[]>("/api/jds"),
98
+
99
+ getJD: (id: string) => request<JD>(`/api/jds/${id}`),
100
+
101
+ uploadCandidates: (file: File) => {
102
+ const fd = new FormData();
103
+ fd.append("file", file);
104
+ return fetch(`${API_BASE}/api/candidates/upload`, { method: "POST", body: fd })
105
+ .then((r) => {
106
+ if (!r.ok) throw new Error("Upload failed");
107
+ return r.json();
108
+ });
109
+ },
110
+
111
+ candidateCount: () => request<{ count: number }>("/api/candidates/count"),
112
+
113
+ taskStatus: (id: string) => request<{ task_id: string; status: string; result: unknown }>(`/api/candidates/status/${id}`),
114
+
115
+ triggerMatch: (jd_id: string) =>
116
+ request<MatchResponse>(`/api/match/${jd_id}`, { method: "POST" }),
117
+
118
+ getMatchResults: (jd_id: string) => request<MatchResponse>(`/api/match/${jd_id}`),
119
+
120
+ getCandidateDetail: (jd_id: string, candidate_id: string) =>
121
+ request<CandidateDetail>(`/api/match/${jd_id}/${candidate_id}`),
122
+
123
+ rerank: (jd_id: string, weights: Record<string, number>) =>
124
+ request<MatchResponse>(`/api/match/${jd_id}/rerank`, {
125
+ method: "POST",
126
+ body: JSON.stringify({ weights }),
127
+ }),
128
+ };