abdullah090809 commited on
Commit
692114d
·
1 Parent(s): cf25e9f

Added Front End

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ .env.*
6
+ .git/
7
+ .gitignore
8
+ *.md
9
+ .vscode/
10
+ .idea/
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/cores/config.py CHANGED
@@ -1,7 +1,9 @@
1
  from pydantic_settings import BaseSettings
2
  from pathlib import Path
 
3
 
4
- env_path = Path(__file__).resolve().parent.parent.parent / ".env"
 
5
 
6
  class Settings(BaseSettings):
7
  DATABASE_HOSTNAME: str
 
1
  from pydantic_settings import BaseSettings
2
  from pathlib import Path
3
+ import os
4
 
5
+ ENV_FILE = os.getenv("ENV_FILE", ".env")
6
+ env_path = Path(__file__).resolve().parent.parent.parent / ENV_FILE
7
 
8
  class Settings(BaseSettings):
9
  DATABASE_HOSTNAME: str
app/main.py CHANGED
@@ -1,12 +1,22 @@
1
  from fastapi import FastAPI
 
 
2
  from app.routers import application, auth, user
3
 
4
  app = FastAPI(title="Job Tracker API")
5
 
 
 
 
 
 
 
 
 
6
  app.include_router(auth.router)
7
  app.include_router(user.router)
8
  app.include_router(application.router)
9
 
10
- @app.get("/")
11
- def root():
12
- return {"message": "Job Tracker API is running"}
 
1
  from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.staticfiles import StaticFiles
4
  from app.routers import application, auth, user
5
 
6
  app = FastAPI(title="Job Tracker API")
7
 
8
+ app.add_middleware(
9
+ CORSMiddleware,
10
+ allow_origins=["*"], # tighten this to your actual frontend domain once deployed
11
+ allow_credentials=True,
12
+ allow_methods=["*"],
13
+ allow_headers=["*"],
14
+ )
15
+
16
  app.include_router(auth.router)
17
  app.include_router(user.router)
18
  app.include_router(application.router)
19
 
20
+ # Serve the frontend (static/index.html) at "/".
21
+ # Must be mounted AFTER the routers above so API routes take priority.
22
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
app/services/ai_service.py CHANGED
@@ -1,8 +1,9 @@
1
  import json
2
- import google.generativeai as genai
 
3
  from app.cores.config import settings
4
 
5
- genai.configure(api_key=settings.GEMINI_API_KEY)
6
 
7
  SYSTEM_PROMPT = """You are a resume analysis assistant. Compare the candidate's resume text against a job description and respond with ONLY valid JSON (no markdown, no preamble, no code fences) in exactly this shape:
8
 
@@ -19,10 +20,6 @@ SYSTEM_PROMPT = """You are a resume analysis assistant. Compare the candidate's
19
  }
20
  """
21
 
22
- model = genai.GenerativeModel(
23
- model_name="gemini-2.5-flash",
24
- system_instruction=SYSTEM_PROMPT,
25
- )
26
 
27
  def analyze_resume_against_jd(resume_text: str, jd_text: str) -> dict:
28
  user_prompt = f"""RESUME:
@@ -32,9 +29,11 @@ JOB DESCRIPTION:
32
  {jd_text}
33
  """
34
 
35
- response = model.generate_content(
36
- user_prompt,
37
- generation_config=genai.types.GenerationConfig(
 
 
38
  temperature=0.3,
39
  response_mime_type="application/json",
40
  ),
 
1
  import json
2
+ from google import genai
3
+ from google.genai import types
4
  from app.cores.config import settings
5
 
6
+ client = genai.Client(api_key=settings.GEMINI_API_KEY)
7
 
8
  SYSTEM_PROMPT = """You are a resume analysis assistant. Compare the candidate's resume text against a job description and respond with ONLY valid JSON (no markdown, no preamble, no code fences) in exactly this shape:
9
 
 
20
  }
21
  """
22
 
 
 
 
 
23
 
24
  def analyze_resume_against_jd(resume_text: str, jd_text: str) -> dict:
25
  user_prompt = f"""RESUME:
 
29
  {jd_text}
