resume-forge / static /app.js
aka38's picture
Upload 5 files
f226eb6 verified
/**
* ResumeForge V2 β€” Frontend with PDF Preview & Visual Edit Mode
*/
(function () {
'use strict';
// ─── State ───
const state = {
resumeContent: '',
jobDescription: '',
fileName: 'resume.tex',
pdfSessionId: null,
analysis: null,
editSelections: {},
editModifiedTexts: {}, // Tracks user edits to suggestions
modifiedContent: '',
exportSessionId: null,
};
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
const els = {
stageInput: $('#stage-input'),
stageReview: $('#stage-review'),
stageExport: $('#stage-export'),
dropZone: $('#drop-zone'),
fileInput: $('#file-input'),
btnBrowse: $('#btn-browse'),
jdInput: $('#jd-input'),
instructionsInput: $('#instructions-input'),
resumeFileInfo: $('#resume-file-info'),
fileName: $('#file-name'),
btnRemoveFile: $('#btn-remove-file'),
btnLoadSample: $('#btn-load-sample'),
btnAnalyze: $('#btn-analyze'),
loadingOverlay: $('#loading-overlay'),
loadingStatus: $('#loading-status'),
pdfCompileStatus: $('#pdf-compile-status'),
pdfPlaceholder: $('#pdf-placeholder'),
pdfCanvas: $('#pdf-canvas'),
reviewPdfCanvas: $('#review-pdf-canvas'),
exportPdfCanvas: $('#export-pdf-canvas'),
scoreBefore: $('#score-before'),
scoreAfter: $('#score-after'),
keywordPills: $('#keyword-pills'),
analysisSummary: $('#analysis-summary'),
editCount: $('#edit-count'),
editsContainer: $('#edits-container'),
btnSelectAll: $('#btn-select-all'),
btnDeselectAll: $('#btn-deselect-all'),
btnBackInput: $('#btn-back-input'),
btnApply: $('#btn-apply'),
selectedCount: $('#selected-count'),
btnDownloadPdf: $('#btn-download-pdf'),
btnDownloadTex: $('#btn-download-tex'),
btnCopyTex: $('#btn-copy-tex'),
btnStartOver: $('#btn-start-over'),
btnEditSource: $('#btn-edit-source'),
btnIterateAi: $('#btn-iterate-ai'),
manualEditCard: $('#manual-edit-card'),
mainExportCard: $('.export-actions-pane .export-card:not(#manual-edit-card)'),
btnDoneEditing: $('#btn-done-editing'),
btnCompilePreview: $('#btn-compile-preview'),
manualLatexEditor: $('#manual-latex-editor'),
keyModal: $('#key-modal'),
apiKeyInput: $('#api-key-input'),
btnSaveKey: $('#btn-save-key'),
toast: $('#toast'),
toastMessage: $('#toast-message'),
toastClose: $('#toast-close'),
};
const SAMPLE_JD = `Senior Full-Stack Engineer β€” FinTech Startup
We are looking for a Senior Full-Stack Engineer to join our growing team building the next generation of digital banking infrastructure.
Responsibilities:
- Design and implement scalable microservices using Python (FastAPI/Django) and Go
- Build and maintain React-based web applications with TypeScript
- Architect and optimize cloud-native solutions on AWS (ECS, Lambda, DynamoDB, SQS)
- Implement robust CI/CD pipelines and infrastructure as code using Terraform
- Lead technical design reviews and mentor junior engineers
- Work with product managers to translate business requirements into technical solutions
- Ensure high availability and security compliance (SOC 2, PCI-DSS)
Requirements:
- 5+ years of professional software engineering experience
- Strong proficiency in Python and TypeScript/JavaScript
- Experience with cloud platforms (AWS preferred) and containerization (Docker, Kubernetes)
- Experience with relational and NoSQL databases (PostgreSQL, DynamoDB, Redis)
- Familiarity with event-driven architectures and message queues
- BS/MS in Computer Science or equivalent experience
Nice to have:
- Experience in FinTech or regulated industries
- Knowledge of Terraform or CloudFormation
- Experience with GraphQL
- Contributions to open-source projects`;
// ─── Utils ───
function showToast(msg, duration = 5000) {
els.toastMessage.textContent = msg;
els.toast.classList.remove('hidden');
clearTimeout(state._toastTimer);
state._toastTimer = setTimeout(() => els.toast.classList.add('hidden'), duration);
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function updateAnalyzeButton() {
const hasResume = state.resumeContent.length > 20;
const hasJD = els.jdInput.value.trim().length > 20;
els.btnAnalyze.disabled = !(hasResume && hasJD);
}
function switchStage(name) {
$$('.stage').forEach(s => s.classList.remove('active'));
$$('.step').forEach(s => s.classList.remove('active', 'completed'));
$$('.step-connector').forEach(c => c.classList.remove('completed'));
const map = { input: 1, review: 2, export: 3 };
const n = map[name];
$(`#stage-${name}`).classList.add('active');
$$('.step').forEach(s => {
const sn = parseInt(s.dataset.step);
if (sn < n) s.classList.add('completed');
if (sn === n) s.classList.add('active');
});
$$('.step-connector').forEach((c, i) => { if (i + 1 < n) c.classList.add('completed'); });
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function getSelectedCount() {
return Object.values(state.editSelections).filter(Boolean).length;
}
function updateSelectedCount() {
const c = getSelectedCount();
els.selectedCount.textContent = c;
els.btnApply.disabled = c === 0;
}
// ─── PDF Rendering ───
async function renderPdf(sessionId, canvas) {
if (!sessionId) return;
try {
const url = `/api/pdf/${sessionId}`;
const pdf = await pdfjsLib.getDocument(url).promise;
const page = await pdf.getPage(1);
// Scale to fit container width
const container = canvas.parentElement;
const containerWidth = container.clientWidth - 32;
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = Math.min(containerWidth / unscaledViewport.width, 2.0);
const viewport = page.getViewport({ scale });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.classList.remove('hidden');
await page.render({
canvasContext: canvas.getContext('2d'),
viewport: viewport,
}).promise;
} catch (err) {
console.error('PDF render error:', err);
}
}
async function compilePdf(latexContent) {
const resp = await fetch('/api/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latex_content: latexContent }),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Compilation failed');
}
const data = await resp.json();
return data.session_id;
}
// ─── File Handling ───
function handleFile(file) {
if (!file) return;
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!['.tex', '.latex', '.txt'].includes(ext)) {
showToast('Please upload a .tex file.');
return;
}
state.fileName = file.name;
const reader = new FileReader();
reader.onload = async (e) => {
state.resumeContent = e.target.result;
els.dropZone.classList.add('hidden');
els.resumeFileInfo.classList.remove('hidden');
els.fileName.textContent = file.name;
updateAnalyzeButton();
await compileAndPreview();
};
reader.readAsText(file);
}
async function compileAndPreview() {
els.pdfCompileStatus.classList.remove('hidden');
els.pdfPlaceholder.classList.add('hidden');
try {
state.pdfSessionId = await compilePdf(state.resumeContent);
await renderPdf(state.pdfSessionId, els.pdfCanvas);
} catch (err) {
showToast('PDF compilation failed: ' + err.message);
els.pdfPlaceholder.classList.remove('hidden');
} finally {
els.pdfCompileStatus.classList.add('hidden');
}
}
// ─── API Key ───
async function checkApiKey() {
try {
const r = await fetch('/api/key/status');
return (await r.json()).has_key;
} catch { return false; }
}
async function saveApiKey(key) {
const r = await fetch('/api/key/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: key }),
});
return r.ok;
}
// ─── Analyze ───
async function analyzeResume() {
if (!(await checkApiKey())) {
els.keyModal.classList.remove('hidden');
els.apiKeyInput.focus();
return;
}
els.loadingOverlay.classList.remove('hidden');
try {
const resp = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resume_content: state.resumeContent,
job_description: els.jdInput.value,
additional_instructions: els.instructionsInput ? els.instructionsInput.value : "",
}),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Analysis failed');
}
state.analysis = await resp.json();
state.jobDescription = els.jdInput.value;
switchStage('review');
renderReviewStage();
} catch (err) {
showToast(err.message);
} finally {
els.loadingOverlay.classList.add('hidden');
}
}
// ─── Apply ───
async function applyEdits() {
const selectedEdits = state.analysis.edits
.map((e, i) => {
if (!state.editSelections[i]) return null;
return {
original_text: e.original_text,
modified_text: state.editModifiedTexts[i] || e.modified_text,
};
})
.filter(Boolean);
if (selectedEdits.length === 0) {
showToast('Select at least one edit.');
return;
}
els.loadingOverlay.classList.remove('hidden');
els.loadingStatus.textContent = 'Applying edits and compiling PDF...';
try {
const resp = await fetch('/api/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resume_content: state.resumeContent,
edits: selectedEdits,
}),
});
if (!resp.ok) throw new Error('Failed to apply edits');
const data = await resp.json();
state.modifiedContent = data.modified_content;
state.exportSessionId = data.session_id;
switchStage('export');
// Render export PDF
if (state.exportSessionId) {
await renderPdf(state.exportSessionId, els.exportPdfCanvas);
}
} catch (err) {
showToast(err.message);
} finally {
els.loadingOverlay.classList.add('hidden');
}
}
// ─── Render Review ───
function renderReviewStage() {
const a = state.analysis;
// Render original PDF in review pane
if (state.pdfSessionId) {
renderPdf(state.pdfSessionId, els.reviewPdfCanvas);
}
// Scores
els.scoreBefore.textContent = a.match_score_before;
els.scoreAfter.textContent = a.match_score_after;
// Summary
els.analysisSummary.textContent = a.summary;
// Keywords
els.keywordPills.innerHTML = a.keyword_gaps
.map(k => `<span class="keyword-pill">${escapeHtml(k)}</span>`)
.join('');
// Edits
els.editCount.textContent = a.edits.length;
state.editSelections = {};
state.editModifiedTexts = {};
a.edits.forEach((_, i) => { state.editSelections[i] = true; });
els.editsContainer.innerHTML = a.edits.map((edit, i) => `
<div class="edit-card selected expanded" data-index="${i}">
<div class="edit-card-header">
<div class="edit-checkbox">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20,6 9,17 4,12"/></svg>
</div>
<span class="edit-section">${escapeHtml(edit.section)}</span>
<span class="edit-priority priority-${edit.priority}">${edit.priority}</span>
<button class="edit-toggle-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"/></svg></button>
</div>
<div class="edit-body">
<p class="edit-reasoning">${escapeHtml(edit.reasoning)}</p>
<div class="edit-visual-diff">
<div class="diff-col diff-col-original">
<span class="diff-col-label">Original</span>
<div class="diff-col-text">${escapeHtml(edit.display_original || edit.original_text)}</div>
</div>
<div class="diff-col diff-col-modified">
<span class="diff-col-label">Suggested (editable)</span>
<textarea class="edit-suggestion-textarea" data-index="${i}">${escapeHtml(edit.display_modified || edit.modified_text)}</textarea>
</div>
</div>
</div>
</div>
`).join('');
updateSelectedCount();
// Event listeners
els.editsContainer.querySelectorAll('.edit-card').forEach(card => {
const idx = parseInt(card.dataset.index);
card.querySelector('.edit-checkbox').addEventListener('click', (e) => {
e.stopPropagation();
state.editSelections[idx] = !state.editSelections[idx];
card.classList.toggle('selected', state.editSelections[idx]);
updateSelectedCount();
});
card.querySelector('.edit-toggle-btn').addEventListener('click', (e) => {
e.stopPropagation();
card.classList.toggle('expanded');
});
card.querySelector('.edit-card-header').addEventListener('click', () => {
card.classList.toggle('expanded');
});
});
// Track user edits to suggestion textareas
// Note: the textarea shows display_modified (readable text) but we need to
// detect if the user changed it. If they did, we'll need to update the LaTeX.
// For simplicity, if user edits the display text, we update modified_text
// by replacing the display portion in the LaTeX.
els.editsContainer.querySelectorAll('.edit-suggestion-textarea').forEach(ta => {
const idx = parseInt(ta.dataset.index);
const edit = state.analysis.edits[idx];
ta.addEventListener('input', () => {
// If user modifies the displayed text, update the modified LaTeX
// We do a simple approach: replace the display_modified portion in modified_text
const newDisplayText = ta.value;
// If the display was different from original display, mark as user-edited
if (newDisplayText !== (edit.display_modified || edit.modified_text)) {
// Simple heuristic: rebuild the LaTeX by replacing the readable content
// while preserving LaTeX structure
state.editModifiedTexts[idx] = rebuildLatex(
edit.modified_text,
edit.display_modified || edit.modified_text,
newDisplayText
);
}
});
});
}
/**
* Rebuild LaTeX text by replacing the display portion.
* Simple approach: if the original LaTeX has known patterns, replace them.
* Fallback: just use the new text as-is (losing LaTeX formatting).
*/
function rebuildLatex(originalLatex, originalDisplay, newDisplay) {
// Try to preserve LaTeX structure by doing a text replacement
// First check if the display text is literally in the LaTeX (common case)
if (originalLatex.includes(originalDisplay)) {
return originalLatex.replace(originalDisplay, newDisplay);
}
// Fallback: return the new display text with basic LaTeX wrapping if item
if (originalLatex.trim().startsWith('\\item')) {
return '\\item ' + newDisplay;
}
return newDisplay;
}
// ─── Init ───
function init() {
// File handling
els.btnBrowse.addEventListener('click', () => els.fileInput.click());
els.fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
els.dropZone.addEventListener('dragover', (e) => { e.preventDefault(); els.dropZone.classList.add('dragover'); });
els.dropZone.addEventListener('dragleave', () => els.dropZone.classList.remove('dragover'));
els.dropZone.addEventListener('drop', (e) => {
e.preventDefault();
els.dropZone.classList.remove('dragover');
handleFile(e.dataTransfer.files[0]);
});
els.dropZone.addEventListener('click', () => els.fileInput.click());
els.btnRemoveFile.addEventListener('click', () => {
state.resumeContent = '';
state.pdfSessionId = null;
els.fileInput.value = '';
els.dropZone.classList.remove('hidden');
els.resumeFileInfo.classList.add('hidden');
els.pdfCanvas.classList.add('hidden');
els.pdfPlaceholder.classList.remove('hidden');
updateAnalyzeButton();
});
els.jdInput.addEventListener('input', updateAnalyzeButton);
// Load sample
els.btnLoadSample.addEventListener('click', async () => {
try {
const resp = await fetch('/sample/sample_resume.tex');
if (resp.ok) state.resumeContent = await resp.text();
} catch {
showToast('Could not load sample.');
return;
}
els.jdInput.value = SAMPLE_JD;
els.dropZone.classList.add('hidden');
els.resumeFileInfo.classList.remove('hidden');
els.fileName.textContent = 'sample_resume.tex';
updateAnalyzeButton();
await compileAndPreview();
});
// Analyze
els.btnAnalyze.addEventListener('click', analyzeResume);
// Review controls
els.btnSelectAll.addEventListener('click', () => {
Object.keys(state.editSelections).forEach(k => { state.editSelections[k] = true; });
els.editsContainer.querySelectorAll('.edit-card').forEach(c => c.classList.add('selected'));
updateSelectedCount();
});
els.btnDeselectAll.addEventListener('click', () => {
Object.keys(state.editSelections).forEach(k => { state.editSelections[k] = false; });
els.editsContainer.querySelectorAll('.edit-card').forEach(c => c.classList.remove('selected'));
updateSelectedCount();
});
els.btnBackInput.addEventListener('click', () => switchStage('input'));
els.btnApply.addEventListener('click', applyEdits);
// Export
els.btnDownloadPdf.addEventListener('click', () => {
if (state.exportSessionId) {
const a = document.createElement('a');
const dlName = state.fileName.replace(/\.tex$/, '_tailored.pdf');
a.href = `/api/pdf/${state.exportSessionId}?download=true&filename=${encodeURIComponent(dlName)}`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
});
els.btnDownloadTex.addEventListener('click', () => {
const blob = new Blob([state.modifiedContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = state.fileName.replace(/\.tex$/, '_tailored.tex');
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
els.btnCopyTex.addEventListener('click', () => {
navigator.clipboard.writeText(state.modifiedContent).then(() => {
const orig = els.btnCopyTex.innerHTML;
els.btnCopyTex.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><polyline points="20,6 9,17 4,12"/></svg> Copied!';
setTimeout(() => { els.btnCopyTex.innerHTML = orig; }, 2000);
});
});
els.btnStartOver.addEventListener('click', () => {
state.analysis = null;
state.editSelections = {};
state.editModifiedTexts = {};
state.modifiedContent = '';
state.exportSessionId = null;
switchStage('input');
});
// ─── V2.1 New Features ───
els.btnIterateAi.addEventListener('click', () => {
// Take the currently tailored resume and start over with it
state.resumeContent = state.modifiedContent;
state.fileName = state.fileName.replace(/\.tex$/, '_tailored.tex');
// Reset state
state.analysis = null;
state.editSelections = {};
state.editModifiedTexts = {};
state.modifiedContent = '';
state.pdfSessionId = state.exportSessionId; // Reuse the compiled PDF session
state.exportSessionId = null;
// Update UI
els.fileInput.value = ''; // clear physical file input
els.fileName.textContent = state.fileName;
els.dropZone.classList.add('hidden');
els.resumeFileInfo.classList.remove('hidden');
switchStage('input');
updateAnalyzeButton();
// Automatically preview the new state
if (state.pdfSessionId) {
renderPdf(state.pdfSessionId, els.pdfCanvas);
}
});
els.btnEditSource.addEventListener('click', () => {
els.mainExportCard.classList.add('hidden');
els.manualEditCard.classList.remove('hidden');
els.btnCompilePreview.classList.remove('hidden');
$('#export-pdf-title').classList.add('hidden');
$('#export-success-badge').classList.add('hidden');
els.manualLatexEditor.value = state.modifiedContent;
});
els.btnDoneEditing.addEventListener('click', () => {
els.manualEditCard.classList.add('hidden');
els.mainExportCard.classList.remove('hidden');
els.btnCompilePreview.classList.add('hidden');
$('#export-pdf-title').classList.remove('hidden');
$('#export-success-badge').classList.remove('hidden');
});
els.btnCompilePreview.addEventListener('click', async () => {
const newLatex = els.manualLatexEditor.value;
els.btnCompilePreview.disabled = true;
els.btnCompilePreview.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" class="spin"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg> Compiling...';
try {
const newSessionId = await compilePdf(newLatex);
state.exportSessionId = newSessionId;
state.modifiedContent = newLatex;
await renderPdf(state.exportSessionId, els.exportPdfCanvas);
showToast('PDF Updated successfully!');
} catch (err) {
showToast('Compilation error: ' + err.message);
} finally {
els.btnCompilePreview.disabled = false;
els.btnCompilePreview.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polygon points="5 3 19 12 5 21 5 3"/></svg> Compile & Preview';
}
});
// Toast
els.toastClose.addEventListener('click', () => els.toast.classList.add('hidden'));
// API Key modal
const btnChangeKey = $('#btn-change-key');
if (btnChangeKey) {
btnChangeKey.addEventListener('click', () => {
els.keyModal.classList.remove('hidden');
els.apiKeyInput.focus();
});
}
const btnCloseModal = $('#btn-close-modal');
if (btnCloseModal) {
btnCloseModal.addEventListener('click', () => {
els.keyModal.classList.add('hidden');
});
}
els.btnSaveKey.addEventListener('click', async () => {
const key = els.apiKeyInput.value.trim();
if (!key) { els.apiKeyInput.style.borderColor = '#ef4444'; return; }
els.btnSaveKey.disabled = true;
els.btnSaveKey.textContent = 'Saving...';
if (await saveApiKey(key)) {
els.keyModal.classList.add('hidden');
els.apiKeyInput.value = '';
// Only auto-analyze if we have both inputs, otherwise just save
if (!els.btnAnalyze.disabled && state.resumeContent && els.jdInput.value.trim()) {
analyzeResume();
} else {
showToast('API key updated.');
}
} else {
showToast('Failed to save API key.');
}
els.btnSaveKey.disabled = false;
els.btnSaveKey.textContent = 'Save & Continue';
});
els.apiKeyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') els.btnSaveKey.click();
els.apiKeyInput.style.borderColor = '';
});
}
document.addEventListener('DOMContentLoaded', init);
})();