feat: CV Application - AI-powered CV management, parsing, matching, and generation
This commit is contained in:
659
static/app.js
Normal file
659
static/app.js
Normal file
@@ -0,0 +1,659 @@
|
||||
// CV Application - Frontend JavaScript
|
||||
const API = '';
|
||||
let currentChatId = null;
|
||||
|
||||
// ============================================================
|
||||
// NAVIGATION
|
||||
// ============================================================
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
const page = link.dataset.page;
|
||||
document.getElementById('page-' + page).classList.add('active');
|
||||
// Load data for page
|
||||
if (page === 'dashboard') loadDashboard();
|
||||
if (page === 'candidates') loadCandidates();
|
||||
if (page === 'templates') loadTemplates();
|
||||
if (page === 'requirements') loadRequirements();
|
||||
if (page === 'generated') loadGeneratedCVs();
|
||||
if (page === 'chat') loadChat();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// API helpers
|
||||
// ============================================================
|
||||
async function api(path, method = 'GET', body = null) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const resp = await fetch(API + path, opts);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.text();
|
||||
throw new Error(err);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function toast(msg, type = 'success') {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DASHBOARD
|
||||
// ============================================================
|
||||
async function loadDashboard() {
|
||||
const stats = await api('/api/stats');
|
||||
const grid = document.getElementById('stats-grid');
|
||||
grid.innerHTML = `
|
||||
<div class="stat-card"><div class="stat-value">${stats.total_candidates}</div><div class="stat-label">Total Candidates</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${stats.parsed_candidates}</div><div class="stat-label">Parsed CVs</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${stats.unique_skills}</div><div class="stat-label">Unique Skills</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${stats.total_templates}</div><div class="stat-label">Templates</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${stats.total_requirements}</div><div class="stat-label">Requirements</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${stats.total_generated_cvs}</div><div class="stat-label">Generated CVs</div></div>
|
||||
`;
|
||||
// Recent candidates
|
||||
const data = await api('/api/candidates?limit=5');
|
||||
document.getElementById('recent-candidates').innerHTML = data.candidates.map(c => `
|
||||
<div class="skill-row">
|
||||
<span><strong>${c.first_name || ''} ${c.last_name || ''}</strong> ${c.email ? '· ' + c.email : ''}</span>
|
||||
<span class="badge badge-${c.parse_status === 'parsed' ? 'green' : c.parse_status === 'error' ? 'red' : 'orange'}">${c.parse_status}</span>
|
||||
</div>
|
||||
`).join('') || '<p class="text-muted">No candidates yet</p>';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CANDIDATES
|
||||
// ============================================================
|
||||
async function loadCandidates(search = '') {
|
||||
const data = await api('/api/candidates' + (search ? '?search=' + encodeURIComponent(search) : ''));
|
||||
const list = document.getElementById('candidates-list');
|
||||
if (!data.candidates.length) {
|
||||
list.innerHTML = '<p class="text-muted">No candidates found. Upload some CVs to get started.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.candidates.map(c => `
|
||||
<div class="card" style="cursor:pointer" onclick="viewCandidate('${c.id}')">
|
||||
<div class="flex-between">
|
||||
<div>
|
||||
<strong>${c.first_name || ''} ${c.last_name || ''}</strong>
|
||||
${c.email ? '· ' + c.email : ''}
|
||||
<div class="text-muted text-sm mt-8">${(c.preview || '').substring(0, 100)}...</div>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<span class="badge badge-${c.parse_status === 'parsed' ? 'green' : c.parse_status === 'error' ? 'red' : 'orange'}">${c.parse_status}</span>
|
||||
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();deleteCandidate('${c.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function searchCandidates() {
|
||||
const q = document.getElementById('search-input').value;
|
||||
loadCandidates(q);
|
||||
}
|
||||
|
||||
document.getElementById('search-input')?.addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') searchCandidates();
|
||||
});
|
||||
|
||||
async function viewCandidate(id) {
|
||||
const data = await api('/api/candidates/' + id);
|
||||
const c = data.candidate;
|
||||
const body = `
|
||||
<div style="margin-bottom:20px">
|
||||
<h2>${c.first_name || ''} ${c.last_name || ''}</h2>
|
||||
${c.email ? '<div class="text-muted">' + c.email + '</div>' : ''}
|
||||
${c.phone ? '<div class="text-muted">' + c.phone + '</div>' : ''}
|
||||
${c.linkedin ? '<div class="text-muted">LinkedIn: ' + c.linkedin + '</div>' : ''}
|
||||
</div>
|
||||
${c.summary ? '<div class="card"><h3>Summary</h3><p>' + c.summary + '</p></div>' : ''}
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">Skills (${data.skills.length})</h3>
|
||||
${data.skills.map(s => `
|
||||
<div class="skill-row">
|
||||
<div>
|
||||
<span class="skill-name">${s.skill_name}</span>
|
||||
<span class="skill-meta"> · ${s.skill_category || 'General'} · ${s.proficiency || 'N/A'}</span>
|
||||
</div>
|
||||
<span class="badge badge-blue">${s.years_experience || 0} yrs</span>
|
||||
</div>
|
||||
`).join('') || '<p class="text-muted">No skills extracted</p>'}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">Experience (${data.experience.length})</h3>
|
||||
${data.experience.map(e => `
|
||||
<div style="margin-bottom:16px">
|
||||
<strong>${e.position || ''}</strong> at ${e.company || ''}
|
||||
<div class="text-muted text-sm">${formatDate(e.start_date)} — ${e.end_date ? formatDate(e.end_date) : 'Present'}</div>
|
||||
${e.description ? '<p class="text-sm mt-8">' + e.description + '</p>' : ''}
|
||||
</div>
|
||||
`).join('') || '<p class="text-muted">No experience extracted</p>'}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">Education (${data.education.length})</h3>
|
||||
${data.education.map(e => `
|
||||
<div style="margin-bottom:12px">
|
||||
<strong>${e.degree || ''}</strong> ${e.field_of_study ? 'in ' + e.field_of_study : ''}
|
||||
<div class="text-muted text-sm">${e.institution || ''}</div>
|
||||
</div>
|
||||
`).join('') || '<p class="text-muted">No education extracted</p>'}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">Raw CV Text</h3>
|
||||
<div style="max-height:400px;overflow-y:auto;background:var(--bg-input);padding:12px;border-radius:6px;white-space:pre-wrap;font-size:13px">${c.raw_cv_text || 'No raw text available'}</div>
|
||||
</div>
|
||||
`;
|
||||
showModal(body, 'Candidate Details');
|
||||
}
|
||||
|
||||
async function deleteCandidate(id) {
|
||||
if (!confirm('Delete this candidate and all related data?')) return;
|
||||
await api('/api/candidates/' + id, 'DELETE');
|
||||
toast('Candidate deleted');
|
||||
loadCandidates();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UPLOAD
|
||||
// ============================================================
|
||||
const uploadZone = document.getElementById('upload-zone');
|
||||
uploadZone?.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||||
uploadZone?.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
||||
uploadZone?.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
async function uploadFile(file) {
|
||||
if (!file) return;
|
||||
const status = document.getElementById('upload-status');
|
||||
status.innerHTML = '<div class="loading"><span class="spinner"></span> Uploading and parsing with AI... this may take 30-60 seconds</div>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(API + '/api/candidates/upload', { method: 'POST', body: formData });
|
||||
const data = await resp.json();
|
||||
if (data.status === 'parsed') {
|
||||
status.innerHTML = `<div class="toast success" style="position:relative;bottom:0;right:0">
|
||||
<strong>Parsed successfully!</strong><br>
|
||||
Name: ${data.parsed_data.first_name || ''} ${data.parsed_data.last_name || ''}<br>
|
||||
Skills: ${data.parsed_data.skills?.length || 0} | Experience: ${data.parsed_data.experience?.length || 0} | Education: ${data.parsed_data.education?.length || 0}
|
||||
</div>`;
|
||||
toast('CV uploaded and parsed successfully');
|
||||
} else {
|
||||
status.innerHTML = `<div class="toast error" style="position:relative;bottom:0;right:0">Parse error: ${data.error}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
status.innerHTML = `<div class="toast error" style="position:relative;bottom:0;right:0">Upload failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TEMPLATES
|
||||
// ============================================================
|
||||
async function loadTemplates() {
|
||||
const data = await api('/api/templates');
|
||||
const list = document.getElementById('templates-list');
|
||||
if (!data.templates.length) {
|
||||
list.innerHTML = '<p class="text-muted">No templates yet. Create one manually or generate with AI.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.templates.map(t => `
|
||||
<div class="card">
|
||||
<div class="flex-between">
|
||||
<div>
|
||||
<strong>${t.name}</strong>
|
||||
<span class="badge badge-${t.created_by === 'ai' ? 'purple' : 'blue'}">${t.created_by}</span>
|
||||
<div class="text-muted text-sm mt-8">${t.description || ''}</div>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-sm btn-outline" onclick="editTemplate('${t.id}')">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteTemplate('${t.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showTemplateModal() {
|
||||
const body = `
|
||||
<div class="form-group">
|
||||
<label>Template Name</label>
|
||||
<input type="text" id="tmpl-name" placeholder="e.g. Standard Tech CV">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea id="tmpl-desc" placeholder="Brief description of this template"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Template Structure (JSON)</label>
|
||||
<textarea id="tmpl-structure" style="min-height:300px;font-family:monospace" placeholder='{"sections":[{"name":"Header","type":"header","order":1,"fields":["name","email","phone"]},{"name":"Summary","type":"summary","order":2},{"name":"Experience","type":"experience","order":3,"styling":{"show_years":true}},{"name":"Skills","type":"skills","order":4,"styling":{"group_by_category":true}},{"name":"Education","type":"education","order":5}],"styling":"font-family: Arial;"}'></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>CSS Styling (optional)</label>
|
||||
<textarea id="tmpl-styling" placeholder="Additional CSS for the template"></textarea>
|
||||
</div>
|
||||
<button class="btn" onclick="createTemplate()">Create Template</button>
|
||||
`;
|
||||
showModal(body, 'Create Template');
|
||||
}
|
||||
|
||||
function showTemplateGenModal() {
|
||||
const body = `
|
||||
<div class="form-group">
|
||||
<label>Template Name</label>
|
||||
<input type="text" id="tmpl-gen-name" placeholder="e.g. Senior Developer CV">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Describe the template you want</label>
|
||||
<textarea id="tmpl-gen-desc" style="min-height:120px" placeholder="e.g. A modern CV template for software engineers with emphasis on technical skills, project highlights, and cloud certifications. Dark theme. Group skills by category. Show years of experience for each skill."></textarea>
|
||||
</div>
|
||||
<button class="btn" onclick="generateTemplate()">Generate with AI</button>
|
||||
`;
|
||||
showModal(body, 'Generate Template with AI');
|
||||
}
|
||||
|
||||
async function createTemplate() {
|
||||
const structure = JSON.parse(document.getElementById('tmpl-structure').value || '{}');
|
||||
await api('/api/templates', 'POST', {
|
||||
name: document.getElementById('tmpl-name').value,
|
||||
description: document.getElementById('tmpl-desc').value,
|
||||
template_structure: structure,
|
||||
styling: document.getElementById('tmpl-styling').value,
|
||||
created_by: 'manual'
|
||||
});
|
||||
closeModal();
|
||||
toast('Template created');
|
||||
loadTemplates();
|
||||
}
|
||||
|
||||
async function generateTemplate() {
|
||||
const desc = document.getElementById('tmpl-gen-desc').value;
|
||||
if (!desc) { toast('Description required', 'error'); return; }
|
||||
document.querySelector('.modal button').textContent = 'Generating...';
|
||||
document.querySelector('.modal button').disabled = true;
|
||||
try {
|
||||
await api('/api/templates/generate', 'POST', {
|
||||
name: document.getElementById('tmpl-gen-name').value || 'AI Generated Template',
|
||||
description: desc
|
||||
});
|
||||
closeModal();
|
||||
toast('Template generated with AI');
|
||||
loadTemplates();
|
||||
} catch(e) {
|
||||
toast('Generation failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate(id) {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
await api('/api/templates/' + id, 'DELETE');
|
||||
toast('Template deleted');
|
||||
loadTemplates();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// REQUIREMENTS
|
||||
// ============================================================
|
||||
async function loadRequirements() {
|
||||
const data = await api('/api/requirements');
|
||||
const list = document.getElementById('requirements-list');
|
||||
if (!data.requirements.length) {
|
||||
list.innerHTML = '<p class="text-muted">No requirements yet. Create one to start matching candidates.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.requirements.map(r => `
|
||||
<div class="card">
|
||||
<div class="flex-between">
|
||||
<div>
|
||||
<strong>${r.title}</strong>
|
||||
${r.customer_name ? '<span class="text-muted"> · ' + r.customer_name + '</span>' : ''}
|
||||
<div class="text-muted text-sm mt-8">${r.description || ''}</div>
|
||||
<div class="text-sm mt-8">${(r.requirements || []).length} position(s) requested</div>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-sm" onclick="viewRequirement('${r.id}')">View</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="matchRequirement('${r.id}')">Match</button>
|
||||
<button class="btn btn-sm btn-green" onclick="generateForRequirement('${r.id}')">Generate CVs</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteRequirement('${r.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showRequirementModal() {
|
||||
const body = `
|
||||
<div class="form-group">
|
||||
<label>Request Title</label>
|
||||
<input type="text" id="req-title" placeholder="e.g. Q3 2026 Staff Augmentation">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Customer Name</label>
|
||||
<input type="text" id="req-customer" placeholder="Client name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea id="req-desc" placeholder="Overall description of the requirement"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Positions Required (JSON)</label>
|
||||
<p class="text-muted text-sm mb-16">Define each position with required skills and years of experience.</p>
|
||||
<textarea id="req-positions" style="min-height:300px;font-family:monospace;font-size:13px" placeholder='[
|
||||
{
|
||||
"position_title": "Senior .NET Developer",
|
||||
"quantity": 2,
|
||||
"experience_years": 5,
|
||||
"skills": [
|
||||
{"name": "C#", "min_years": 4, "required": true},
|
||||
{"name": ".NET Core", "min_years": 3, "required": true},
|
||||
{"name": "Azure", "min_years": 2, "required": false}
|
||||
],
|
||||
"education": "Bachelor degree in Computer Science or related",
|
||||
"description": "Backend developer with cloud experience"
|
||||
},
|
||||
{
|
||||
"position_title": "Frontend Developer",
|
||||
"quantity": 1,
|
||||
"experience_years": 3,
|
||||
"skills": [
|
||||
{"name": "React", "min_years": 2, "required": true},
|
||||
{"name": "TypeScript", "min_years": 2, "required": true}
|
||||
]
|
||||
}
|
||||
]'></textarea>
|
||||
</div>
|
||||
<button class="btn" onclick="createRequirement()">Create Requirement</button>
|
||||
`;
|
||||
showModal(body, 'Create Requirement Request');
|
||||
}
|
||||
|
||||
async function createRequirement() {
|
||||
const positions = JSON.parse(document.getElementById('req-positions').value || '[]');
|
||||
await api('/api/requirements', 'POST', {
|
||||
title: document.getElementById('req-title').value,
|
||||
customer_name: document.getElementById('req-customer').value,
|
||||
description: document.getElementById('req-desc').value,
|
||||
requirements: positions
|
||||
});
|
||||
closeModal();
|
||||
toast('Requirement created');
|
||||
loadRequirements();
|
||||
}
|
||||
|
||||
async function viewRequirement(id) {
|
||||
const data = await api('/api/requirements/' + id);
|
||||
const positions = data.requirements || [];
|
||||
const body = `
|
||||
<h2>${data.title}</h2>
|
||||
${data.customer_name ? '<p class="text-muted">Customer: ' + data.customer_name + '</p>' : ''}
|
||||
${data.description ? '<p class="mt-8">' + data.description + '</p>' : ''}
|
||||
<h3 style="margin-top:20px">Positions (${positions.length})</h3>
|
||||
${positions.map((p, i) => `
|
||||
<div class="req-position mt-16">
|
||||
<strong>${p.position_title || 'Position ' + (i+1)}</strong>
|
||||
${p.quantity ? '<span class="badge badge-blue">Qty: ' + p.quantity + '</span>' : ''}
|
||||
${p.experience_years ? '<span class="badge badge-orange">Min ' + p.experience_years + ' yrs</span>' : ''}
|
||||
${p.description ? '<p class="text-sm mt-8">' + p.description + '</p>' : ''}
|
||||
${(p.skills || []).map(s => `
|
||||
<div class="text-sm" style="margin:4px 0">
|
||||
${s.required ? '🔒' : '⚪'} ${s.name} ${s.min_years ? '(min ' + s.min_years + ' yrs)' : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
${data.generated_cvs && data.generated_cvs.length ? `
|
||||
<h3 style="margin-top:20px">Generated CVs (${data.generated_cvs.length})</h3>
|
||||
${data.generated_cvs.map(g => `
|
||||
<div class="skill-row">
|
||||
<span>${g.position_title || 'N/A'} — ${g.first_name || ''} ${g.last_name || ''}</span>
|
||||
<span class="badge badge-${g.status === 'approved' ? 'green' : g.status === 'draft' ? 'orange' : 'red'}">${g.status}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
` : ''}
|
||||
`;
|
||||
showModal(body, 'Requirement Details');
|
||||
}
|
||||
|
||||
async function matchRequirement(id) {
|
||||
const list = document.getElementById('requirements-list');
|
||||
// Show loading
|
||||
const oldHTML = list.innerHTML;
|
||||
list.innerHTML = '<div class="loading"><span class="spinner"></span> Matching candidates with AI... this may take a while</div>';
|
||||
|
||||
try {
|
||||
const data = await api('/api/requirements/' + id + '/match', 'POST', {});
|
||||
const results = data.matches.filter(m => !m.error);
|
||||
results.sort((a, b) => (b.match_score || 0) - (a.match_score || 0));
|
||||
|
||||
const body = `
|
||||
<h2>Match Results</h2>
|
||||
<p class="text-muted">${results.length} candidates matched</p>
|
||||
<div style="margin-top:16px">
|
||||
${results.map(m => `
|
||||
<div class="match-item">
|
||||
<div>
|
||||
<strong>${m.candidate_name || 'Unknown'}</strong>
|
||||
<div class="text-sm text-muted">${m.position_title || ''}</div>
|
||||
<div class="text-sm">
|
||||
Match: ${m.matching_skills?.join(', ') || 'none'} |
|
||||
Missing: ${m.missing_skills?.join(', ') || 'none'}
|
||||
</div>
|
||||
<div class="text-sm text-muted mt-8">${m.reasoning || ''}</div>
|
||||
</div>
|
||||
<div class="match-score" style="color: ${m.match_score >= 70 ? 'var(--green)' : m.match_score >= 40 ? 'var(--orange)' : 'var(--red)'}">${m.match_score || 0}%</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
showModal(body, 'Match Results');
|
||||
list.innerHTML = oldHTML;
|
||||
} catch(e) {
|
||||
list.innerHTML = oldHTML;
|
||||
toast('Match failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function generateForRequirement(id) {
|
||||
if (!confirm('Generate tailored CVs for all parsed candidates? This may take several minutes.')) return;
|
||||
const list = document.getElementById('requirements-list');
|
||||
const oldHTML = list.innerHTML;
|
||||
list.innerHTML = '<div class="loading"><span class="spinner"></span> Generating CVs with AI... this may take several minutes</div>';
|
||||
|
||||
try {
|
||||
const data = await api('/api/requirements/' + id + '/generate', 'POST', {});
|
||||
closeModal();
|
||||
list.innerHTML = oldHTML;
|
||||
toast(`Generated ${data.generated.length} CVs`);
|
||||
loadGeneratedCVs();
|
||||
document.querySelector('[data-page="generated"]').click();
|
||||
} catch(e) {
|
||||
list.innerHTML = oldHTML;
|
||||
toast('Generation failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRequirement(id) {
|
||||
if (!confirm('Delete this requirement and all generated CVs?')) return;
|
||||
await api('/api/requirements/' + id, 'DELETE');
|
||||
toast('Requirement deleted');
|
||||
loadRequirements();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GENERATED CVs
|
||||
// ============================================================
|
||||
async function loadGeneratedCVs() {
|
||||
const data = await api('/api/generated-cvs');
|
||||
const list = document.getElementById('generated-list');
|
||||
if (!data.generated_cvs.length) {
|
||||
list.innerHTML = '<p class="text-muted">No generated CVs yet. Match and generate from a requirement request.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.generated_cvs.map(g => `
|
||||
<div class="card">
|
||||
<div class="flex-between">
|
||||
<div>
|
||||
<strong>${g.first_name || ''} ${g.last_name || ''}</strong>
|
||||
<span class="text-muted"> · ${g.position_title || 'N/A'}</span>
|
||||
<div class="text-sm text-muted mt-8">Generated: ${formatDate(g.generation_date)} · Score: ${g.match_score || 0}%</div>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<span class="badge badge-${g.status === 'approved' ? 'green' : g.status === 'draft' ? 'orange' : g.status === 'reviewed' ? 'blue' : 'red'}">${g.status}</span>
|
||||
<button class="btn btn-sm btn-outline" onclick="viewGeneratedCV('${g.id}')">View</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteGeneratedCV('${g.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function viewGeneratedCV(id) {
|
||||
const data = await api('/api/generated-cvs/' + id);
|
||||
const content = data.edited_content || data.generated_content || '';
|
||||
const body = `
|
||||
<h2>${data.first_name || ''} ${data.last_name || ''} — ${data.position_title || ''}</h2>
|
||||
<p class="text-muted">Generated: ${formatDate(data.generation_date)} · Score: ${data.match_score || 0}% · Status: ${data.status}</p>
|
||||
${data.match_reasoning ? '<p class="text-sm mt-8"><strong>Changes made:</strong> ' + data.match_reasoning + '</p>' : ''}
|
||||
<div class="tabs mt-16">
|
||||
<div class="tab active" onclick="switchTab(this, 'cv-preview-tab')">Preview</div>
|
||||
<div class="tab" onclick="switchTab(this, 'cv-edit-tab')">Edit</div>
|
||||
</div>
|
||||
<div id="cv-preview-tab">
|
||||
<div class="cv-preview mt-16">${content}</div>
|
||||
</div>
|
||||
<div id="cv-edit-tab" style="display:none">
|
||||
<textarea id="cv-edit-content" style="min-height:500px;font-family:monospace;font-size:13px">${content}</textarea>
|
||||
<div class="flex gap-8 mt-16">
|
||||
<button class="btn btn-green" onclick="saveGeneratedCV('${id}')">Save</button>
|
||||
<select id="cv-status-select" class="btn btn-outline">
|
||||
<option value="draft" ${data.status === 'draft' ? 'selected' : ''}>Draft</option>
|
||||
<option value="reviewed" ${data.status === 'reviewed' ? 'selected' : ''}>Reviewed</option>
|
||||
<option value="approved" ${data.status === 'approved' ? 'selected' : ''}>Approved</option>
|
||||
<option value="rejected" ${data.status === 'rejected' ? 'selected' : ''}>Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
showModal(body, 'Generated CV', 'large');
|
||||
}
|
||||
|
||||
function switchTab(el, tabId) {
|
||||
el.parentElement.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById('cv-preview-tab').style.display = tabId === 'cv-preview-tab' ? 'block' : 'none';
|
||||
document.getElementById('cv-edit-tab').style.display = tabId === 'cv-edit-tab' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function saveGeneratedCV(id) {
|
||||
const content = document.getElementById('cv-edit-content').value;
|
||||
const status = document.getElementById('cv-status-select').value;
|
||||
await api('/api/generated-cvs/' + id, 'PUT', { edited_content: content, status });
|
||||
toast('CV saved');
|
||||
closeModal();
|
||||
loadGeneratedCVs();
|
||||
}
|
||||
|
||||
async function deleteGeneratedCV(id) {
|
||||
if (!confirm('Delete this generated CV?')) return;
|
||||
await api('/api/generated-cvs/' + id, 'DELETE');
|
||||
toast('Generated CV deleted');
|
||||
loadGeneratedCVs();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHAT
|
||||
// ============================================================
|
||||
async function loadChat() {
|
||||
// Just prepare the chat interface
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const msg = input.value.trim();
|
||||
if (!msg) return;
|
||||
input.value = '';
|
||||
|
||||
const messagesEl = document.getElementById('chat-messages');
|
||||
// Clear placeholder
|
||||
if (messagesEl.querySelector('.text-muted')) messagesEl.innerHTML = '';
|
||||
|
||||
// Add user message
|
||||
messagesEl.innerHTML += `<div class="chat-msg user"><div class="bubble">${escapeHtml(msg)}</div></div>`;
|
||||
messagesEl.innerHTML += `<div class="chat-msg assistant" id="chat-loading"><div class="bubble loading"><span class="spinner"></span> Thinking...</div></div>`;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
|
||||
try {
|
||||
const data = await api('/api/chat', 'POST', { message: msg, conversation_id: currentChatId });
|
||||
currentChatId = data.conversation_id;
|
||||
document.getElementById('chat-loading').remove();
|
||||
messagesEl.innerHTML += `<div class="chat-msg assistant"><div class="bubble">${escapeHtml(data.response)}</div></div>`;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
} catch(e) {
|
||||
document.getElementById('chat-loading').remove();
|
||||
messagesEl.innerHTML += `<div class="chat-msg assistant"><div class="bubble" style="color:var(--red)">Error: ${e.message}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chat-input')?.addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendChat();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// MODAL + UTILITIES
|
||||
// ============================================================
|
||||
function showModal(body, title, size = '') {
|
||||
const overlay = document.getElementById('modal-overlay');
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="${size === 'large' ? 'max-width:1000px' : ''}">
|
||||
<div class="modal-header">
|
||||
<h2>${title}</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div>${body}</div>
|
||||
</div>
|
||||
`;
|
||||
overlay.classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-overlay').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('modal-overlay')?.addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('modal-overlay')) closeModal();
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
try {
|
||||
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
} catch { return d; }
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadDashboard();
|
||||
109
static/index.html
Normal file
109
static/index.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CV Application</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>CV Application</h1>
|
||||
<p>AI-Powered CV Management</p>
|
||||
</div>
|
||||
<ul class="nav">
|
||||
<li><a href="#" class="nav-link active" data-page="dashboard">Dashboard</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="candidates">Candidates</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="upload">Upload CV</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="templates">Templates</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="requirements">Requirements</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="generated">Generated CVs</a></li>
|
||||
<li><a href="#" class="nav-link" data-page="chat">AI Chat</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main">
|
||||
<!-- DASHBOARD -->
|
||||
<div id="page-dashboard" class="page active">
|
||||
<h2 style="margin-bottom:20px">Dashboard</h2>
|
||||
<div class="stats-grid" id="stats-grid"></div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Recent Candidates</span></div>
|
||||
<div id="recent-candidates"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CANDIDATES -->
|
||||
<div id="page-candidates" class="page">
|
||||
<h2 style="margin-bottom:20px">Candidates</h2>
|
||||
<div class="flex mb-16 gap-8">
|
||||
<input type="text" id="search-input" placeholder="Search by name, email, or keyword..." style="flex:1">
|
||||
<button class="btn" onclick="searchCandidates()">Search</button>
|
||||
</div>
|
||||
<div id="candidates-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- UPLOAD -->
|
||||
<div id="page-upload" class="page">
|
||||
<h2 style="margin-bottom:20px">Upload CV</h2>
|
||||
<div class="card">
|
||||
<div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
|
||||
<p style="font-size:18px; margin-bottom:8px">Drop CV file here or click to browse</p>
|
||||
<p class="text-muted text-sm">Supports PDF, DOCX, TXT, DOC</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept=".pdf,.docx,.doc,.txt,.rtf" style="display:none" onchange="uploadFile(this.files[0])">
|
||||
<div id="upload-status" style="margin-top:16px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TEMPLATES -->
|
||||
<div id="page-templates" class="page">
|
||||
<h2 style="margin-bottom:20px">CV Templates</h2>
|
||||
<div class="flex mb-16 gap-8">
|
||||
<button class="btn" onclick="showTemplateModal()">+ New Template</button>
|
||||
<button class="btn btn-outline" onclick="showTemplateGenModal()">Generate with AI</button>
|
||||
</div>
|
||||
<div id="templates-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- REQUIREMENTS -->
|
||||
<div id="page-requirements" class="page">
|
||||
<h2 style="margin-bottom:20px">Requirement Requests</h2>
|
||||
<div class="flex mb-16 gap-8">
|
||||
<button class="btn" onclick="showRequirementModal()">+ New Requirement</button>
|
||||
</div>
|
||||
<div id="requirements-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- GENERATED CVs -->
|
||||
<div id="page-generated" class="page">
|
||||
<h2 style="margin-bottom:20px">Generated CVs</h2>
|
||||
<div id="generated-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- CHAT -->
|
||||
<div id="page-chat" class="page">
|
||||
<h2 style="margin-bottom:20px">AI Chat Assistant</h2>
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="text-muted">Start a conversation with the AI assistant...</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input" placeholder="Type your message..." rows="1"></textarea>
|
||||
<button class="btn" onclick="sendChat()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div class="modal-overlay" id="modal-overlay"></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
383
static/style.css
Normal file
383
static/style.css
Normal file
@@ -0,0 +1,383 @@
|
||||
/* CV Application - Dark Theme CSS */
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg-card: #1a1d27;
|
||||
--bg-input: #22252e;
|
||||
--border: #2d303d;
|
||||
--text: #e0e0e8;
|
||||
--text-muted: #888897;
|
||||
--accent: #5b8def;
|
||||
--accent-hover: #4a7bd9;
|
||||
--green: #4caf50;
|
||||
--orange: #ff9800;
|
||||
--red: #ef5350;
|
||||
--purple: #ab47bc;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Layout */
|
||||
.app { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.sidebar-header p {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.nav { list-style: none; padding: 8px 0; }
|
||||
.nav li { padding: 0; }
|
||||
.nav a {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav a:hover {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav a.active {
|
||||
background: var(--bg-input);
|
||||
color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
max-width: 1400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-title { font-size: 16px; font-weight: 600; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 9px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn:hover { background: var(--accent-hover); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-outline:hover { background: var(--bg-input); border-color: var(--accent); }
|
||||
.btn-danger { background: var(--red); }
|
||||
.btn-danger:hover { background: #d32f2f; }
|
||||
.btn-green { background: var(--green); }
|
||||
.btn-green:hover { background: #388e3c; }
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
}
|
||||
.stat-value { font-size: 32px; font-weight: 700; color: var(--accent); }
|
||||
.stat-label { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
tr:hover { background: var(--bg-input); }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-green { background: rgba(76,175,80,0.15); color: var(--green); }
|
||||
.badge-orange { background: rgba(255,152,0,0.15); color: var(--orange); }
|
||||
.badge-red { background: rgba(239,83,80,0.15); color: var(--red); }
|
||||
.badge-blue { background: rgba(91,141,239,0.15); color: var(--accent); }
|
||||
.badge-purple { background: rgba(171,71,188,0.15); color: var(--purple); }
|
||||
|
||||
/* Chat */
|
||||
.chat-container { display: flex; flex-direction: column; height: calc(100vh - 120px); }
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chat-msg { margin-bottom: 12px; }
|
||||
.chat-msg.user { text-align: right; }
|
||||
.chat-msg .bubble {
|
||||
display: inline-block;
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.chat-msg.user .bubble { background: var(--accent); color: white; }
|
||||
.chat-msg.assistant .bubble { background: var(--bg-input); color: var(--text); }
|
||||
.chat-input { display: flex; gap: 8px; }
|
||||
.chat-input textarea { flex: 1; min-height: 44px; max-height: 120px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-overlay.active { display: block; }
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
/* Page sections */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
/* Skills display */
|
||||
.skill-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.skill-name { font-weight: 500; }
|
||||
.skill-meta { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* Generated CV preview */
|
||||
.cv-preview {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 40px;
|
||||
border-radius: var(--radius);
|
||||
font-family: 'Georgia', serif;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.cv-preview h1, .cv-preview h2, .cv-preview h3 { color: #222; }
|
||||
|
||||
/* Loading */
|
||||
.loading { color: var(--text-muted); font-style: italic; }
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* File upload */
|
||||
.upload-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.upload-zone:hover { border-color: var(--accent); }
|
||||
.upload-zone.dragover { border-color: var(--accent); background: var(--bg-input); }
|
||||
|
||||
/* Requirement editor */
|
||||
.req-position {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.req-position-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.req-skill-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.req-skill-row input { flex: 1; }
|
||||
.req-skill-row select { width: 120px; }
|
||||
|
||||
/* Match results */
|
||||
.match-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.match-score {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 20px;
|
||||
z-index: 2000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
font-size: 14px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toast.success { border-left: 3px solid var(--green); }
|
||||
.toast.error { border-left: 3px solid var(--red); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* Grid helpers */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||
.flex { display: flex; gap: 8px; align-items: center; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-sm { font-size: 13px; }
|
||||
.text-right { text-align: right; }
|
||||
.w-full { width: 100%; }
|
||||
.gap-8 { gap: 8px; }
|
||||
.gap-16 { gap: 16px; }
|
||||
Reference in New Issue
Block a user