/* global React, Icons */
// ============================================================
// Section registry + shared section renderers
// Every résumé section is data-driven so the builder can let the
// user drag, drop, add and remove "segments" in any order, and the
// unified template engine (ResumePreview.jsx) renders them.
// ============================================================
// key -> { label, icon, kind, blurb }
const SECTION_DEFS = {
summary: { label: "Summary", icon: "pen", kind: "text", blurb: "A short professional intro" },
experience: { label: "Experience", icon: "brief", kind: "jobs", blurb: "Roles, companies & impact" },
projects: { label: "Projects", icon: "folder", kind: "projects",blurb: "Selected work & side projects" },
education: { label: "Education", icon: "cap", kind: "edu", blurb: "Degrees & schools" },
skills: { label: "Skills", icon: "star", kind: "skills", blurb: "Tools & competencies" },
certifications: { label: "Certifications", icon: "cert", kind: "named", blurb: "Licenses & credentials" },
awards: { label: "Awards", icon: "trophy", kind: "named", blurb: "Honors & recognition" },
languages: { label: "Languages", icon: "lang", kind: "leveled", blurb: "Spoken & written languages" },
publications: { label: "Publications", icon: "book", kind: "pubs", blurb: "Papers, articles & talks" },
volunteer: { label: "Volunteering", icon: "heart", kind: "jobs", blurb: "Community & pro-bono work" },
courses: { label: "Coursework", icon: "list", kind: "chips", blurb: "Relevant courses" },
interests: { label: "Interests", icon: "spark", kind: "chips", blurb: "Hobbies & personal" },
references: { label: "References", icon: "user", kind: "refs", blurb: "Referees or 'on request'" },
};
// natural fallback order if a résumé has no explicit sectionOrder
const DEFAULT_ORDER = [
"summary", "experience", "projects", "skills", "certifications",
"awards", "languages", "education", "volunteer", "publications",
"courses", "interests", "references",
];
const sectionLabel = (k) => (SECTION_DEFS[k] ? SECTION_DEFS[k].label : k);
// does this résumé hold any content for section k?
function sectionHasData(k, d) {
if (k === "summary") return !!(d.summary && d.summary.trim());
if (k === "references") {
const r = d.references;
return d.referencesOnRequest || (Array.isArray(r) && r.length > 0);
}
const v = d[k];
return Array.isArray(v) && v.length > 0;
}
// the effective, ordered, non-empty list of section keys to render.
// When an explicit sectionOrder exists it is authoritative (so removing a
// segment in the organizer truly removes it). Only fall back to DEFAULT_ORDER
// when no order has been set yet.
function activeSections(d) {
if (d.sectionOrder && d.sectionOrder.length) {
return d.sectionOrder.filter((k) => SECTION_DEFS[k] && sectionHasData(k, d));
}
return DEFAULT_ORDER.filter((k) => SECTION_DEFS[k] && sectionHasData(k, d));
}
// ---- contacts ----
const CICONS = { email: Icons.mail, phone: Icons.phone, website: Icons.globe, location: Icons.pin };
function contactList(d, keys) {
return (keys || ["email", "phone", "website", "location"])
.map((k) => ({ k, Ic: CICONS[k], val: d[k] }))
.filter((x) => x.val);
}
const initials = (name) => (name || "?").trim().split(" ").map((w) => w[0]).slice(0, 2).join("");
// ---- photo with monogram fallback ----
// When a real photo URL is set we layer it over the accent gradient, so a
// slow / failed remote load still reads as an intentional colored avatar
// rather than an empty white box.
function Photo({ d, shape = "circle", className = "" }) {
if (d.photo) return (
);
return {initials(d.name)}
;
}
// ---- proficiency scale ("vector") model ---------------------------------
// A skill is stored as either a plain string (legacy) or { name, level }
// where level is 1..SKILL_MAX. The scale is how much you've used a tool /
// language — rendered as dots, segments or signal bars instead of plain text.
const SKILL_MAX = 5;
const LEVEL_WORDS = ["", "Novice", "Familiar", "Proficient", "Advanced", "Expert"];
// level -> fill % for the bar / ring variants (reads naturally, never 0/100)
const LVL_PCT = { 1: 50, 2: 65, 3: 78, 4: 90, 5: 98 };
const clampLvl = (n) => { n = Math.round(Number(n)); return Math.max(1, Math.min(SKILL_MAX, Number.isFinite(n) && n ? n : 4)); };
const SKILL_LVLS = [94, 88, 96, 82, 90, 85, 92, 80, 87, 83];
const lvl = (i) => SKILL_LVLS[i % SKILL_LVLS.length];
// legacy strings get a varied-but-plausible level so old résumés still look alive
const legacyTier = (i) => clampLvl(Math.round(lvl(i) / 20));
const skillName = (s) => (s && typeof s === "object" ? s.name : s);
const normSkill = (s, i = 0) =>
s && typeof s === "object" ? { name: s.name || "", level: clampLvl(s.level) } : { name: String(s ?? ""), level: legacyTier(i) };
// spoken-language proficiency words -> tier on the same 1..5 scale
const LANG_TIER = {
native: 5, bilingual: 5, mother: 5, fluent: 5, "full professional": 4, professional: 4,
advanced: 4, "working professional": 4, conversational: 3, intermediate: 3, limited: 2,
"limited working": 2, basic: 2, elementary: 2, beginner: 1,
};
const langTier = (level) => {
if (level == null) return 3;
const k = String(level).toLowerCase().trim();
if (LANG_TIER[k] != null) return LANG_TIER[k];
const hit = Object.keys(LANG_TIER).find((w) => k.includes(w));
return hit ? LANG_TIER[hit] : 3;
};
// ---- the scale renderers (the "vectors") ----
function ScaleDots({ level, max = SKILL_MAX }) {
return {Array.from({ length: max }, (_, i) => )} ;
}
function ScaleSegs({ level, max = SKILL_MAX }) {
return {Array.from({ length: max }, (_, i) => )} ;
}
function ScaleSignal({ level, max = SKILL_MAX }) {
return {Array.from({ length: max }, (_, i) => )} ;
}
function Scale({ kind, level, max }) {
if (kind === "segments") return ;
if (kind === "signal") return ;
return ;
}
// one labelled scale row (name on the left, scale on the right)
function ScaleRow({ name, level, kind, showWord, editPath }) {
return (
{name}
{showWord && {LEVEL_WORDS[level]} }
);
}
// ---- skill visualisations ----
function SkillBar({ name, pct, word, editPath }) {
return (
{name} {word || pct + "%"}
);
}
function SkillRing({ name, pct, editPath }) {
const R = 22, C = 2 * Math.PI * R;
return (
{pct}%
{name}
);
}
function RSkills({ items, variant, showWord }) {
const norm = (items || []).map((s, i) => normSkill(s, i));
if (variant === "bars")
return {norm.slice(0, 9).map((s, i) => )}
;
if (variant === "rings")
return {norm.slice(0, 6).map((s, i) => )}
;
if (variant === "dots" || variant === "segments" || variant === "signal")
return {norm.slice(0, 12).map((s, i) => )}
;
if (variant === "list")
return {norm.map((s, i) => {s.name} )}
;
return {norm.map((s, i) => {s.name} )}
;
}
// ---- experience / volunteer entry ----
function RJob({ j, secKey = "experience" }) {
return (
{j.role}
{j.start}{j.end ? ` – ${j.end}` : ""}
{j.company}
{j.location ? <> · {j.location} > : ""}
{j.bullets && j.bullets.filter(Boolean).length > 0 && (
{j.bullets.map((b, i) => (b && b.trim() ? {b} : null))}
)}
);
}
function REdu({ e }) {
return (
{e.school} {e.start}{e.end ? ` – ${e.end}` : ""}
{e.degree &&
{e.degree}
}
);
}
// ---- the body of one section (heading is added by the engine) ----
function SectionBody({ k, d, variant, tpl }) {
const def = SECTION_DEFS[k];
if (!def) return null;
// graphic templates show a proficiency scale; ATS / list templates stay text-only
const graphic = tpl && tpl.skills && tpl.skills !== "list";
const showWord = !!(tpl && tpl.scaleWord);
switch (def.kind) {
case "text":
return {d.summary}
;
case "jobs": {
const arr = k === "experience" ? d.experience : d.volunteer;
return {(arr || []).map((j) => )}
;
}
case "projects":
return (
{d.projects.map((p) => (
{p.name} {p.desc ? — {p.desc} : null}
))}
);
case "edu":
return {d.education.map((e) => )}
;
case "skills":
return ;
case "named": {
const arr = d[k] || [];
return (
{arr.map((x) => (
{x.name}
{(x.issuer || x.year) && {[x.issuer, x.year].filter(Boolean).join(" · ")} }
))}
);
}
case "leveled": {
// languages get the same scale 'vector' on graphic templates; keep the
// word too (CEFR-ish proficiency reads clearer than dots alone here)
const lkind = (tpl && (tpl.skills === "segments" || tpl.skills === "signal")) ? tpl.skills : "dots";
return (
{d.languages.map((l) => (
{l.name}
{graphic
? {l.level && {l.level} }
: (l.level && {l.level} )}
))}
);
}
case "pubs":
return (
{d.publications.map((p) => (
{p.title} {p.venue ? <>. {p.venue} > : null}{p.year ? `, ${p.year}` : ""}.
))}
);
case "chips": {
const arr = d[k] || [];
return {arr.map((s, i) => {s} )}
;
}
case "refs":
if (d.referencesOnRequest && !(d.references && d.references.length)) {
return Available upon request.
;
}
return (
{(d.references || []).map((r) => (
{r.name} {r.title && {r.title} }{r.contact && {r.contact} }
))}
);
default:
return null;
}
}
Object.assign(window, {
SECTION_DEFS, DEFAULT_ORDER, sectionLabel, sectionHasData, activeSections,
contactList, initials, Photo, SkillBar, SkillRing, RSkills, RJob, REdu, SectionBody, lvl,
SKILL_MAX, LEVEL_WORDS, LVL_PCT, clampLvl, skillName, normSkill, langTier,
ScaleDots, ScaleSegs, ScaleSignal, Scale, ScaleRow,
});