Job Board — Resume Upload
A candidate-portal resume upload step with a drag-and-drop zone, a file chip that shows an animated upload progress bar, and a simulated parse that streams section checks before populating an editable preview. The preview auto-fills name, title, email, phone and location, renders removable skill pills with an add-on-Enter input, and lists prior roles with colored company logos. Remove, re-upload and sample actions all work, powered by dependency-free vanilla JavaScript.
MCP
Code
:root {
--brand: #2563eb;
--brand-d: #1d4ed8;
--brand-50: #eaf1ff;
--ink: #0f172a;
--ink-2: #475569;
--muted: #64748b;
--bg: #f6f8fb;
--surface: #ffffff;
--line: rgba(15, 23, 42, 0.1);
--line-2: rgba(15, 23, 42, 0.18);
--ok: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--new: #2563eb;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.05);
--sh-2: 0 8px 24px rgba(15, 23, 42, 0.08);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
font-size: 15px;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(900px 420px at 90% -10%, #e9f0ff 0%, transparent 60%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0;
}
.shell {
max-width: 1040px;
margin: 0 auto;
padding: 28px 20px 56px;
}
/* ---------- topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 40px; height: 40px;
border-radius: 12px;
color: #fff;
background: linear-gradient(160deg, var(--brand) 0%, var(--brand-d) 100%);
box-shadow: var(--sh-1);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-text strong { font-weight: 800; font-size: 16px; letter-spacing: -0.01em; }
.brand-text span { font-size: 12.5px; color: var(--muted); }
.stepper { display: flex; align-items: center; gap: 8px; }
.step {
position: relative;
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
padding: 5px 10px 5px 24px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line);
}
.step::before {
content: "";
position: absolute;
left: 9px; top: 50%;
transform: translateY(-50%);
width: 8px; height: 8px;
border-radius: 50%;
background: var(--line-2);
}
.step.done { color: var(--ok); }
.step.done::before { background: var(--ok); }
.step.current {
color: var(--brand-d);
background: var(--brand-50);
border-color: rgba(37, 99, 235, 0.28);
}
.step.current::before { background: var(--brand); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }
/* ---------- lede ---------- */
.lede { margin: 26px 0 18px; }
.lede h1 { margin: 0 0 6px; font-size: clamp(22px, 4vw, 28px); font-weight: 800; letter-spacing: -0.02em; }
.lede p { margin: 0; color: var(--ink-2); max-width: 62ch; }
/* ---------- grid ---------- */
.grid {
display: grid;
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
gap: 18px;
align-items: start;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--sh-1);
}
.panel-title {
margin: 0 0 14px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
/* ---------- dropzone ---------- */
.dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 4px;
padding: 30px 20px;
border: 2px dashed var(--line-2);
border-radius: var(--r-md);
background:
repeating-linear-gradient(45deg, transparent, transparent 12px, rgba(37,99,235,0.02) 12px, rgba(37,99,235,0.02) 24px),
#fbfcfe;
cursor: pointer;
transition: border-color .18s ease, background .18s ease, transform .18s ease, box-shadow .18s ease;
}
.dropzone:hover { border-color: var(--brand); }
.dropzone:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.16);
}
.dropzone.dragging {
border-color: var(--brand);
background: var(--brand-50);
transform: translateY(-2px);
box-shadow: var(--sh-2);
}
.dz-icon {
display: grid;
place-items: center;
width: 56px; height: 56px;
border-radius: 16px;
margin-bottom: 6px;
color: var(--brand-d);
background: var(--brand-50);
}
.dropzone.dragging .dz-icon { animation: bob .9s ease-in-out infinite; }
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
.dz-headline { margin: 2px 0 0; font-weight: 700; }
.dz-sub { margin: 0; color: var(--ink-2); }
.dz-hint { margin: 6px 0 0; font-size: 12.5px; color: var(--muted); }
.linkbtn {
border: 0; background: none; padding: 0;
font: inherit; font-weight: 700; color: var(--brand-d);
cursor: pointer; text-decoration: underline; text-underline-offset: 2px;
}
.linkbtn:hover { color: var(--brand); }
.sample-row {
display: flex; align-items: center; gap: 10px;
list-style: none; margin: 12px 0 0; padding: 0;
font-size: 13px; color: var(--muted);
}
.chip-ghost {
border: 1px solid var(--line-2);
background: var(--surface);
border-radius: 999px;
padding: 5px 12px;
font: inherit; font-size: 12.5px; font-weight: 600;
color: var(--ink-2); cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
}
.chip-ghost:hover { background: var(--brand-50); border-color: rgba(37,99,235,0.3); color: var(--brand-d); }
/* ---------- file chip ---------- */
.filechip {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 14px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: #fbfcfe;
animation: pop .25s ease;
}
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(.98); } to { opacity: 1; transform: none; } }
.fc-thumb {
flex: none;
display: grid; place-items: center;
width: 42px; height: 50px;
border-radius: 8px;
font-size: 10.5px; font-weight: 800; letter-spacing: .04em;
color: #fff;
background: linear-gradient(160deg, #ef4444, #b91c1c);
box-shadow: var(--sh-1);
}
.fc-body { flex: 1; min-width: 0; }
.fc-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.fc-name {
font-weight: 600; font-size: 14px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.fc-status { flex: none; font-size: 12px; font-weight: 600; color: var(--brand-d); }
.fc-status.done { color: var(--ok); }
.fc-meta { font-size: 12px; color: var(--muted); margin: 1px 0 8px; }
.progress {
height: 7px; border-radius: 999px;
background: rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.progress-bar {
display: block; height: 100%; width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--brand-d));
transition: width .12s linear;
}
.progress-bar.done { background: linear-gradient(90deg, #22c55e, var(--ok)); }
.fc-remove {
flex: none;
display: grid; place-items: center;
width: 30px; height: 30px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--muted); cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.fc-remove:hover { background: #fff1f1; color: var(--danger); border-color: rgba(220,38,38,0.3); }
.trust {
display: flex; align-items: center; gap: 8px;
margin-top: 14px;
font-size: 12.5px; color: var(--muted);
}
.trust-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px rgba(22,163,74,0.18); }
/* ---------- preview ---------- */
.pv-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.pv-head .panel-title { margin: 0; }
.badge-empty {
font-size: 11.5px; font-weight: 700;
padding: 4px 10px; border-radius: 999px;
color: var(--muted);
background: rgba(15,23,42,0.05);
border: 1px solid var(--line);
}
.badge-empty.parsing { color: var(--warn); background: #fff7ed; border-color: rgba(217,119,6,.25); }
.badge-empty.ready { color: var(--ok); background: #ecfdf3; border-color: rgba(22,163,74,.25); }
.pv-empty {
display: flex; flex-direction: column; align-items: center;
text-align: center; gap: 12px;
padding: 36px 18px;
color: var(--ink-2);
}
.pv-empty-icon {
display: grid; place-items: center;
width: 64px; height: 64px;
border-radius: 18px;
color: var(--muted);
background: rgba(15,23,42,0.04);
}
.pv-empty p { margin: 0; max-width: 38ch; font-size: 14px; }
/* parsing */
.pv-parsing { padding: 26px 8px; text-align: center; }
.parse-spinner {
width: 38px; height: 38px; margin: 0 auto 12px;
border-radius: 50%;
border: 3px solid var(--brand-50);
border-top-color: var(--brand);
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pv-parsing p { margin: 0 0 14px; font-weight: 600; color: var(--ink-2); }
.parse-checks { list-style: none; margin: 0 auto; padding: 0; max-width: 240px; text-align: left; }
.parse-checks li {
position: relative;
padding: 7px 0 7px 28px;
font-size: 13.5px; color: var(--muted);
border-bottom: 1px solid var(--line);
}
.parse-checks li:last-child { border-bottom: 0; }
.parse-checks li::before {
content: "";
position: absolute; left: 4px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid var(--line-2);
transition: all .2s;
}
.parse-checks li.done { color: var(--ink); }
.parse-checks li.done::before {
background: var(--ok); border-color: var(--ok);
box-shadow: inset 0 0 0 2px #fff, 0 0 0 0 var(--ok);
}
/* form */
.pv-form { animation: pop .3s ease; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field.span2 { grid-column: 1 / -1; }
.field > span, .field-label {
font-size: 12px; font-weight: 600; color: var(--ink-2);
}
.field-label { display: block; margin-bottom: 8px; }
.field-label em { font-style: normal; color: var(--brand-d); font-weight: 700; }
.field input, .skill-add input {
width: 100%;
font: inherit; font-size: 14px;
padding: 9px 11px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: #fbfcfe;
color: var(--ink);
transition: border-color .15s, box-shadow .15s, background .15s;
}
.field input:focus, .skill-add input:focus {
outline: none;
border-color: var(--brand);
background: #fff;
box-shadow: 0 0 0 4px rgba(37,99,235,0.14);
}
.skills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.skill {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 6px 5px 12px;
font-size: 12.5px; font-weight: 600;
color: var(--brand-d);
background: var(--brand-50);
border: 1px solid rgba(37,99,235,0.22);
border-radius: 999px;
animation: pop .2s ease;
}
.skill button {
display: grid; place-items: center;
width: 18px; height: 18px;
border: 0; border-radius: 50%;
background: rgba(37,99,235,0.16);
color: var(--brand-d); cursor: pointer;
font-size: 13px; line-height: 1;
}
.skill button:hover { background: var(--brand); color: #fff; }
.exp-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.exp-item {
display: flex; gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: #fbfcfe;
}
.exp-logo {
flex: none;
display: grid; place-items: center;
width: 38px; height: 38px;
border-radius: 9px;
font-weight: 800; font-size: 14px; color: #fff;
}
.exp-meta { min-width: 0; }
.exp-role { font-weight: 700; font-size: 14px; }
.exp-co { font-size: 13px; color: var(--ink-2); }
.exp-dates { font-size: 12px; color: var(--muted); margin-top: 2px; }
.pv-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.btn-primary, .btn-ghost {
font: inherit; font-weight: 700; font-size: 14px;
padding: 10px 18px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s, transform .08s, box-shadow .15s, border-color .15s;
}
.btn-primary {
border: 1px solid var(--brand-d);
color: #fff;
background: linear-gradient(160deg, var(--brand), var(--brand-d));
box-shadow: var(--sh-1);
}
.btn-primary:hover { background: var(--brand-d); }
.btn-primary:active { transform: translateY(1px); }
.btn-ghost {
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink-2);
}
.btn-ghost:hover { background: var(--bg); border-color: var(--ink-2); }
/* ---------- toast ---------- */
.toast-wrap {
position: fixed;
left: 50%; bottom: 22px;
transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px;
z-index: 50;
pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 9px;
padding: 11px 16px;
border-radius: 999px;
background: var(--ink);
color: #fff;
font-size: 13.5px; font-weight: 600;
box-shadow: var(--sh-2);
animation: toastIn .25s ease;
}
.toast::before {
content: "";
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok);
}
.toast.err::before { background: var(--danger); }
.toast.leaving { animation: toastOut .25s ease forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes toastOut { to { opacity: 0; transform: translateY(10px); } }
/* ---------- responsive ---------- */
@media (max-width: 820px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
.shell { padding: 20px 14px 48px; }
.stepper { width: 100%; justify-content: flex-start; }
.lede h1 { font-size: 22px; }
.field-grid { grid-template-columns: 1fr; }
.dropzone { padding: 26px 14px; }
.pv-actions { flex-direction: column-reverse; }
.btn-primary, .btn-ghost { width: 100%; }
}(function () {
"use strict";
// ---- elements ----
const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("fileInput");
const browseBtn = document.getElementById("browseBtn");
const fileChip = document.getElementById("fileChip");
const fcName = document.getElementById("fcName");
const fcMeta = document.getElementById("fcMeta");
const fcStatus = document.getElementById("fcStatus");
const fcBar = document.getElementById("fcBar");
const fcRemove = document.getElementById("fcRemove");
const progressEl = fileChip.querySelector(".progress");
const pvBadge = document.getElementById("pvBadge");
const pvEmpty = document.getElementById("pvEmpty");
const pvParsing = document.getElementById("pvParsing");
const pvForm = document.getElementById("pvForm");
const parseLabel = document.getElementById("parseLabel");
const parseChecks = pvParsing.querySelectorAll("[data-check]");
const fName = document.getElementById("fName");
const fTitle = document.getElementById("fTitle");
const fEmail = document.getElementById("fEmail");
const fPhone = document.getElementById("fPhone");
const fLocation = document.getElementById("fLocation");
const skillBox = document.getElementById("skillBox");
const skillInput = document.getElementById("skillInput");
const skillCount = document.getElementById("skillCount");
const expList = document.getElementById("expList");
const reuploadBtn = document.getElementById("reuploadBtn");
const toastWrap = document.getElementById("toastWrap");
let timers = [];
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function later(fn, ms) { const t = setTimeout(fn, ms); timers.push(t); return t; }
// ---- toast ----
function toast(msg, isErr) {
const el = document.createElement("div");
el.className = "toast" + (isErr ? " err" : "");
el.textContent = msg;
toastWrap.appendChild(el);
setTimeout(() => {
el.classList.add("leaving");
setTimeout(() => el.remove(), 240);
}, 2400);
}
// ---- fictional parsed profiles (keyed loosely by file name) ----
const PROFILES = {
"Amara Okafor": {
name: "Amara Okafor",
title: "Senior Frontend Engineer",
email: "amara.okafor@mailspring.dev",
phone: "+1 (415) 555-0184",
location: "Oakland, CA · Open to remote",
skills: ["TypeScript", "React", "Astro", "CSS Architecture", "Accessibility", "Design Systems", "Vitest"],
experience: [
{ role: "Senior Frontend Engineer", co: "Lumen Health", dates: "2022 — Present", color: "#2563eb" },
{ role: "Frontend Engineer", co: "Brightpath Labs", dates: "2019 — 2022", color: "#9333ea" },
{ role: "UI Developer", co: "Cobalt Studio", dates: "2017 — 2019", color: "#0891b2" }
]
},
"Diego Fuentes": {
name: "Diego Fuentes",
title: "Full-Stack Engineer",
email: "d.fuentes@postbox.io",
phone: "+34 612 55 0142",
location: "Madrid, ES · Hybrid",
skills: ["Node.js", "React", "PostgreSQL", "GraphQL", "AWS", "Docker"],
experience: [
{ role: "Full-Stack Engineer", co: "Mercado Verde", dates: "2021 — Present", color: "#16a34a" },
{ role: "Backend Engineer", co: "Tarifa Tech", dates: "2018 — 2021", color: "#d97706" }
]
}
};
const DEFAULT_KEY = "Amara Okafor";
function pickProfile(fileName) {
const lower = (fileName || "").toLowerCase();
if (lower.includes("diego") || lower.includes("fuentes")) return PROFILES["Diego Fuentes"];
return PROFILES[DEFAULT_KEY];
}
// ---- state view switching ----
function showEmpty() {
pvEmpty.hidden = false;
pvParsing.hidden = true;
pvForm.hidden = true;
setBadge("Awaiting upload", "");
}
function showParsing() {
pvEmpty.hidden = true;
pvForm.hidden = true;
pvParsing.hidden = false;
parseChecks.forEach((c) => c.classList.remove("done"));
setBadge("Parsing…", "parsing");
}
function showForm() {
pvEmpty.hidden = true;
pvParsing.hidden = true;
pvForm.hidden = false;
setBadge("Ready to review", "ready");
}
function setBadge(text, cls) {
pvBadge.textContent = text;
pvBadge.className = "badge-empty" + (cls ? " " + cls : "");
}
// ---- file size formatting ----
function fmtSize(bytes) {
if (!bytes && bytes !== 0) return "1.2 MB";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
function extOf(name) {
const m = /\.([a-z0-9]+)$/i.exec(name || "");
return m ? m[1].toUpperCase() : "PDF";
}
// ---- validation ----
const ALLOWED = ["pdf", "doc", "docx"];
function validFile(name, size) {
const ext = (extOf(name) || "").toLowerCase();
if (ALLOWED.indexOf(ext) === -1) {
toast("Unsupported file — use PDF, DOC or DOCX", true);
return false;
}
if (size && size > 5 * 1024 * 1024) {
toast("File too large — 5 MB maximum", true);
return false;
}
return true;
}
// ---- main flow ----
function handleFile(meta) {
clearTimers();
const name = meta.name || "resume.pdf";
const size = meta.size;
if (!validFile(name, size)) return;
// file chip
fileChip.hidden = false;
fcName.textContent = name;
fcMeta.textContent = fmtSize(size);
fileChip.querySelector(".fc-thumb").textContent = extOf(name);
fcStatus.textContent = "Uploading…";
fcStatus.classList.remove("done");
fcBar.classList.remove("done");
fcBar.style.width = "0%";
// simulate upload progress
let pct = 0;
function step() {
pct += Math.random() * 16 + 8;
if (pct >= 100) {
pct = 100;
fcBar.style.width = "100%";
progressEl.setAttribute("aria-valuenow", "100");
fcStatus.textContent = "Uploaded";
fcStatus.classList.add("done");
fcBar.classList.add("done");
later(() => runParse(name), 320);
return;
}
fcBar.style.width = pct.toFixed(0) + "%";
progressEl.setAttribute("aria-valuenow", pct.toFixed(0));
later(step, 130);
}
later(step, 120);
}
function runParse(name) {
showParsing();
const labels = ["Reading document…", "Detecting sections…", "Extracting fields…"];
let li = 0;
function relabel() {
if (li < labels.length) { parseLabel.textContent = labels[li++]; later(relabel, 520); }
}
relabel();
const order = ["contact", "skills", "experience"];
order.forEach((key, i) => {
later(() => {
const node = pvParsing.querySelector('[data-check="' + key + '"]');
if (node) node.classList.add("done");
}, 650 + i * 520);
});
later(() => {
populate(pickProfile(name));
showForm();
toast("Resume parsed — review the details");
}, 650 + order.length * 520 + 250);
}
// ---- populate preview ----
let skills = [];
function populate(p) {
fName.value = p.name;
fTitle.value = p.title;
fEmail.value = p.email;
fPhone.value = p.phone;
fLocation.value = p.location;
skills = p.skills.slice();
renderSkills();
renderExperience(p.experience);
}
function renderSkills() {
skillBox.innerHTML = "";
skills.forEach((s, idx) => {
const el = document.createElement("span");
el.className = "skill";
el.innerHTML = '<span>' + escapeHtml(s) + '</span>';
const x = document.createElement("button");
x.type = "button";
x.setAttribute("aria-label", "Remove " + s);
x.textContent = "×";
x.addEventListener("click", () => {
skills.splice(idx, 1);
renderSkills();
});
el.appendChild(x);
skillBox.appendChild(el);
});
skillCount.textContent = "(" + skills.length + ")";
}
function renderExperience(items) {
expList.innerHTML = "";
items.forEach((it) => {
const li = document.createElement("li");
li.className = "exp-item";
const initials = it.co.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
li.innerHTML =
'<span class="exp-logo" style="background:' + it.color + '">' + initials + '</span>' +
'<div class="exp-meta">' +
'<div class="exp-role">' + escapeHtml(it.role) + '</div>' +
'<div class="exp-co">' + escapeHtml(it.co) + '</div>' +
'<div class="exp-dates">' + escapeHtml(it.dates) + '</div>' +
'</div>';
expList.appendChild(li);
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
}[c]));
}
// ---- add skill ----
skillInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const v = skillInput.value.trim();
if (!v) return;
if (skills.some((s) => s.toLowerCase() === v.toLowerCase())) {
toast("That skill is already listed", true);
return;
}
skills.push(v);
skillInput.value = "";
renderSkills();
}
});
// ---- reset / re-upload ----
function reset() {
clearTimers();
fileChip.hidden = true;
fileInput.value = "";
skills = [];
showEmpty();
}
fcRemove.addEventListener("click", () => { reset(); toast("File removed"); });
reuploadBtn.addEventListener("click", () => {
reset();
dropzone.focus();
toast("Ready for a new file");
});
// ---- form submit ----
pvForm.addEventListener("submit", (e) => {
e.preventDefault();
if (!fName.value.trim() || !fEmail.value.trim()) {
toast("Name and email are required", true);
return;
}
toast("Saved — continuing to review");
});
// ---- input triggers ----
browseBtn.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("click", (e) => {
if (e.target.closest(".linkbtn")) return;
fileInput.click();
});
dropzone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInput.click(); }
});
fileInput.addEventListener("change", () => {
const f = fileInput.files && fileInput.files[0];
if (f) handleFile({ name: f.name, size: f.size });
});
// ---- sample ----
document.querySelectorAll("[data-sample]").forEach((btn) => {
btn.addEventListener("click", () => {
const who = btn.getAttribute("data-sample");
const file = who === "Diego Fuentes" ? "Diego_Fuentes_Resume.pdf" : "Amara_Okafor_Resume.pdf";
handleFile({ name: file, size: 1.24 * 1024 * 1024 });
});
});
// ---- drag & drop ----
let dragDepth = 0;
["dragenter", "dragover", "dragleave", "drop"].forEach((ev) => {
dropzone.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); });
});
dropzone.addEventListener("dragenter", () => { dragDepth++; dropzone.classList.add("dragging"); });
dropzone.addEventListener("dragleave", () => { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) dropzone.classList.remove("dragging"); });
dropzone.addEventListener("drop", (e) => {
dragDepth = 0;
dropzone.classList.remove("dragging");
const dt = e.dataTransfer;
const f = dt && dt.files && dt.files[0];
if (f) {
handleFile({ name: f.name, size: f.size });
} else {
// no real file (e.g. dragging text) — fall back to a sample so the demo still works
handleFile({ name: "Amara_Okafor_Resume.pdf", size: 1.24 * 1024 * 1024 });
}
});
// prevent the page from navigating when a file is dropped outside the zone
window.addEventListener("dragover", (e) => e.preventDefault());
window.addEventListener("drop", (e) => e.preventDefault());
// init
showEmpty();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Job Board — Resume Upload</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<path d="M6 3h7l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.6"/>
<path d="M13 3v5h5" stroke="currentColor" stroke-width="1.6"/>
<path d="M8.5 13h7M8.5 16h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</span>
<div class="brand-text">
<strong>Northpath</strong>
<span>Candidate Portal</span>
</div>
</div>
<div class="stepper" aria-label="Application progress">
<span class="step done">Account</span>
<span class="step current" aria-current="step">Resume</span>
<span class="step">Review</span>
</div>
</header>
<section class="lede">
<h1>Upload your resume</h1>
<p>Drop a PDF or DOCX and we’ll auto-fill your profile. You can edit everything before submitting your application for <strong>Senior Frontend Engineer</strong>.</p>
</section>
<div class="grid">
<!-- LEFT: dropzone + file chip -->
<section class="panel" aria-labelledby="dz-title">
<h2 id="dz-title" class="panel-title">Resume file</h2>
<div id="dropzone" class="dropzone" tabindex="0" role="button"
aria-label="Upload resume. Drag a file here or press Enter to browse.">
<div class="dz-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30" fill="none">
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</div>
<p class="dz-headline">Drag & drop your resume</p>
<p class="dz-sub">or <button type="button" id="browseBtn" class="linkbtn">browse files</button></p>
<p class="dz-hint">PDF, DOC or DOCX · up to 5 MB</p>
<input type="file" id="fileInput" class="sr-only" accept=".pdf,.doc,.docx" />
</div>
<ul class="sample-row" aria-label="Try a sample resume">
<li>No file handy?</li>
<li><button type="button" class="chip-ghost" data-sample="Amara Okafor">Try sample</button></li>
</ul>
<div id="fileChip" class="filechip" hidden>
<span class="fc-thumb" aria-hidden="true">PDF</span>
<div class="fc-body">
<div class="fc-head">
<span class="fc-name" id="fcName">resume.pdf</span>
<span class="fc-status" id="fcStatus">Uploading…</span>
</div>
<div class="fc-meta" id="fcMeta">1.2 MB</div>
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span class="progress-bar" id="fcBar"></span>
</div>
</div>
<button type="button" class="fc-remove" id="fcRemove" aria-label="Remove file" title="Remove">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>
</div>
<div class="trust">
<span class="trust-dot" aria-hidden="true"></span>
Parsed on-device for this demo — nothing leaves your browser.
</div>
</section>
<!-- RIGHT: parsed preview -->
<section class="panel preview" aria-labelledby="pv-title" aria-live="polite">
<div class="pv-head">
<h2 id="pv-title" class="panel-title">Parsed details</h2>
<span class="badge-empty" id="pvBadge">Awaiting upload</span>
</div>
<!-- empty state -->
<div class="pv-empty" id="pvEmpty">
<div class="pv-empty-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none">
<rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" stroke-width="1.6"/>
<path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</div>
<p>Your name, contact, skills and experience will appear here automatically once a resume is parsed.</p>
</div>
<!-- parsing skeleton -->
<div class="pv-parsing" id="pvParsing" hidden>
<div class="parse-spinner" aria-hidden="true"></div>
<p id="parseLabel">Reading document…</p>
<ul class="parse-checks">
<li data-check="contact">Contact details</li>
<li data-check="skills">Skills</li>
<li data-check="experience">Work experience</li>
</ul>
</div>
<!-- populated form -->
<form class="pv-form" id="pvForm" hidden novalidate>
<div class="field-grid">
<label class="field">
<span>Full name</span>
<input type="text" id="fName" name="name" autocomplete="name" />
</label>
<label class="field">
<span>Job title</span>
<input type="text" id="fTitle" name="title" />
</label>
<label class="field">
<span>Email</span>
<input type="email" id="fEmail" name="email" autocomplete="email" />
</label>
<label class="field">
<span>Phone</span>
<input type="tel" id="fPhone" name="phone" autocomplete="tel" />
</label>
<label class="field span2">
<span>Location</span>
<input type="text" id="fLocation" name="location" />
</label>
</div>
<div class="field span2">
<span class="field-label">Skills <em id="skillCount">0</em></span>
<div class="skills" id="skillBox" aria-label="Skills, click to remove"></div>
<div class="skill-add">
<input type="text" id="skillInput" placeholder="Add a skill and press Enter" aria-label="Add a skill" />
</div>
</div>
<div class="field span2">
<span class="field-label">Experience</span>
<ul class="exp-list" id="expList"></ul>
</div>
<div class="pv-actions">
<button type="button" class="btn-ghost" id="reuploadBtn">Re-upload</button>
<button type="submit" class="btn-primary">Save & continue</button>
</div>
</form>
</section>
</div>
</main>
<div class="toast-wrap" id="toastWrap" aria-live="assertive"></div>
<script src="script.js"></script>
</body>
</html>Resume Upload
A two-panel resume step from an ATS-style candidate portal. The left panel is a dashed drag-and-drop zone with a hover and active drag state, a browse-files link, and a “Try sample” shortcut for when no file is handy. Dropping or selecting a file reveals a file chip with a PDF thumbnail, file name and size, and a progress bar that animates from upload to a green “Uploaded” state before parsing begins.
The right panel walks through three states. It opens empty, switches to a parsing view with a spinner and a checklist that ticks off contact details, skills and work experience one at a time, then resolves into an editable preview form. The parser auto-fills name, job title, email, phone and location, renders skills as removable pills with a live count and an add-on-Enter input, and lists prior roles as cards with colored company initials.
Everything is interactive and dependency-free: file validation for type and size, an animated upload simulation, remove and re-upload actions that reset the flow, a working form submit with required-field checks, and a small toast helper for feedback. The layout collapses to a single column and stacks its actions down to roughly 360px.
Illustrative UI only — fictional jobs & companies, not a real hiring platform.