30
  """
31
 
32
+ response = client.models.generate_content(
33
+ model="gemini-2.5-flash",
34
+ contents=user_prompt,
35
+ config=types.GenerateContentConfig(
36
+ system_instruction=SYSTEM_PROMPT,
37
  temperature=0.3,
38
  response_mime_type="application/json",
39
  ),
static/index.html ADDED
@@ -0,0 +1,1107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Pathway — Job Application Tracker</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{
12
+ --bg: #15120F;
13
+ --surface: #1D1813;
14
+ --surface-raised: #251F18;
15
+ --border: #332B22;
16
+ --accent: #D98E3B;
17
+ --accent-soft: rgba(217,142,59,0.14);
18
+ --text: #F3EDE2;
19
+ --text-muted: #A89A87;
20
+ --text-faint: #6E6256;
21
+ --applied: #7A93B0;
22
+ --interview: #D9B23A;
23
+ --offer: #6FA988;
24
+ --rejected: #B5563C;
25
+ --radius: 10px;
26
+ --shadow: 0 8px 24px rgba(0,0,0,0.35);
27
+ }
28
+ *{box-sizing:border-box;}
29
+ body{margin:0;}
30
+ html,body{
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ font-family: 'Inter', sans-serif;
34
+ -webkit-font-smoothing: antialiased;
35
+ }
36
+ @media (prefers-reduced-motion: reduce){
37
+ *{ animation-duration: 0.001s !important; transition-duration: 0.001s !important; }
38
+ }
39
+ body::before{
40
+ content:"";
41
+ position:fixed; inset:0;
42
+ pointer-events:none;
43
+ z-index:0;
44
+ opacity:0.5;
45
+ background-image: radial-gradient(rgba(255,255,255,0.025) 1px, transparent 1px);
46
+ background-size: 3px 3px;
47
+ }
48
+ @keyframes fadeUp{
49
+ from{ opacity:0; transform: translateY(10px); }
50
+ to{ opacity:1; transform: translateY(0); }
51
+ }
52
+ @keyframes fadeIn{
53
+ from{ opacity:0; }
54
+ to{ opacity:1; }
55
+ }
56
+ @keyframes scaleIn{
57
+ from{ opacity:0; transform: scale(0.96) translateY(6px); }
58
+ to{ opacity:1; transform: scale(1) translateY(0); }
59
+ }
60
+ @keyframes drawLine{
61
+ from{ transform: scaleX(0); }
62
+ to{ transform: scaleX(1); }
63
+ }
64
+ @keyframes glowPulse{
65
+ 0%,100%{ box-shadow: 0 0 0 4px var(--accent-soft); }
66
+ 50%{ box-shadow: 0 0 0 7px rgba(217,142,59,0.06); }
67
+ }
68
+ @keyframes driftA{
69
+ 0%,100%{ transform: translate(0,0); }
70
+ 50%{ transform: translate(24px,-18px); }
71
+ }
72
+ @keyframes driftB{
73
+ 0%,100%{ transform: translate(0,0); }
74
+ 50%{ transform: translate(-20px,16px); }
75
+ }
76
+ h1,h2,h3,.display{
77
+ font-family: 'Fraunces', serif;
78
+ font-weight: 500;
79
+ letter-spacing: -0.01em;
80
+ }
81
+ a{color:inherit;}
82
+ button{font-family:inherit;}
83
+ ::selection{background: var(--accent-soft);}
84
+
85
+ /* ---------- Layout shells ---------- */
86
+ #app{min-height:100vh;}
87
+ .hidden{display:none !important;}
88
+
89
+ /* ---------- Auth screen ---------- */
90
+ .auth-shell{
91
+ min-height:100vh;
92
+ display:flex;
93
+ align-items:center;
94
+ justify-content:center;
95
+ padding:24px;
96
+ position:relative;
97
+ overflow:hidden;
98
+ }
99
+ .auth-shell::before{
100
+ content:"";
101
+ position:absolute;
102
+ top:-20%; right:-10%;
103
+ width:520px; height:520px;
104
+ background: radial-gradient(circle, rgba(217,142,59,0.10), transparent 70%);
105
+ pointer-events:none;
106
+ animation: driftA 14s ease-in-out infinite;
107
+ }
108
+ .auth-shell::after{
109
+ content:"";
110
+ position:absolute;
111
+ bottom:-25%; left:-12%;
112
+ width:460px; height:460px;
113
+ background: radial-gradient(circle, rgba(111,169,136,0.08), transparent 70%);
114
+ pointer-events:none;
115
+ animation: driftB 17s ease-in-out infinite;
116
+ }
117
+ .auth-card{
118
+ width:100%;
119
+ max-width:400px;
120
+ background: var(--surface);
121
+ border:1px solid var(--border);
122
+ border-radius:14px;
123
+ padding:36px 32px;
124
+ box-shadow: var(--shadow);
125
+ position:relative;
126
+ z-index:1;
127
+ animation: scaleIn .45s cubic-bezier(.16,1,.3,1);
128
+ }
129
+ .auth-mark{
130
+ display:flex; align-items:center; gap:10px;
131
+ margin-bottom: 28px;
132
+ }
133
+ .auth-mark .dot{
134
+ width:9px; height:9px; border-radius:50%;
135
+ background: var(--accent);
136
+ animation: glowPulse 2.6s ease-in-out infinite;
137
+ }
138
+ .auth-mark span{
139
+ font-size:13px; letter-spacing:0.14em; text-transform:uppercase; color: var(--text-muted);
140
+ }
141
+ .auth-card h1{
142
+ font-size: 28px;
143
+ margin: 0 0 6px 0;
144
+ }
145
+ .auth-card p.sub{
146
+ color: var(--text-muted);
147
+ font-size: 14px;
148
+ margin: 0 0 26px 0;
149
+ line-height:1.5;
150
+ }
151
+ .field{margin-bottom:16px;}
152
+ .field label{
153
+ display:block;
154
+ font-size:12px;
155
+ color: var(--text-muted);
156
+ margin-bottom:6px;
157
+ letter-spacing:0.02em;
158
+ }
159
+ .field input, .field select, .field textarea{
160
+ width:100%;
161
+ background: var(--surface-raised);
162
+ border:1px solid var(--border);
163
+ color: var(--text);
164
+ border-radius:8px;
165
+ padding:11px 13px;
166
+ font-size:14px;
167
+ font-family:inherit;
168
+ outline:none;
169
+ transition: border-color .18s ease, box-shadow .18s ease, transform .12s ease;
170
+ }
171
+ .field input:focus, .field select:focus, .field textarea:focus{
172
+ border-color: var(--accent);
173
+ box-shadow: 0 0 0 3px var(--accent-soft);
174
+ }
175
+ .field textarea{resize:vertical; min-height:90px; line-height:1.5;}
176
+ .btn{
177
+ appearance:none;
178
+ border:none;
179
+ border-radius:8px;
180
+ padding:11px 18px;
181
+ font-size:14px;
182
+ font-weight:600;
183
+ cursor:pointer;
184
+ transition: transform .12s cubic-bezier(.34,1.56,.64,1), opacity .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
185
+ position:relative;
186
+ }
187
+ .btn:hover{ transform: translateY(-1px); }
188
+ .btn:active{transform: scale(0.97) translateY(0);}
189
+ .btn-primary{
190
+ background: var(--accent);
191
+ color: #18130C;
192
+ width:100%;
193
+ box-shadow: 0 1px 0 rgba(0,0,0,0.15);
194
+ }
195
+ .btn-primary:hover{opacity:0.94; box-shadow: 0 4px 14px rgba(217,142,59,0.22);}
196
+ .btn-ghost{
197
+ background:transparent;
198
+ color: var(--text-muted);
199
+ border:1px solid var(--border);
200
+ }
201
+ .btn-ghost:hover{ color: var(--text); border-color: var(--text-faint);}
202
+ .btn-danger{
203
+ background: rgba(181,86,60,0.14);
204
+ color: var(--rejected);
205
+ border:1px solid rgba(181,86,60,0.3);
206
+ }
207
+ .btn-danger:hover{background: rgba(181,86,60,0.22);}
208
+ .btn-small{ padding:7px 12px; font-size:12.5px; }
209
+ .auth-switch{
210
+ margin-top:20px;
211
+ font-size:13px;
212
+ color: var(--text-muted);
213
+ text-align:center;
214
+ }
215
+ .auth-switch button{
216
+ background:none; border:none; color: var(--accent);
217
+ cursor:pointer; font-size:13px; font-weight:600; padding:0;
218
+ }
219
+ .form-error{
220
+ background: rgba(181,86,60,0.12);
221
+ border:1px solid rgba(181,86,60,0.3);
222
+ color:#E2A292;
223
+ font-size:13px;
224
+ padding:10px 12px;
225
+ border-radius:8px;
226
+ margin-bottom:16px;
227
+ line-height:1.4;
228
+ }
229
+ .form-ok{
230
+ background: rgba(111,169,136,0.12);
231
+ border:1px solid rgba(111,169,136,0.3);
232
+ color:#A9D2BC;
233
+ font-size:13px;
234
+ padding:10px 12px;
235
+ border-radius:8px;
236
+ margin-bottom:16px;
237
+ line-height:1.4;
238
+ }
239
+
240
+ /* ---------- Top bar ---------- */
241
+ .topbar{
242
+ display:flex; align-items:center; justify-content:space-between;
243
+ padding:18px 32px;
244
+ border-bottom:1px solid var(--border);
245
+ position:sticky; top:0;
246
+ background: rgba(21,18,15,0.88);
247
+ backdrop-filter: blur(8px);
248
+ z-index:20;
249
+ animation: fadeUp .4s ease;
250
+ }
251
+ .topbar .mark{display:flex; align-items:center; gap:10px;}
252
+ .topbar .mark .dot{
253
+ width:9px; height:9px; border-radius:50%;
254
+ background: var(--accent);
255
+ animation: glowPulse 2.6s ease-in-out infinite;
256
+ }
257
+ .topbar .mark span{font-size:13px; letter-spacing:0.14em; text-transform:uppercase; color:var(--text-muted);}
258
+ .topbar-right{display:flex; align-items:center; gap:10px;}
259
+ .user-pill{
260
+ font-size:13px; color: var(--text-muted);
261
+ padding:7px 12px; border:1px solid var(--border); border-radius:20px;
262
+ }
263
+
264
+ .container{ max-width:1180px; margin:0 auto; padding:32px; }
265
+
266
+ /* ---------- Trail / pipeline strip (signature element) ---------- */
267
+ .trail{
268
+ display:flex;
269
+ align-items:stretch;
270
+ gap:0;
271
+ margin-bottom:36px;
272
+ border:1px solid var(--border);
273
+ border-radius:14px;
274
+ overflow:hidden;
275
+ background: var(--surface);
276
+ animation: fadeUp .5s ease .05s both;
277
+ }
278
+ .trail-stop{
279
+ flex:1;
280
+ padding:20px 22px;
281
+ position:relative;
282
+ border-right:1px solid var(--border);
283
+ transition: background .2s ease;
284
+ }
285
+ .trail-stop:hover{ background: rgba(255,255,255,0.015); }
286
+ .trail-stop:last-child{border-right:none;}
287
+ .trail-stop::after{
288
+ content:"";
289
+ position:absolute;
290
+ bottom:0; left:0; right:0;
291
+ height:3px;
292
+ background: var(--stop-color, var(--accent));
293
+ opacity:0.85;
294
+ transform-origin: left;
295
+ animation: drawLine .7s cubic-bezier(.16,1,.3,1) .15s both;
296
+ }
297
+ .trail-stop .count{
298
+ font-family:'Fraunces', serif;
299
+ font-size:32px;
300
+ font-weight:500;
301
+ line-height:1;
302
+ margin-bottom:6px;
303
+ }
304
+ .trail-stop .label{
305
+ font-size:12px;
306
+ letter-spacing:0.08em;
307
+ text-transform:uppercase;
308
+ color: var(--text-muted);
309
+ }
310
+
311
+ /* ---------- Toolbar ---------- */
312
+ .toolbar{
313
+ display:flex; align-items:center; justify-content:space-between;
314
+ margin-bottom:22px;
315
+ gap:16px;
316
+ flex-wrap:wrap;
317
+ animation: fadeUp .5s ease .1s both;
318
+ }
319
+ .toolbar h2{font-size:21px; margin:0;}
320
+ .toolbar-actions{display:flex; gap:10px; align-items:center;}
321
+
322
+ /* ---------- Board ---------- */
323
+ .board{
324
+ display:grid;
325
+ grid-template-columns: repeat(4, 1fr);
326
+ gap:18px;
327
+ }
328
+ @media (max-width: 980px){ .board{grid-template-columns:repeat(2,1fr);} }
329
+ @media (max-width: 600px){ .board{grid-template-columns:1fr;} }
330
+
331
+ .col{
332
+ background: var(--surface);
333
+ border:1px solid var(--border);
334
+ border-radius:12px;
335
+ padding:14px;
336
+ min-height:140px;
337
+ animation: fadeUp .5s ease both;
338
+ }
339
+ .col:nth-child(1){ animation-delay: .08s; }
340
+ .col:nth-child(2){ animation-delay: .14s; }
341
+ .col:nth-child(3){ animation-delay: .20s; }
342
+ .col:nth-child(4){ animation-delay: .26s; }
343
+ .col-head{
344
+ display:flex; align-items:center; gap:8px;
345
+ margin-bottom:14px;
346
+ padding:0 2px;
347
+ }
348
+ .col-head .swatch{ width:8px; height:8px; border-radius:50%; }
349
+ .col-head .name{ font-size:12.5px; letter-spacing:0.06em; text-transform:uppercase; color: var(--text-muted); }
350
+ .col-head .num{ margin-left:auto; font-size:12px; color: var(--text-faint); }
351
+
352
+ .card{
353
+ background: var(--surface-raised);
354
+ border:1px solid var(--border);
355
+ border-radius:10px;
356
+ padding:14px;
357
+ margin-bottom:10px;
358
+ cursor:pointer;
359
+ transition: border-color .18s ease, transform .18s cubic-bezier(.16,1,.3,1), box-shadow .18s ease;
360
+ animation: fadeUp .35s ease both;
361
+ }
362
+ .card:hover{ border-color: var(--text-faint); transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.28); }
363
+ .card:active{ transform: translateY(0) scale(0.99); }
364
+ .card .role{ font-size:14.5px; font-weight:600; margin-bottom:3px; }
365
+ .card .company{ font-size:13px; color: var(--text-muted); margin-bottom:10px; }
366
+ .card .meta{ display:flex; align-items:center; justify-content:space-between; }
367
+ .card .date{ font-size:11.5px; color: var(--text-faint); }
368
+ .card .jd-flag{ font-size:11px; color: var(--accent); }
369
+
370
+ .empty-col{
371
+ font-size:12.5px; color: var(--text-faint);
372
+ padding:16px 4px; text-align:center; line-height:1.5;
373
+ }
374
+
375
+ /* ---------- Modal ---------- */
376
+ .overlay{
377
+ position:fixed; inset:0;
378
+ background: rgba(10,8,6,0.6);
379
+ backdrop-filter: blur(2px);
380
+ display:flex; align-items:flex-start; justify-content:center;
381
+ padding:48px 20px;
382
+ overflow-y:auto;
383
+ z-index:50;
384
+ animation: fadeIn .2s ease;
385
+ }
386
+ .modal{
387
+ width:100%; max-width:560px;
388
+ background: var(--surface);
389
+ border:1px solid var(--border);
390
+ border-radius:14px;
391
+ box-shadow: var(--shadow);
392
+ padding:28px;
393
+ animation: scaleIn .28s cubic-bezier(.16,1,.3,1);
394
+ }
395
+ .modal-head{
396
+ display:flex; align-items:center; justify-content:space-between;
397
+ margin-bottom:20px;
398
+ }
399
+ .modal-head h3{ font-size:19px; margin:0; }
400
+ .modal-close{
401
+ background:none; border:none; color: var(--text-muted);
402
+ font-size:20px; cursor:pointer; line-height:1; padding:4px;
403
+ }
404
+ .modal-close:hover{ color: var(--text); }
405
+ .modal-actions{
406
+ display:flex; gap:10px; margin-top:22px;
407
+ justify-content:flex-end;
408
+ }
409
+ .row-2{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
410
+
411
+ .badge{
412
+ display:inline-flex; align-items:center; gap:6px;
413
+ font-size:11.5px; letter-spacing:0.04em; text-transform:uppercase;
414
+ padding:4px 9px; border-radius:20px;
415
+ border:1px solid var(--border);
416
+ }
417
+ .badge .dot{ width:6px; height:6px; border-radius:50%; }
418
+
419
+ /* ---------- Analysis result ---------- */
420
+ .score-ring{
421
+ width:84px; height:84px;
422
+ border-radius:50%;
423
+ display:flex; align-items:center; justify-content:center;
424
+ font-family:'Fraunces', serif; font-size:24px;
425
+ flex-shrink:0;
426
+ border: 6px solid var(--surface-raised);
427
+ }
428
+ .analysis-head{ display:flex; align-items:center; gap:18px; margin-bottom:18px;}
429
+ .analysis-head .summary{ font-size:13.5px; color: var(--text-muted); line-height:1.55; }
430
+ .skill-group{ margin-bottom:16px; }
431
+ .skill-group h4{
432
+ font-size:11.5px; letter-spacing:0.08em; text-transform:uppercase;
433
+ color: var(--text-faint); margin:0 0 8px 0; font-weight:600;
434
+ }
435
+ .skill-chips{ display:flex; flex-wrap:wrap; gap:6px; }
436
+ .chip{
437
+ font-size:12px; padding:5px 10px; border-radius:20px;
438
+ background: var(--surface-raised); border:1px solid var(--border);
439
+ }
440
+ .chip.match{ color:#A9D2BC; border-color: rgba(111,169,136,0.3); }
441
+ .chip.gap{ color:#E2A292; border-color: rgba(181,86,60,0.3); }
442
+ .tailored-block{
443
+ background: var(--surface-raised);
444
+ border:1px solid var(--border);
445
+ border-radius:10px;
446
+ padding:16px;
447
+ margin-top:6px;
448
+ }
449
+ .tailored-block p{ font-size:13.5px; line-height:1.6; color: var(--text); margin:0 0 12px 0; }
450
+ .tailored-block ul{ margin:0; padding-left:18px; }
451
+ .tailored-block li{ font-size:13px; line-height:1.6; color: var(--text-muted); margin-bottom:4px; }
452
+
453
+ .spinner{
454
+ width:16px; height:16px;
455
+ border:2px solid rgba(255,255,255,0.25);
456
+ border-top-color: #18130C;
457
+ border-radius:50%;
458
+ animation: spin .7s linear infinite;
459
+ display:inline-block;
460
+ vertical-align:middle;
461
+ }
462
+ @keyframes spin{ to{ transform: rotate(360deg); } }
463
+
464
+ .resume-box{
465
+ border:1px dashed var(--border);
466
+ border-radius:10px;
467
+ padding:16px;
468
+ font-size:13px;
469
+ color: var(--text-muted);
470
+ display:flex; align-items:center; justify-content:space-between;
471
+ gap:12px;
472
+ flex-wrap:wrap;
473
+ }
474
+
475
+ .toast-stack{
476
+ position:fixed; bottom:24px; right:24px;
477
+ display:flex; flex-direction:column; gap:10px;
478
+ z-index:100;
479
+ }
480
+ .toast{
481
+ background: var(--surface-raised);
482
+ border:1px solid var(--border);
483
+ border-radius:10px;
484
+ padding:12px 16px;
485
+ font-size:13px;
486
+ box-shadow: var(--shadow);
487
+ max-width:320px;
488
+ animation: toastIn .2s ease;
489
+ }
490
+ .toast.err{ border-color: rgba(181,86,60,0.4); color:#E2A292; }
491
+ .toast.ok{ border-color: rgba(111,169,136,0.4); color:#A9D2BC; }
492
+ @keyframes toastIn{ from{opacity:0; transform:translateY(6px);} to{opacity:1; transform:translateY(0);} }
493
+ @keyframes toastOut{ from{opacity:1; transform:translateY(0);} to{opacity:0; transform:translateY(6px);} }
494
+ .toast.leaving{ animation: toastOut .18s ease forwards; }
495
+ </style>
496
+ </head>
497
+ <body>
498
+
499
+ <div id="app"></div>
500
+ <div class="toast-stack" id="toastStack"></div>
501
+
502
+ <script>
503
+ /* ============================================================
504
+ Pathway — Job Application Tracker frontend
505
+ Vanilla JS, talks to the FastAPI backend via fetch.
506
+ ============================================================ */
507
+
508
+ const STATUS_META = {
509
+ applied: { label: "Applied", color: "var(--applied)" },
510
+ interview: { label: "Interview", color: "var(--interview)" },
511
+ offer: { label: "Offer", color: "var(--offer)" },
512
+ rejected: { label: "Rejected", color: "var(--rejected)" },
513
+ };
514
+ const STATUS_ORDER = ["applied", "interview", "offer", "rejected"];
515
+
516
+ // Set this to your deployed API URL when you ship to HF Spaces / Supabase.
517
+ const API_BASE = "http://127.0.0.1:8000";
518
+
519
+ const state = {
520
+ apiBase: API_BASE,
521
+ token: localStorage.getItem("pathway_token") || null,
522
+ applications: [],
523
+ view: "login", // login | register | dashboard
524
+ };
525
+
526
+ const root = document.getElementById("app");
527
+
528
+ /* ---------------- API helper ---------------- */
529
+ async function api(path, { method = "GET", body, auth = true, isForm = false } = {}) {
530
+ const headers = {};
531
+ if (!isForm) headers["Content-Type"] = "application/json";
532
+ if (auth && state.token) headers["Authorization"] = "Bearer " + state.token;
533
+
534
+ const res = await fetch(state.apiBase + path, {
535
+ method,
536
+ headers,
537
+ body: isForm ? body : body ? JSON.stringify(body) : undefined,
538
+ });
539
+
540
+ let data = null;
541
+ try { data = await res.json(); } catch (e) { /* no body */ }
542
+
543
+ if (!res.ok) {
544
+ const detail = data && data.detail ? data.detail : `Request failed (${res.status})`;
545
+ throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
546
+ }
547
+ return data;
548
+ }
549
+
550
+ async function apiLogin(email, password) {
551
+ const form = new URLSearchParams();
552
+ form.append("username", email);
553
+ form.append("password", password);
554
+ const res = await fetch(state.apiBase + "/login", {
555
+ method: "POST",
556
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
557
+ body: form.toString(),
558
+ });
559
+ let data = null;
560
+ try { data = await res.json(); } catch (e) {}
561
+ if (!res.ok) throw new Error((data && data.detail) || "Login failed");
562
+ return data;
563
+ }
564
+
565
+ /* ---------------- Toasts ---------------- */
566
+ function toast(msg, kind = "ok") {
567
+ const stack = document.getElementById("toastStack");
568
+ const el = document.createElement("div");
569
+ el.className = "toast " + (kind === "err" ? "err" : "ok");
570
+ el.textContent = msg;
571
+ stack.appendChild(el);
572
+ setTimeout(() => {
573
+ el.classList.add("leaving");
574
+ setTimeout(() => el.remove(), 200);
575
+ }, 4000);
576
+ }
577
+
578
+ /* ---------------- Render router ---------------- */
579
+ function render() {
580
+ if (state.view === "dashboard" && state.token) {
581
+ renderDashboard();
582
+ } else if (state.view === "register") {
583
+ renderAuth("register");
584
+ } else {
585
+ renderAuth("login");
586
+ }
587
+ }
588
+
589
+ /* ---------------- Auth screens ---------------- */
590
+ function renderAuth(mode) {
591
+ root.innerHTML = `
592
+ <div class="auth-shell">
593
+ <div class="auth-card">
594
+ <div class="auth-mark"><div class="dot"></div><span>Pathway</span></div>
595
+ <h1>${mode === "login" ? "Welcome back" : "Start your trail"}</h1>
596
+ <p class="sub">${mode === "login"
597
+ ? "Sign in to keep tracking where every application stands."
598
+ : "Create an account to start tracking applications and matching your resume to roles."}</p>
599
+ <div id="authMsg"></div>
600
+ <form id="authForm">
601
+ <div class="field">
602
+ <label>Email</label>
603
+ <input type="email" id="authEmail" required placeholder="you@example.com" autocomplete="email">
604
+ </div>
605
+ <div class="field">
606
+ <label>Password</label>
607
+ <input type="password" id="authPassword" required placeholder="••••••••" autocomplete="${mode === "login" ? "current-password" : "new-password"}">
608
+ </div>
609
+ <button class="btn btn-primary" type="submit" id="authSubmit">${mode === "login" ? "Sign in" : "Create account"}</button>
610
+ </form>
611
+ <div class="auth-switch">
612
+ ${mode === "login"
613
+ ? `New here? <button id="switchToRegister">Create an account</button>`
614
+ : `Already have an account? <button id="switchToLogin">Sign in</button>`}
615
+ </div>
616
+ </div>
617
+ </div>
618
+ `;
619
+
620
+ const switchBtn = document.getElementById(mode === "login" ? "switchToRegister" : "switchToLogin");
621
+ switchBtn.addEventListener("click", () => {
622
+ state.view = mode === "login" ? "register" : "login";
623
+ render();
624
+ });
625
+
626
+ document.getElementById("authForm").addEventListener("submit", async (e) => {
627
+ e.preventDefault();
628
+ const email = document.getElementById("authEmail").value.trim();
629
+ const password = document.getElementById("authPassword").value;
630
+ const msgEl = document.getElementById("authMsg");
631
+ const submitBtn = document.getElementById("authSubmit");
632
+ msgEl.innerHTML = "";
633
+ submitBtn.disabled = true;
634
+ submitBtn.innerHTML = `<span class="spinner"></span>`;
635
+
636
+ try {
637
+ if (mode === "register") {
638
+ await api("/users", { method: "POST", body: { email, password }, auth: false });
639
+ const tokenData = await apiLogin(email, password);
640
+ state.token = tokenData.access_token;
641
+ localStorage.setItem("pathway_token", state.token);
642
+ toast("Account created. Welcome to Pathway.");
643
+ await loadApplications();
644
+ state.view = "dashboard";
645
+ render();
646
+ } else {
647
+ const tokenData = await apiLogin(email, password);
648
+ state.token = tokenData.access_token;
649
+ localStorage.setItem("pathway_token", state.token);
650
+ await loadApplications();
651
+ state.view = "dashboard";
652
+ render();
653
+ }
654
+ } catch (err) {
655
+ msgEl.innerHTML = `<div class="form-error">${escapeHtml(err.message)}</div>`;
656
+ submitBtn.disabled = false;
657
+ submitBtn.textContent = mode === "login" ? "Sign in" : "Create account";
658
+ }
659
+ });
660
+ }
661
+
662
+ /* ---------------- Dashboard ---------------- */
663
+ async function loadApplications() {
664
+ state.applications = await api("/applications");
665
+ }
666
+
667
+ function renderDashboard() {
668
+ const counts = { applied: 0, interview: 0, offer: 0, rejected: 0 };
669
+ state.applications.forEach(a => { counts[a.status] = (counts[a.status] || 0) + 1; });
670
+
671
+ root.innerHTML = `
672
+ <div class="topbar">
673
+ <div class="mark"><div class="dot"></div><span>Pathway</span></div>
674
+ <div class="topbar-right">
675
+ <button class="btn btn-ghost btn-small" id="resumeBtn">Resume</button>
676
+ <button class="btn btn-ghost btn-small" id="passwordBtn">Password</button>
677
+ <button class="btn btn-ghost btn-small" id="logoutBtn">Sign out</button>
678
+ </div>
679
+ </div>
680
+ <div class="container">
681
+ <div class="trail">
682
+ ${STATUS_ORDER.map(s => `
683
+ <div class="trail-stop" style="--stop-color:${STATUS_META[s].color}">
684
+ <div class="count" data-target="${counts[s] || 0}">0</div>
685
+ <div class="label">${STATUS_META[s].label}</div>
686
+ </div>
687
+ `).join("")}
688
+ </div>
689
+
690
+ <div class="toolbar">
691
+ <h2>Your applications</h2>
692
+ <div class="toolbar-actions">
693
+ <button class="btn btn-primary btn-small" id="newAppBtn">+ New application</button>
694
+ </div>
695
+ </div>
696
+
697
+ <div class="board">
698
+ ${STATUS_ORDER.map(s => renderColumn(s)).join("")}
699
+ </div>
700
+ </div>
701
+ `;
702
+
703
+ document.getElementById("logoutBtn").addEventListener("click", () => {
704
+ state.token = null;
705
+ localStorage.removeItem("pathway_token");
706
+ state.view = "login";
707
+ render();
708
+ });
709
+ document.getElementById("newAppBtn").addEventListener("click", () => openApplicationModal());
710
+ document.getElementById("resumeBtn").addEventListener("click", () => openResumeModal());
711
+ document.getElementById("passwordBtn").addEventListener("click", () => openPasswordModal());
712
+
713
+ document.querySelectorAll(".trail-stop .count").forEach(el => animateCount(el));
714
+
715
+ state.applications.forEach(a => {
716
+ const el = document.getElementById("card-" + a.id);
717
+ if (el) el.addEventListener("click", () => openApplicationModal(a));
718
+ });
719
+ }
720
+
721
+ function renderColumn(statusKey) {
722
+ const meta = STATUS_META[statusKey];
723
+ const items = state.applications.filter(a => a.status === statusKey);
724
+ return `
725
+ <div class="col">
726
+ <div class="col-head">
727
+ <div class="swatch" style="background:${meta.color}"></div>
728
+ <div class="name">${meta.label}</div>
729
+ <div class="num">${items.length}</div>
730
+ </div>
731
+ ${items.length === 0
732
+ ? `<div class="empty-col">No applications here yet.</div>`
733
+ : items.map((a, i) => `
734
+ <div class="card" id="card-${a.id}" style="animation-delay:${0.32 + i * 0.05}s">
735
+ <div class="role">${escapeHtml(a.role)}</div>
736
+ <div class="company">${escapeHtml(a.company)}</div>
737
+ <div class="meta">
738
+ <span class="date">${formatDate(a.applied_date)}</span>
739
+ ${a.jd_text ? `<span class="jd-flag">JD attached</span>` : ""}
740
+ </div>
741
+ </div>
742
+ `).join("")}
743
+ </div>
744
+ `;
745
+ }
746
+
747
+ /* ---------------- Application modal (create / edit / analyze) ---------------- */
748
+ function openApplicationModal(app = null) {
749
+ const isEdit = !!app;
750
+ const overlay = document.createElement("div");
751
+ overlay.className = "overlay";
752
+ overlay.innerHTML = `
753
+ <div class="modal">
754
+ <div class="modal-head">
755
+ <h3>${isEdit ? "Edit application" : "New application"}</h3>
756
+ <button class="modal-close" id="closeModal">&times;</button>
757
+ </div>
758
+ <div id="modalMsg"></div>
759
+ <form id="appForm">
760
+ <div class="row-2">
761
+ <div class="field">
762
+ <label>Company</label>
763
+ <input id="f_company" required value="${isEdit ? escapeAttr(app.company) : ""}">
764
+ </div>
765
+ <div class="field">
766
+ <label>Role</label>
767
+ <input id="f_role" required value="${isEdit ? escapeAttr(app.role) : ""}">
768
+ </div>
769
+ </div>
770
+ <div class="row-2">
771
+ <div class="field">
772
+ <label>Status</label>
773
+ <select id="f_status">
774
+ ${STATUS_ORDER.map(s => `<option value="${s}" ${isEdit && app.status === s ? "selected" : ""}>${STATUS_META[s].label}</option>`).join("")}
775
+ </select>
776
+ </div>
777
+ <div class="field">
778
+ <label>Applied date</label>
779
+ <input type="date" id="f_date" required value="${isEdit ? app.applied_date : new Date().toISOString().slice(0,10)}">
780
+ </div>
781
+ </div>
782
+ <div class="field">
783
+ <label>Job description</label>
784
+ <textarea id="f_jd" placeholder="Paste the job description here to enable AI matching...">${isEdit && app.jd_text ? escapeHtml(app.jd_text) : ""}</textarea>
785
+ </div>
786
+ <div class="field">
787
+ <label>Notes</label>
788
+ <textarea id="f_notes" placeholder="Referral, interview prep, contacts...">${isEdit && app.notes ? escapeHtml(app.notes) : ""}</textarea>
789
+ </div>
790
+ <div class="modal-actions">
791
+ ${isEdit ? `<button type="button" class="btn btn-danger btn-small" id="deleteBtn">Delete</button>` : ""}
792
+ ${isEdit ? `<button type="button" class="btn btn-ghost btn-small" id="analyzeBtn">Analyze match</button>` : ""}
793
+ <button type="button" class="btn btn-ghost btn-small" id="cancelBtn">Cancel</button>
794
+ <button type="submit" class="btn btn-primary btn-small" id="saveBtn">${isEdit ? "Save changes" : "Add application"}</button>
795
+ </div>
796
+ </form>
797
+ </div>
798
+ `;
799
+ document.body.appendChild(overlay);
800
+
801
+ const close = () => overlay.remove();
802
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
803
+ document.getElementById("closeModal").addEventListener("click", close);
804
+ document.getElementById("cancelBtn").addEventListener("click", close);
805
+
806
+ if (isEdit) {
807
+ document.getElementById("deleteBtn").addEventListener("click", async () => {
808
+ if (!confirm(`Delete the application for ${app.role} at ${app.company}?`)) return;
809
+ try {
810
+ await api(`/applications/${app.id}`, { method: "DELETE" });
811
+ toast("Application deleted.");
812
+ close();
813
+ await loadApplications();
814
+ render();
815
+ } catch (err) {
816
+ toast(err.message, "err");
817
+ }
818
+ });
819
+ document.getElementById("analyzeBtn").addEventListener("click", () => {
820
+ close();
821
+ openAnalysisModal(app);
822
+ });
823
+ }
824
+
825
+ document.getElementById("appForm").addEventListener("submit", async (e) => {
826
+ e.preventDefault();
827
+ const msgEl = document.getElementById("modalMsg");
828
+ const saveBtn = document.getElementById("saveBtn");
829
+ msgEl.innerHTML = "";
830
+ const payload = {
831
+ company: document.getElementById("f_company").value.trim(),
832
+ role: document.getElementById("f_role").value.trim(),
833
+ status: document.getElementById("f_status").value,
834
+ applied_date: document.getElementById("f_date").value,
835
+ jd_text: document.getElementById("f_jd").value.trim() || null,
836
+ notes: document.getElementById("f_notes").value.trim() || null,
837
+ };
838
+ saveBtn.disabled = true;
839
+ saveBtn.innerHTML = `<span class="spinner"></span>`;
840
+ try {
841
+ if (isEdit) {
842
+ await api(`/applications/${app.id}`, { method: "PUT", body: payload });
843
+ toast("Application updated.");
844
+ } else {
845
+ await api("/applications", { method: "POST", body: payload });
846
+ toast("Application added.");
847
+ }
848
+ close();
849
+ await loadApplications();
850
+ render();
851
+ } catch (err) {
852
+ msgEl.innerHTML = `<div class="form-error">${escapeHtml(err.message)}</div>`;
853
+ saveBtn.disabled = false;
854
+ saveBtn.textContent = isEdit ? "Save changes" : "Add application";
855
+ }
856
+ });
857
+ }
858
+
859
+ /* ---------------- Analysis modal ---------------- */
860
+ function openAnalysisModal(app) {
861
+ const overlay = document.createElement("div");
862
+ overlay.className = "overlay";
863
+ overlay.innerHTML = `
864
+ <div class="modal">
865
+ <div class="modal-head">
866
+ <h3>Match analysis</h3>
867
+ <button class="modal-close" id="closeModal">&times;</button>
868
+ </div>
869
+ <div id="analysisBody">
870
+ <div style="display:flex; align-items:center; gap:10px; color:var(--text-muted); font-size:13.5px; padding: 20px 0;">
871
+ <span class="spinner" style="border-top-color: var(--accent);"></span>
872
+ Comparing your resume against this job description...
873
+ </div>
874
+ </div>
875
+ </div>
876
+ `;
877
+ document.body.appendChild(overlay);
878
+ const close = () => overlay.remove();
879
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
880
+ document.getElementById("closeModal").addEventListener("click", close);
881
+
882
+ runAnalysis(app, overlay);
883
+ }
884
+
885
+ async function runAnalysis(app, overlay) {
886
+ const body = overlay.querySelector("#analysisBody");
887
+ try {
888
+ const result = await api(`/applications/${app.id}/analyze`, { method: "POST" });
889
+ const score = result.match_score ?? 0;
890
+ const ringColor = score >= 70 ? "var(--offer)" : score >= 40 ? "var(--interview)" : "var(--rejected)";
891
+ const tailored = result.tailored_resume || {};
892
+
893
+ body.innerHTML = `
894
+ <div class="analysis-head">
895
+ <div class="score-ring" style="border-color:${ringColor}; color:${ringColor}">${score}%</div>
896
+ <div class="summary">${escapeHtml(result.summary || "")}</div>
897
+ </div>
898
+
899
+ <div class="skill-group">
900
+ <h4>Matching skills</h4>
901
+ <div class="skill-chips">
902
+ ${(result.matching_skills || []).map(s => `<span class="chip match">${escapeHtml(s)}</span>`).join("") || `<span style="color:var(--text-faint); font-size:12.5px;">None detected</span>`}
903
+ </div>
904
+ </div>
905
+
906
+ <div class="skill-group">
907
+ <h4>Missing skills</h4>
908
+ <div class="skill-chips">
909
+ ${(result.missing_skills || []).map(s => `<span class="chip gap">${escapeHtml(s)}</span>`).join("") || `<span style="color:var(--text-faint); font-size:12.5px;">None detected</span>`}
910
+ </div>
911
+ </div>
912
+
913
+ ${tailored.professional_summary || (tailored.skills && tailored.skills.length) || (tailored.experience_bullets && tailored.experience_bullets.length) ? `
914
+ <div class="skill-group">
915
+ <h4>Tailored resume suggestions</h4>
916
+ <div class="tailored-block">
917
+ ${tailored.professional_summary ? `<p><strong>Summary:</strong> ${escapeHtml(tailored.professional_summary)}</p>` : ""}
918
+ ${tailored.skills && tailored.skills.length ? `<p style="margin-bottom:6px;"><strong>Skills to lead with:</strong></p>
919
+ <div class="skill-chips" style="margin-bottom:12px;">${tailored.skills.map(s => `<span class="chip">${escapeHtml(s)}</span>`).join("")}</div>` : ""}
920
+ ${tailored.experience_bullets && tailored.experience_bullets.length ? `
921
+ <p style="margin-bottom:6px;"><strong>Suggested bullets:</strong></p>
922
+ <ul>${tailored.experience_bullets.map(b => `<li>${escapeHtml(b)}</li>`).join("")}</ul>` : ""}
923
+ </div>
924
+ </div>` : ""}
925
+
926
+ <div class="modal-actions">
927
+ <button class="btn btn-ghost btn-small" id="closeAnalysis">Close</button>
928
+ </div>
929
+ `;
930
+ overlay.querySelector("#closeAnalysis").addEventListener("click", () => overlay.remove());
931
+ } catch (err) {
932
+ body.innerHTML = `
933
+ <div class="form-error">${escapeHtml(err.message)}</div>
934
+ <div class="modal-actions">
935
+ <button class="btn btn-ghost btn-small" id="retryAnalysis">Retry</button>
936
+ </div>
937
+ `;
938
+ overlay.querySelector("#retryAnalysis").addEventListener("click", () => runAnalysis(app, overlay));
939
+ }
940
+ }
941
+
942
+ /* ---------------- Resume modal ---------------- */
943
+ function openResumeModal() {
944
+ const overlay = document.createElement("div");
945
+ overlay.className = "overlay";
946
+ overlay.innerHTML = `
947
+ <div class="modal" style="max-width:460px;">
948
+ <div class="modal-head">
949
+ <h3>Resume</h3>
950
+ <button class="modal-close" id="closeModal">&times;</button>
951
+ </div>
952
+ <p style="font-size:13.5px; color:var(--text-muted); line-height:1.6; margin-top:0;">
953
+ Upload a PDF resume. This is the document used for AI matching against job descriptions.
954
+ </p>
955
+ <div class="resume-box">
956
+ <span id="resumeStatus">No file selected.</span>
957
+ <label class="btn btn-ghost btn-small" style="cursor:pointer;">
958
+ Choose PDF
959
+ <input type="file" id="resumeFile" accept="application/pdf" style="display:none;">
960
+ </label>
961
+ </div>
962
+ <div id="resumeMsg" style="margin-top:14px;"></div>
963
+ <div class="modal-actions">
964
+ <button class="btn btn-ghost btn-small" id="cancelResume">Close</button>
965
+ <button class="btn btn-primary btn-small" id="uploadResumeBtn" disabled>Upload</button>
966
+ </div>
967
+ </div>
968
+ `;
969
+ document.body.appendChild(overlay);
970
+ const close = () => overlay.remove();
971
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
972
+ document.getElementById("closeModal").addEventListener("click", close);
973
+ document.getElementById("cancelResume").addEventListener("click", close);
974
+
975
+ let selectedFile = null;
976
+ document.getElementById("resumeFile").addEventListener("change", (e) => {
977
+ selectedFile = e.target.files[0] || null;
978
+ document.getElementById("resumeStatus").textContent = selectedFile ? selectedFile.name : "No file selected.";
979
+ document.getElementById("uploadResumeBtn").disabled = !selectedFile;
980
+ });
981
+
982
+ document.getElementById("uploadResumeBtn").addEventListener("click", async () => {
983
+ if (!selectedFile) return;
984
+ const msgEl = document.getElementById("resumeMsg");
985
+ const btn = document.getElementById("uploadResumeBtn");
986
+ btn.disabled = true;
987
+ btn.innerHTML = `<span class="spinner"></span>`;
988
+ const formData = new FormData();
989
+ formData.append("file", selectedFile);
990
+ try {
991
+ const result = await api("/users/resume", { method: "POST", body: formData, isForm: true });
992
+ msgEl.innerHTML = `<div class="form-ok">Resume uploaded — ${result.characters_extracted || 0} characters extracted.</div>`;
993
+ toast("Resume uploaded.");
994
+ btn.textContent = "Upload";
995
+ btn.disabled = false;
996
+ } catch (err) {
997
+ msgEl.innerHTML = `<div class="form-error">${escapeHtml(err.message)}</div>`;
998
+ btn.textContent = "Upload";
999
+ btn.disabled = false;
1000
+ }
1001
+ });
1002
+ }
1003
+
1004
+ /* ---------------- Password modal ---------------- */
1005
+ function openPasswordModal() {
1006
+ const overlay = document.createElement("div");
1007
+ overlay.className = "overlay";
1008
+ overlay.innerHTML = `
1009
+ <div class="modal" style="max-width:420px;">
1010
+ <div class="modal-head">
1011
+ <h3>Change password</h3>
1012
+ <button class="modal-close" id="closeModal">&times;</button>
1013
+ </div>
1014
+ <div id="pwMsg"></div>
1015
+ <form id="pwForm">
1016
+ <div class="field">
1017
+ <label>Current password</label>
1018
+ <input type="password" id="oldPw" required>
1019
+ </div>
1020
+ <div class="field">
1021
+ <label>New password</label>
1022
+ <input type="password" id="newPw" required>
1023
+ </div>
1024
+ <div class="modal-actions">
1025
+ <button type="button" class="btn btn-ghost btn-small" id="cancelPw">Cancel</button>
1026
+ <button type="submit" class="btn btn-primary btn-small" id="savePw">Update password</button>
1027
+ </div>
1028
+ </form>
1029
+ </div>
1030
+ `;
1031
+ document.body.appendChild(overlay);
1032
+ const close = () => overlay.remove();
1033
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
1034
+ document.getElementById("closeModal").addEventListener("click", close);
1035
+ document.getElementById("cancelPw").addEventListener("click", close);
1036
+
1037
+ document.getElementById("pwForm").addEventListener("submit", async (e) => {
1038
+ e.preventDefault();
1039
+ const msgEl = document.getElementById("pwMsg");
1040
+ const btn = document.getElementById("savePw");
1041
+ btn.disabled = true;
1042
+ btn.innerHTML = `<span class="spinner"></span>`;
1043
+ try {
1044
+ await api("/users", {
1045
+ method: "PUT",
1046
+ body: {
1047
+ old_password: document.getElementById("oldPw").value,
1048
+ new_password: document.getElementById("newPw").value,
1049
+ },
1050
+ });
1051
+ msgEl.innerHTML = `<div class="form-ok">Password updated.</div>`;
1052
+ toast("Password updated.");
1053
+ setTimeout(close, 900);
1054
+ } catch (err) {
1055
+ msgEl.innerHTML = `<div class="form-error">${escapeHtml(err.message)}</div>`;
1056
+ btn.disabled = false;
1057
+ btn.textContent = "Update password";
1058
+ }
1059
+ });
1060
+ }
1061
+
1062
+ /* ---------------- Utilities ---------------- */
1063
+ function escapeHtml(str) {
1064
+ if (str === null || str === undefined) return "";
1065
+ return String(str)
1066
+ .replace(/&/g, "&amp;")
1067
+ .replace(/</g, "&lt;")
1068
+ .replace(/>/g, "&gt;")
1069
+ .replace(/"/g, "&quot;");
1070
+ }
1071
+ function escapeAttr(str) { return escapeHtml(str); }
1072
+ function formatDate(d) {
1073
+ if (!d) return "";
1074
+ const date = new Date(d + "T00:00:00");
1075
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
1076
+ }
1077
+ function animateCount(el) {
1078
+ const target = parseInt(el.getAttribute("data-target"), 10) || 0;
1079
+ if (target === 0) { el.textContent = "0"; return; }
1080
+ const duration = 600;
1081
+ const start = performance.now();
1082
+ function step(now) {
1083
+ const progress = Math.min((now - start) / duration, 1);
1084
+ const eased = 1 - Math.pow(1 - progress, 3);
1085
+ el.textContent = Math.round(eased * target);
1086
+ if (progress < 1) requestAnimationFrame(step);
1087
+ }
1088
+ requestAnimationFrame(step);
1089
+ }
1090
+
1091
+ /* ---------------- Boot ---------------- */
1092
+ (async function boot() {
1093
+ if (state.token) {
1094
+ try {
1095
+ await loadApplications();
1096
+ state.view = "dashboard";
1097
+ } catch (err) {
1098
+ state.token = null;
1099
+ localStorage.removeItem("pathway_token");
1100
+ state.view = "login";
1101
+ }
1102
+ }
1103
+ render();
1104
+ })();
1105
+ </script>
1106
+ </body>
1107
+ </html>