sk31415 commited on
Commit
abc215d
·
0 Parent(s):

initial commit

Browse files
Files changed (5) hide show
  1. Dockerfile +15 -0
  2. README.md +32 -0
  3. app/main.py +462 -0
  4. requirements.txt +6 -0
  5. static/index.html +697 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+ ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH
5
+ WORKDIR $HOME/app
6
+ USER user
7
+
8
+ COPY --chown=user requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY --chown=user app/ ./app/
12
+ COPY --chown=user static/ ./static/
13
+
14
+ EXPOSE 7860
15
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ResumeForge
3
+ emoji: 📄
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # ResumeForge - AI Resume Optimizer
12
+
13
+ Upload your resume and a job description to get a tailored PDF resume optimized for the position.
14
+
15
+ ## Features
16
+
17
+ - **Resume Parsing**: Upload PDF, DOCX, or TXT resumes
18
+ - **AI Optimization**: Uses MiMo V2 Flash via OpenRouter to tailor your resume
19
+ - **Professional Output**: Generates high-quality PDF using LaTeX
20
+
21
+ ## Usage
22
+
23
+ 1. Upload your current resume
24
+ 2. Paste the job description you're applying for
25
+ 3. Click "Optimize Resume"
26
+ 4. Download your tailored PDF
27
+
28
+ ## Configuration
29
+
30
+ This Space requires the `OPENROUTER_API_KEY` secret to be set in the Space Settings.
31
+
32
+ Get your free API key at [OpenRouter](https://openrouter.ai).
app/main.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Resume Optimizer API
3
+ - Parses uploaded resumes (PDF/DOCX/TXT)
4
+ - Uses MiMo V2 Flash via OpenRouter to optimize for job descriptions
5
+ - Compiles to PDF via latex.ytotech.com
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import json
11
+ import base64
12
+ import tempfile
13
+ from typing import Optional
14
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import Response
17
+ from fastapi.staticfiles import StaticFiles
18
+ import httpx
19
+
20
+ # PDF parsing
21
+ import fitz # PyMuPDF
22
+
23
+ app = FastAPI(title="Resume Optimizer API")
24
+
25
+ # CORS for frontend
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"], # In production, restrict this
29
+ allow_credentials=True,
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ # Configuration
35
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
36
+ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
37
+ LATEX_API_URL = "https://latex.ytotech.com/builds/sync"
38
+
39
+ # LaTeX Template
40
+ LATEX_TEMPLATE = r"""
41
+ \documentclass[a4paper,10pt]{extarticle}
42
+
43
+ \usepackage[utf8]{inputenc}
44
+ \usepackage{geometry}
45
+ \geometry{a4paper, margin=0.5in}
46
+ \usepackage{titlesec}
47
+ \usepackage{enumitem}
48
+ \usepackage{hyperref}
49
+ \setlist{noitemsep,leftmargin=*}
50
+ \titleformat{\section}{\Large\bfseries}{\thesection}{1em}{}[\titlerule]
51
+ \titlespacing*{\section}{0pt}{0.5em}{0.5em}
52
+ \pagestyle{empty}
53
+
54
+ \begin{document}
55
+
56
+ \begin{center}
57
+ \textbf{\Large <<NAME>>}\\[2pt]
58
+ \href{mailto:<<EMAIL>>}{<<EMAIL>>} $|$ \href{<<LINKEDIN_URL>>}{<<LINKEDIN_DISPLAY>>} $|$
59
+ \href{<<GITHUB_URL>>}{<<GITHUB_DISPLAY>>}
60
+ \end{center}
61
+
62
+ \section*{EDUCATION}
63
+ <<EDUCATION_CONTENT>>
64
+
65
+ \section*{EXPERIENCE}
66
+ <<EXPERIENCE_CONTENT>>
67
+
68
+ \section*{PROJECTS}
69
+ <<PROJECTS_CONTENT>>
70
+
71
+ \section*{SKILLS}
72
+ <<SKILLS_CONTENT>>
73
+
74
+ \end{document}
75
+ """
76
+
77
+
78
+ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
79
+ """Extract text from PDF using PyMuPDF"""
80
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
81
+ text = ""
82
+ for page in doc:
83
+ text += page.get_text()
84
+ doc.close()
85
+ return text
86
+
87
+
88
+ def extract_text_from_file(content: bytes, filename: str) -> str:
89
+ """Extract text based on file type"""
90
+ ext = filename.lower().split('.')[-1]
91
+
92
+ if ext == 'pdf':
93
+ return extract_text_from_pdf(content)
94
+ elif ext == 'txt':
95
+ return content.decode('utf-8', errors='ignore')
96
+ elif ext in ['doc', 'docx']:
97
+ # For simplicity, try to extract as text
98
+ # In production, use python-docx
99
+ return content.decode('utf-8', errors='ignore')
100
+ else:
101
+ return content.decode('utf-8', errors='ignore')
102
+
103
+
104
+ def escape_latex(text: str) -> str:
105
+ """Escape special LaTeX characters"""
106
+ replacements = [
107
+ ('\\', r'\textbackslash{}'),
108
+ ('&', r'\&'),
109
+ ('%', r'\%'),
110
+ ('$', r'\$'),
111
+ ('#', r'\#'),
112
+ ('_', r'\_'),
113
+ ('{', r'\{'),
114
+ ('}', r'\}'),
115
+ ('~', r'\textasciitilde{}'),
116
+ ('^', r'\textasciicircum{}'),
117
+ ]
118
+ for old, new in replacements:
119
+ if old != '\\': # Handle backslash separately
120
+ text = text.replace(old, new)
121
+ return text
122
+
123
+
124
+ async def call_openrouter(prompt: str, system_prompt: str) -> str:
125
+ """Call OpenRouter API with MiMo V2 Flash"""
126
+
127
+ if not OPENROUTER_API_KEY:
128
+ raise HTTPException(status_code=500, detail="OPENROUTER_API_KEY not configured")
129
+
130
+ async with httpx.AsyncClient(timeout=120.0) as client:
131
+ response = await client.post(
132
+ OPENROUTER_URL,
133
+ headers={
134
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
135
+ "Content-Type": "application/json",
136
+ "HTTP-Referer": "https://resume-optimizer.app",
137
+ },
138
+ json={
139
+ "model": "moonshotai/mimo-7b-rl:free",
140
+ "messages": [
141
+ {"role": "system", "content": system_prompt},
142
+ {"role": "user", "content": prompt}
143
+ ],
144
+ "max_tokens": 4000,
145
+ "temperature": 0.3,
146
+ }
147
+ )
148
+
149
+ if response.status_code != 200:
150
+ raise HTTPException(
151
+ status_code=response.status_code,
152
+ detail=f"OpenRouter API error: {response.text}"
153
+ )
154
+
155
+ data = response.json()
156
+ return data["choices"][0]["message"]["content"]
157
+
158
+
159
+ async def compile_latex(latex_content: str) -> bytes:
160
+ """Compile LaTeX to PDF using latex.ytotech.com"""
161
+
162
+ async with httpx.AsyncClient(timeout=60.0) as client:
163
+ response = await client.post(
164
+ LATEX_API_URL,
165
+ json={
166
+ "compiler": "pdflatex",
167
+ "resources": [
168
+ {"main": True, "content": latex_content}
169
+ ]
170
+ }
171
+ )
172
+
173
+ if response.status_code != 200:
174
+ raise HTTPException(
175
+ status_code=500,
176
+ detail=f"LaTeX compilation failed: {response.text}"
177
+ )
178
+
179
+ return response.content
180
+
181
+
182
+ SYSTEM_PROMPT = """You are an expert resume optimizer. Your task is to optimize a resume for a specific job description.
183
+
184
+ IMPORTANT RULES:
185
+ 1. Keep all information TRUTHFUL - do not fabricate experiences, skills, or achievements
186
+ 2. Reorder and emphasize existing experiences that are most relevant to the job
187
+ 3. Rephrase bullet points to use keywords from the job description where applicable
188
+ 4. Quantify achievements where possible (if numbers already exist, keep them)
189
+ 5. Make the language more impactful using strong action verbs
190
+ 6. Ensure the skills section highlights the most relevant skills for the job
191
+
192
+ You must respond with a JSON object containing these fields:
193
+ {
194
+ "name": "Full Name",
195
+ "email": "email@example.com",
196
+ "linkedin_url": "https://linkedin.com/in/username",
197
+ "linkedin_display": "linkedin.com/in/username",
198
+ "github_url": "https://github.com/username",
199
+ "github_display": "github.com/username",
200
+ "education": [
201
+ {
202
+ "institution": "University Name",
203
+ "expected": "Expected May 2028",
204
+ "degree": "B.S. in Engineering",
205
+ "coursework": "Course 1, Course 2, Course 3",
206
+ "honors": "Honor 1, Honor 2"
207
+ }
208
+ ],
209
+ "experience": [
210
+ {
211
+ "company": "Company Name",
212
+ "department": "Department (optional, can be empty)",
213
+ "location": "City, State",
214
+ "role": "Job Title",
215
+ "dates": "Month Year – Month Year",
216
+ "bullets": [
217
+ "Achievement bullet 1 with metrics",
218
+ "Achievement bullet 2 with impact"
219
+ ]
220
+ }
221
+ ],
222
+ "projects": [
223
+ {
224
+ "name": "Project Name",
225
+ "technologies": "Tech1, Tech2, Tech3",
226
+ "location": "City, State",
227
+ "role": "Developer",
228
+ "dates": "Month Year – Month Year",
229
+ "bullets": [
230
+ "What you built and its impact",
231
+ "Technical details and results"
232
+ ]
233
+ }
234
+ ],
235
+ "skills": {
236
+ "technical": "Skill1, Skill2, Skill3",
237
+ "communication": "Skill1, Skill2"
238
+ }
239
+ }
240
+
241
+ Optimize the resume content for the job description while maintaining complete truthfulness.
242
+ Reorder experiences and projects to put the most relevant ones first.
243
+ Return ONLY the JSON object, no additional text."""
244
+
245
+
246
+ def build_latex_from_json(data: dict) -> str:
247
+ """Build LaTeX document from structured JSON data"""
248
+
249
+ # Header info
250
+ name = escape_latex(data.get("name", "Name"))
251
+ email = data.get("email", "email@example.com")
252
+ linkedin_url = data.get("linkedin_url", "https://linkedin.com")
253
+ linkedin_display = data.get("linkedin_display", "linkedin.com")
254
+ github_url = data.get("github_url", "https://github.com")
255
+ github_display = data.get("github_display", "github.com")
256
+
257
+ # Build education section
258
+ education_lines = []
259
+ for edu in data.get("education", []):
260
+ institution = escape_latex(edu.get("institution", ""))
261
+ expected = escape_latex(edu.get("expected", ""))
262
+ degree = escape_latex(edu.get("degree", ""))
263
+ coursework = escape_latex(edu.get("coursework", ""))
264
+ honors = escape_latex(edu.get("honors", ""))
265
+
266
+ edu_block = f"""\\noindent
267
+ \\textbf{{{institution}}} \\hfill \\textbf{{{expected}}} \\\\
268
+ {degree}
269
+ \\begin{{itemize}}
270
+ \\item \\textbf{{Coursework: }}{coursework}
271
+ \\item \\textbf{{Honors:}} {honors}
272
+ \\end{{itemize}}"""
273
+ education_lines.append(edu_block)
274
+
275
+ # Build experience section
276
+ experience_lines = []
277
+ for exp in data.get("experience", []):
278
+ company = escape_latex(exp.get("company", ""))
279
+ department = exp.get("department", "")
280
+ location = escape_latex(exp.get("location", ""))
281
+ role = escape_latex(exp.get("role", ""))
282
+ dates = escape_latex(exp.get("dates", ""))
283
+
284
+ if department:
285
+ company_line = f"\\textbf{{{company} $|$ {escape_latex(department)}}}"
286
+ else:
287
+ company_line = f"\\textbf{{{company}}}"
288
+
289
+ bullets = "\n ".join([f"\\item {escape_latex(b)}" for b in exp.get("bullets", [])])
290
+
291
+ exp_block = f"""\\noindent
292
+ {company_line} \\hfill {location} \\\\
293
+ \\textit{{{role}}} \\hfill {dates}
294
+ \\begin{{itemize}}
295
+ {bullets}
296
+ \\end{{itemize}}"""
297
+ experience_lines.append(exp_block)
298
+
299
+ # Build projects section
300
+ project_lines = []
301
+ for proj in data.get("projects", []):
302
+ name_p = escape_latex(proj.get("name", ""))
303
+ tech = escape_latex(proj.get("technologies", ""))
304
+ location = escape_latex(proj.get("location", ""))
305
+ role = escape_latex(proj.get("role", ""))
306
+ dates = escape_latex(proj.get("dates", ""))
307
+
308
+ bullets = "\n ".join([f"\\item {escape_latex(b)}" for b in proj.get("bullets", [])])
309
+
310
+ proj_block = f"""\\noindent
311
+ \\textbf{{{name_p} $|$ {tech}}} \\hfill {location} \\\\
312
+ \\textit{{{role}}} \\hfill {dates}
313
+ \\begin{{itemize}}
314
+ {bullets}
315
+ \\end{{itemize}}"""
316
+ project_lines.append(proj_block)
317
+
318
+ # Build skills section
319
+ skills = data.get("skills", {})
320
+ technical = escape_latex(skills.get("technical", ""))
321
+ communication = escape_latex(skills.get("communication", ""))
322
+
323
+ skills_content = f"""\\begin{{itemize}}
324
+ \\item \\textbf{{Technical:}} {technical}
325
+ \\item \\textbf{{Communication:}} {communication}
326
+ \\end{{itemize}}"""
327
+
328
+ # Assemble final LaTeX
329
+ latex = LATEX_TEMPLATE
330
+ latex = latex.replace("<<NAME>>", name)
331
+ latex = latex.replace("<<EMAIL>>", email)
332
+ latex = latex.replace("<<LINKEDIN_URL>>", linkedin_url)
333
+ latex = latex.replace("<<LINKEDIN_DISPLAY>>", linkedin_display)
334
+ latex = latex.replace("<<GITHUB_URL>>", github_url)
335
+ latex = latex.replace("<<GITHUB_DISPLAY>>", github_display)
336
+ latex = latex.replace("<<EDUCATION_CONTENT>>", "\n\n".join(education_lines))
337
+ latex = latex.replace("<<EXPERIENCE_CONTENT>>", "\n\n".join(experience_lines))
338
+ latex = latex.replace("<<PROJECTS_CONTENT>>", "\n\n".join(project_lines))
339
+ latex = latex.replace("<<SKILLS_CONTENT>>", skills_content)
340
+
341
+ return latex
342
+
343
+
344
+ @app.get("/api/")
345
+ async def root():
346
+ return {"message": "Resume Optimizer API", "status": "running"}
347
+
348
+
349
+ @app.get("/api/health")
350
+ async def health():
351
+ return {"status": "healthy"}
352
+
353
+
354
+ @app.post("/api/optimize")
355
+ async def optimize_resume(
356
+ resume: UploadFile = File(...),
357
+ job_description: str = Form(...)
358
+ ):
359
+ """
360
+ Optimize a resume for a specific job description.
361
+ Returns a compiled PDF.
362
+ """
363
+
364
+ # Read and extract text from resume
365
+ content = await resume.read()
366
+ resume_text = extract_text_from_file(content, resume.filename or "resume.pdf")
367
+
368
+ if not resume_text.strip():
369
+ raise HTTPException(status_code=400, detail="Could not extract text from resume")
370
+
371
+ # Build prompt for LLM
372
+ prompt = f"""Here is the original resume:
373
+
374
+ {resume_text}
375
+
376
+ Here is the job description to optimize for:
377
+
378
+ {job_description}
379
+
380
+ Please optimize this resume for the job description. Return only the JSON object as specified."""
381
+
382
+ # Call OpenRouter
383
+ llm_response = await call_openrouter(prompt, SYSTEM_PROMPT)
384
+
385
+ # Parse JSON from response
386
+ try:
387
+ # Try to extract JSON from the response
388
+ json_match = re.search(r'\{[\s\S]*\}', llm_response)
389
+ if json_match:
390
+ resume_data = json.loads(json_match.group())
391
+ else:
392
+ raise ValueError("No JSON found in response")
393
+ except (json.JSONDecodeError, ValueError) as e:
394
+ raise HTTPException(
395
+ status_code=500,
396
+ detail=f"Failed to parse LLM response: {str(e)}\n\nResponse: {llm_response[:500]}"
397
+ )
398
+
399
+ # Build LaTeX from structured data
400
+ latex_content = build_latex_from_json(resume_data)
401
+
402
+ # Compile to PDF
403
+ pdf_bytes = await compile_latex(latex_content)
404
+
405
+ return Response(
406
+ content=pdf_bytes,
407
+ media_type="application/pdf",
408
+ headers={
409
+ "Content-Disposition": "attachment; filename=optimized_resume.pdf"
410
+ }
411
+ )
412
+
413
+
414
+ @app.post("/api/optimize-json")
415
+ async def optimize_resume_json(
416
+ resume: UploadFile = File(...),
417
+ job_description: str = Form(...)
418
+ ):
419
+ """
420
+ Optimize a resume and return the structured JSON data (for debugging).
421
+ """
422
+
423
+ content = await resume.read()
424
+ resume_text = extract_text_from_file(content, resume.filename or "resume.pdf")
425
+
426
+ if not resume_text.strip():
427
+ raise HTTPException(status_code=400, detail="Could not extract text from resume")
428
+
429
+ prompt = f"""Here is the original resume:
430
+
431
+ {resume_text}
432
+
433
+ Here is the job description to optimize for:
434
+
435
+ {job_description}
436
+
437
+ Please optimize this resume for the job description. Return only the JSON object as specified."""
438
+
439
+ llm_response = await call_openrouter(prompt, SYSTEM_PROMPT)
440
+
441
+ try:
442
+ json_match = re.search(r'\{[\s\S]*\}', llm_response)
443
+ if json_match:
444
+ resume_data = json.loads(json_match.group())
445
+ else:
446
+ raise ValueError("No JSON found in response")
447
+ except (json.JSONDecodeError, ValueError) as e:
448
+ return {"error": str(e), "raw_response": llm_response}
449
+
450
+ return {
451
+ "optimized_data": resume_data,
452
+ "latex": build_latex_from_json(resume_data)
453
+ }
454
+
455
+
456
+ # Mount static files LAST so API routes take precedence
457
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
458
+
459
+
460
+ if __name__ == "__main__":
461
+ import uvicorn
462
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ python-multipart==0.0.6
4
+ httpx==0.26.0
5
+ PyMuPDF==1.23.8
6
+ aiofiles==23.2.1
static/index.html ADDED
@@ -0,0 +1,697 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>ResumeForge | AI Resume Optimizer</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=Instrument+Serif:ital@0;1&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg-primary: #0a0a0b;
13
+ --bg-secondary: #111113;
14
+ --bg-tertiary: #1a1a1d;
15
+ --text-primary: #fafafa;
16
+ --text-secondary: #a1a1a6;
17
+ --text-muted: #636366;
18
+ --accent: #6366f1;
19
+ --accent-soft: rgba(99, 102, 241, 0.15);
20
+ --border: #2a2a2d;
21
+ --success: #22c55e;
22
+ --error: #ef4444;
23
+ }
24
+
25
+ * {
26
+ margin: 0;
27
+ padding: 0;
28
+ box-sizing: border-box;
29
+ }
30
+
31
+ body {
32
+ font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
33
+ background: var(--bg-primary);
34
+ color: var(--text-primary);
35
+ min-height: 100vh;
36
+ line-height: 1.6;
37
+ overflow-x: hidden;
38
+ }
39
+
40
+ /* Animated gradient background */
41
+ .bg-gradient {
42
+ position: fixed;
43
+ top: 0;
44
+ left: 0;
45
+ right: 0;
46
+ bottom: 0;
47
+ background:
48
+ radial-gradient(ellipse 80% 50% at 20% -20%, rgba(99, 102, 241, 0.15), transparent),
49
+ radial-gradient(ellipse 60% 40% at 80% 100%, rgba(139, 92, 246, 0.1), transparent);
50
+ pointer-events: none;
51
+ z-index: 0;
52
+ }
53
+
54
+ .container {
55
+ max-width: 1200px;
56
+ margin: 0 auto;
57
+ padding: 2rem;
58
+ position: relative;
59
+ z-index: 1;
60
+ }
61
+
62
+ /* Header */
63
+ header {
64
+ text-align: center;
65
+ padding: 3rem 0 4rem;
66
+ }
67
+
68
+ .logo {
69
+ font-family: 'Instrument Serif', Georgia, serif;
70
+ font-size: 3rem;
71
+ font-weight: 400;
72
+ letter-spacing: -0.02em;
73
+ margin-bottom: 0.5rem;
74
+ background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
75
+ -webkit-background-clip: text;
76
+ -webkit-text-fill-color: transparent;
77
+ background-clip: text;
78
+ }
79
+
80
+ .tagline {
81
+ color: var(--text-secondary);
82
+ font-size: 1.1rem;
83
+ font-weight: 300;
84
+ }
85
+
86
+ /* Main Grid */
87
+ .main-grid {
88
+ display: grid;
89
+ grid-template-columns: 1fr 1fr;
90
+ gap: 2rem;
91
+ }
92
+
93
+ @media (max-width: 900px) {
94
+ .main-grid {
95
+ grid-template-columns: 1fr;
96
+ }
97
+ }
98
+
99
+ /* Cards */
100
+ .card {
101
+ background: var(--bg-secondary);
102
+ border: 1px solid var(--border);
103
+ border-radius: 16px;
104
+ padding: 1.75rem;
105
+ transition: all 0.3s ease;
106
+ }
107
+
108
+ .card:hover {
109
+ border-color: rgba(99, 102, 241, 0.3);
110
+ }
111
+
112
+ .card-title {
113
+ font-family: 'Instrument Serif', Georgia, serif;
114
+ font-size: 1.5rem;
115
+ font-weight: 400;
116
+ margin-bottom: 0.25rem;
117
+ }
118
+
119
+ .card-subtitle {
120
+ color: var(--text-muted);
121
+ font-size: 0.875rem;
122
+ margin-bottom: 1.5rem;
123
+ }
124
+
125
+ /* File Upload */
126
+ .upload-zone {
127
+ border: 2px dashed var(--border);
128
+ border-radius: 12px;
129
+ padding: 2.5rem;
130
+ text-align: center;
131
+ cursor: pointer;
132
+ transition: all 0.3s ease;
133
+ background: var(--bg-tertiary);
134
+ }
135
+
136
+ .upload-zone:hover,
137
+ .upload-zone.dragover {
138
+ border-color: var(--accent);
139
+ background: var(--accent-soft);
140
+ }
141
+
142
+ .upload-zone.has-file {
143
+ border-color: var(--success);
144
+ border-style: solid;
145
+ }
146
+
147
+ .upload-icon {
148
+ width: 48px;
149
+ height: 48px;
150
+ margin: 0 auto 1rem;
151
+ opacity: 0.5;
152
+ }
153
+
154
+ .upload-text {
155
+ color: var(--text-secondary);
156
+ font-size: 0.95rem;
157
+ }
158
+
159
+ .upload-text strong {
160
+ color: var(--accent);
161
+ }
162
+
163
+ .file-name {
164
+ color: var(--success);
165
+ font-weight: 500;
166
+ margin-top: 0.5rem;
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ gap: 0.5rem;
171
+ }
172
+
173
+ #resume-input {
174
+ display: none;
175
+ }
176
+
177
+ /* Job Description */
178
+ .jd-textarea {
179
+ width: 100%;
180
+ min-height: 280px;
181
+ background: var(--bg-tertiary);
182
+ border: 1px solid var(--border);
183
+ border-radius: 12px;
184
+ padding: 1rem;
185
+ color: var(--text-primary);
186
+ font-family: inherit;
187
+ font-size: 0.95rem;
188
+ resize: vertical;
189
+ transition: all 0.3s ease;
190
+ }
191
+
192
+ .jd-textarea:focus {
193
+ outline: none;
194
+ border-color: var(--accent);
195
+ box-shadow: 0 0 0 3px var(--accent-soft);
196
+ }
197
+
198
+ .jd-textarea::placeholder {
199
+ color: var(--text-muted);
200
+ }
201
+
202
+ /* Submit Button */
203
+ .submit-section {
204
+ margin-top: 2rem;
205
+ text-align: center;
206
+ }
207
+
208
+ .submit-btn {
209
+ display: inline-flex;
210
+ align-items: center;
211
+ gap: 0.75rem;
212
+ padding: 1rem 2.5rem;
213
+ background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
214
+ color: white;
215
+ font-family: inherit;
216
+ font-size: 1rem;
217
+ font-weight: 500;
218
+ border: none;
219
+ border-radius: 50px;
220
+ cursor: pointer;
221
+ transition: all 0.3s ease;
222
+ box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
223
+ }
224
+
225
+ .submit-btn:hover:not(:disabled) {
226
+ transform: translateY(-2px);
227
+ box-shadow: 0 6px 30px rgba(99, 102, 241, 0.4);
228
+ }
229
+
230
+ .submit-btn:disabled {
231
+ opacity: 0.5;
232
+ cursor: not-allowed;
233
+ }
234
+
235
+ .submit-btn svg {
236
+ width: 20px;
237
+ height: 20px;
238
+ }
239
+
240
+ /* Loading State */
241
+ .loading {
242
+ display: none;
243
+ flex-direction: column;
244
+ align-items: center;
245
+ gap: 1rem;
246
+ padding: 3rem;
247
+ }
248
+
249
+ .loading.active {
250
+ display: flex;
251
+ }
252
+
253
+ .spinner {
254
+ width: 48px;
255
+ height: 48px;
256
+ border: 3px solid var(--border);
257
+ border-top-color: var(--accent);
258
+ border-radius: 50%;
259
+ animation: spin 1s linear infinite;
260
+ }
261
+
262
+ @keyframes spin {
263
+ to { transform: rotate(360deg); }
264
+ }
265
+
266
+ .loading-text {
267
+ color: var(--text-secondary);
268
+ font-size: 0.95rem;
269
+ }
270
+
271
+ .loading-steps {
272
+ display: flex;
273
+ flex-direction: column;
274
+ gap: 0.5rem;
275
+ margin-top: 0.5rem;
276
+ }
277
+
278
+ .step {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 0.5rem;
282
+ color: var(--text-muted);
283
+ font-size: 0.85rem;
284
+ }
285
+
286
+ .step.active {
287
+ color: var(--accent);
288
+ }
289
+
290
+ .step.done {
291
+ color: var(--success);
292
+ }
293
+
294
+ /* Result */
295
+ .result {
296
+ display: none;
297
+ text-align: center;
298
+ padding: 2rem;
299
+ }
300
+
301
+ .result.active {
302
+ display: block;
303
+ }
304
+
305
+ .success-icon {
306
+ width: 64px;
307
+ height: 64px;
308
+ margin: 0 auto 1rem;
309
+ color: var(--success);
310
+ }
311
+
312
+ .result h3 {
313
+ font-family: 'Instrument Serif', Georgia, serif;
314
+ font-size: 1.75rem;
315
+ font-weight: 400;
316
+ margin-bottom: 0.5rem;
317
+ }
318
+
319
+ .result p {
320
+ color: var(--text-secondary);
321
+ margin-bottom: 1.5rem;
322
+ }
323
+
324
+ .download-btn {
325
+ display: inline-flex;
326
+ align-items: center;
327
+ gap: 0.5rem;
328
+ padding: 0.875rem 2rem;
329
+ background: var(--success);
330
+ color: white;
331
+ font-family: inherit;
332
+ font-size: 1rem;
333
+ font-weight: 500;
334
+ border: none;
335
+ border-radius: 50px;
336
+ cursor: pointer;
337
+ transition: all 0.3s ease;
338
+ text-decoration: none;
339
+ }
340
+
341
+ .download-btn:hover {
342
+ background: #16a34a;
343
+ transform: translateY(-2px);
344
+ }
345
+
346
+ .reset-btn {
347
+ display: inline-block;
348
+ margin-top: 1rem;
349
+ color: var(--text-muted);
350
+ font-size: 0.9rem;
351
+ cursor: pointer;
352
+ transition: color 0.2s;
353
+ }
354
+
355
+ .reset-btn:hover {
356
+ color: var(--text-primary);
357
+ }
358
+
359
+ /* Error */
360
+ .error-message {
361
+ display: none;
362
+ background: rgba(239, 68, 68, 0.1);
363
+ border: 1px solid rgba(239, 68, 68, 0.3);
364
+ border-radius: 12px;
365
+ padding: 1rem;
366
+ color: var(--error);
367
+ margin-top: 1rem;
368
+ }
369
+
370
+ .error-message.active {
371
+ display: block;
372
+ }
373
+
374
+ /* Footer */
375
+ footer {
376
+ text-align: center;
377
+ padding: 3rem 0;
378
+ color: var(--text-muted);
379
+ font-size: 0.85rem;
380
+ }
381
+
382
+ footer a {
383
+ color: var(--text-secondary);
384
+ text-decoration: none;
385
+ }
386
+
387
+ footer a:hover {
388
+ color: var(--accent);
389
+ }
390
+ </style>
391
+ </head>
392
+ <body>
393
+ <div class="bg-gradient"></div>
394
+
395
+ <div class="container">
396
+ <header>
397
+ <h1 class="logo">ResumeForge</h1>
398
+ <p class="tagline">AI-powered resume optimization for your dream job</p>
399
+ </header>
400
+
401
+ <form id="optimize-form">
402
+ <div class="main-grid">
403
+ <div class="card">
404
+ <h2 class="card-title">Your Resume</h2>
405
+ <p class="card-subtitle">Upload your current resume (PDF, DOCX, or TXT)</p>
406
+
407
+ <div class="upload-zone" id="upload-zone">
408
+ <svg class="upload-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
409
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
410
+ </svg>
411
+ <p class="upload-text">
412
+ <strong>Click to upload</strong> or drag and drop<br>
413
+ PDF, DOCX, or TXT (max 10MB)
414
+ </p>
415
+ <p class="file-name" id="file-name" style="display: none;">
416
+ <svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
417
+ <path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
418
+ </svg>
419
+ <span></span>
420
+ </p>
421
+ </div>
422
+ <input type="file" id="resume-input" name="resume" accept=".pdf,.doc,.docx,.txt">
423
+ </div>
424
+
425
+ <div class="card">
426
+ <h2 class="card-title">Job Description</h2>
427
+ <p class="card-subtitle">Paste the job posting you're applying for</p>
428
+
429
+ <textarea
430
+ class="jd-textarea"
431
+ id="job-description"
432
+ name="job_description"
433
+ placeholder="Paste the full job description here...
434
+
435
+ Include:
436
+ • Job title and company
437
+ • Required qualifications
438
+ • Preferred skills
439
+ • Responsibilities
440
+ • Any specific keywords or technologies mentioned"
441
+ ></textarea>
442
+ </div>
443
+ </div>
444
+
445
+ <div class="submit-section" id="submit-section">
446
+ <button type="submit" class="submit-btn" id="submit-btn" disabled>
447
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
448
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
449
+ </svg>
450
+ Optimize Resume
451
+ </button>
452
+ </div>
453
+
454
+ <div class="error-message" id="error-message"></div>
455
+ </form>
456
+
457
+ <div class="loading" id="loading">
458
+ <div class="spinner"></div>
459
+ <p class="loading-text">Optimizing your resume...</p>
460
+ <div class="loading-steps">
461
+ <div class="step" id="step-1">
462
+ <span>○</span> Analyzing your resume
463
+ </div>
464
+ <div class="step" id="step-2">
465
+ <span>○</span> Matching with job description
466
+ </div>
467
+ <div class="step" id="step-3">
468
+ <span>○</span> Generating optimized content
469
+ </div>
470
+ <div class="step" id="step-4">
471
+ <span>○</span> Compiling PDF
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <div class="result" id="result">
477
+ <svg class="success-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
478
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
479
+ </svg>
480
+ <h3>Resume Optimized!</h3>
481
+ <p>Your tailored resume is ready to download.</p>
482
+ <a href="#" class="download-btn" id="download-btn">
483
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
484
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
485
+ </svg>
486
+ Download PDF
487
+ </a>
488
+ <p class="reset-btn" id="reset-btn">← Optimize another resume</p>
489
+ </div>
490
+
491
+ <footer>
492
+ <p>
493
+ Built with FastAPI + latex.ytotech.com<br>
494
+ Powered by <a href="https://openrouter.ai" target="_blank">OpenRouter</a> (MiMo V2 Flash)<br>
495
+ Deployed on <a href="https://huggingface.co/spaces" target="_blank">Hugging Face Spaces</a>
496
+ </p>
497
+ </footer>
498
+ </div>
499
+
500
+ <script>
501
+ // Elements
502
+ const form = document.getElementById('optimize-form');
503
+ const uploadZone = document.getElementById('upload-zone');
504
+ const resumeInput = document.getElementById('resume-input');
505
+ const fileNameEl = document.getElementById('file-name');
506
+ const jobDescription = document.getElementById('job-description');
507
+ const submitBtn = document.getElementById('submit-btn');
508
+ const submitSection = document.getElementById('submit-section');
509
+ const loading = document.getElementById('loading');
510
+ const result = document.getElementById('result');
511
+ const errorMessage = document.getElementById('error-message');
512
+ const downloadBtn = document.getElementById('download-btn');
513
+ const resetBtn = document.getElementById('reset-btn');
514
+
515
+ // State
516
+ let selectedFile = null;
517
+ let pdfBlob = null;
518
+
519
+ // File upload handling
520
+ uploadZone.addEventListener('click', () => resumeInput.click());
521
+
522
+ uploadZone.addEventListener('dragover', (e) => {
523
+ e.preventDefault();
524
+ uploadZone.classList.add('dragover');
525
+ });
526
+
527
+ uploadZone.addEventListener('dragleave', () => {
528
+ uploadZone.classList.remove('dragover');
529
+ });
530
+
531
+ uploadZone.addEventListener('drop', (e) => {
532
+ e.preventDefault();
533
+ uploadZone.classList.remove('dragover');
534
+ const file = e.dataTransfer.files[0];
535
+ if (file) handleFileSelect(file);
536
+ });
537
+
538
+ resumeInput.addEventListener('change', (e) => {
539
+ if (e.target.files[0]) {
540
+ handleFileSelect(e.target.files[0]);
541
+ }
542
+ });
543
+
544
+ function handleFileSelect(file) {
545
+ const validTypes = ['application/pdf', 'application/msword',
546
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
547
+ 'text/plain'];
548
+
549
+ if (!validTypes.includes(file.type) && !file.name.match(/\.(pdf|doc|docx|txt)$/i)) {
550
+ showError('Please upload a PDF, DOCX, or TXT file.');
551
+ return;
552
+ }
553
+
554
+ if (file.size > 10 * 1024 * 1024) {
555
+ showError('File size must be less than 10MB.');
556
+ return;
557
+ }
558
+
559
+ selectedFile = file;
560
+ uploadZone.classList.add('has-file');
561
+ fileNameEl.style.display = 'flex';
562
+ fileNameEl.querySelector('span').textContent = file.name;
563
+ updateSubmitButton();
564
+ }
565
+
566
+ // Job description handling
567
+ jobDescription.addEventListener('input', updateSubmitButton);
568
+
569
+ function updateSubmitButton() {
570
+ const hasFile = selectedFile !== null;
571
+ const hasJD = jobDescription.value.trim().length > 50;
572
+ submitBtn.disabled = !(hasFile && hasJD);
573
+ }
574
+
575
+ // Form submission
576
+ form.addEventListener('submit', async (e) => {
577
+ e.preventDefault();
578
+
579
+ hideError();
580
+ submitSection.style.display = 'none';
581
+ form.style.display = 'none';
582
+ loading.classList.add('active');
583
+
584
+ // Simulate progress steps
585
+ const steps = ['step-1', 'step-2', 'step-3', 'step-4'];
586
+ let currentStep = 0;
587
+
588
+ const stepInterval = setInterval(() => {
589
+ if (currentStep > 0) {
590
+ document.getElementById(steps[currentStep - 1]).classList.remove('active');
591
+ document.getElementById(steps[currentStep - 1]).classList.add('done');
592
+ document.getElementById(steps[currentStep - 1]).querySelector('span').textContent = '✓';
593
+ }
594
+ if (currentStep < steps.length) {
595
+ document.getElementById(steps[currentStep]).classList.add('active');
596
+ document.getElementById(steps[currentStep]).querySelector('span').textContent = '◉';
597
+ currentStep++;
598
+ }
599
+ }, 2000);
600
+
601
+ try {
602
+ const formData = new FormData();
603
+ formData.append('resume', selectedFile);
604
+ formData.append('job_description', jobDescription.value);
605
+
606
+ const response = await fetch('/api/optimize', {
607
+ method: 'POST',
608
+ body: formData
609
+ });
610
+
611
+ clearInterval(stepInterval);
612
+
613
+ if (!response.ok) {
614
+ const errorText = await response.text();
615
+ throw new Error(errorText || 'Failed to optimize resume');
616
+ }
617
+
618
+ pdfBlob = await response.blob();
619
+
620
+ // Mark all steps as done
621
+ steps.forEach(stepId => {
622
+ const step = document.getElementById(stepId);
623
+ step.classList.remove('active');
624
+ step.classList.add('done');
625
+ step.querySelector('span').textContent = '✓';
626
+ });
627
+
628
+ setTimeout(() => {
629
+ loading.classList.remove('active');
630
+ result.classList.add('active');
631
+ }, 500);
632
+
633
+ } catch (error) {
634
+ clearInterval(stepInterval);
635
+ loading.classList.remove('active');
636
+ form.style.display = 'block';
637
+ submitSection.style.display = 'block';
638
+ showError(error.message);
639
+
640
+ // Reset steps
641
+ steps.forEach(stepId => {
642
+ const step = document.getElementById(stepId);
643
+ step.classList.remove('active', 'done');
644
+ step.querySelector('span').textContent = '○';
645
+ });
646
+ }
647
+ });
648
+
649
+ // Download
650
+ downloadBtn.addEventListener('click', (e) => {
651
+ e.preventDefault();
652
+ if (pdfBlob) {
653
+ const url = URL.createObjectURL(pdfBlob);
654
+ const a = document.createElement('a');
655
+ a.href = url;
656
+ a.download = 'optimized_resume.pdf';
657
+ document.body.appendChild(a);
658
+ a.click();
659
+ document.body.removeChild(a);
660
+ URL.revokeObjectURL(url);
661
+ }
662
+ });
663
+
664
+ // Reset
665
+ resetBtn.addEventListener('click', () => {
666
+ result.classList.remove('active');
667
+ form.style.display = 'block';
668
+ submitSection.style.display = 'block';
669
+
670
+ // Reset form
671
+ selectedFile = null;
672
+ pdfBlob = null;
673
+ uploadZone.classList.remove('has-file');
674
+ fileNameEl.style.display = 'none';
675
+ jobDescription.value = '';
676
+ updateSubmitButton();
677
+
678
+ // Reset steps
679
+ ['step-1', 'step-2', 'step-3', 'step-4'].forEach(stepId => {
680
+ const step = document.getElementById(stepId);
681
+ step.classList.remove('active', 'done');
682
+ step.querySelector('span').textContent = '○';
683
+ });
684
+ });
685
+
686
+ // Error handling
687
+ function showError(message) {
688
+ errorMessage.textContent = message;
689
+ errorMessage.classList.add('active');
690
+ }
691
+
692
+ function hideError() {
693
+ errorMessage.classList.remove('active');
694
+ }
695
+ </script>
696
+ </body>
697
+ </html>