From 79142b97989640f66c70b89339ad6f050faf8ca7 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 24 Jun 2026 15:18:29 +0700 Subject: [PATCH 1/6] style(css): improve input focus behavior --- src/app/globals.css | 56 +++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f9dafdd..07ec9bd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,11 +2,11 @@ @theme inline { --color-black: #000000; - --color-white: #FFFFFF; - --color-gray-100: #F5F5F5; - --color-gray-200: #E5E5E5; - --color-gray-300: #D4D4D4; - --color-gray-400: #A3A3A3; + --color-white: #ffffff; + --color-gray-100: #f5f5f5; + --color-gray-200: #e5e5e5; + --color-gray-300: #d4d4d4; + --color-gray-400: #a3a3a3; --color-gray-500: #737373; --color-gray-600: #525252; --color-gray-700: #404040; @@ -33,12 +33,14 @@ } html { - background-color: #FFFFFF; + background-color: #ffffff; color: #000000; scroll-behavior: smooth; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; - font-feature-settings: "kern" 1, "liga" 1; + font-feature-settings: + "kern" 1, + "liga" 1; text-rendering: optimizeLegibility; } @@ -46,14 +48,14 @@ body { font-family: var(--font-sans), system-ui, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: #FFFFFF; + background-color: #ffffff; color: #000000; } /* Brutalist selection */ ::selection { background-color: #000000; - color: #FFFFFF; + color: #ffffff; } /* Brutalist focus states */ @@ -69,7 +71,7 @@ body { } ::-webkit-scrollbar-track { - background: #FFFFFF; + background: #ffffff; border-left: 1px solid #000000; } @@ -78,7 +80,9 @@ body { } /* Brutalist input reset */ -input, textarea, select { +input, +textarea, +select { border: 1px solid #000000; background: transparent; color: #000000; @@ -87,13 +91,18 @@ input, textarea, select { transition: all 0.15s ease; } -input:focus, textarea:focus, select:focus { - background-color: #000000; - color: #FFFFFF; +input:focus, +textarea:focus, +select:focus { + background-color: #ffffff; + color: #000000; + border-color: #000000; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); outline: none; } -input::placeholder, textarea::placeholder { +input::placeholder, +textarea::placeholder { color: #737373; text-transform: uppercase; font-size: 0.75rem; @@ -123,8 +132,9 @@ textarea::-moz-selection { color: #000000 !important; } -input:focus::placeholder, textarea:focus::placeholder { - color: #A3A3A3; +input:focus::placeholder, +textarea:focus::placeholder { + color: #a3a3a3; } input[type="text"]:focus::selection, @@ -177,15 +187,17 @@ select { background-position: calc(100% - 16px) calc(50% - 2px), calc(100% - 10px) calc(50% - 2px); - background-size: 6px 6px, 6px 6px; + background-size: + 6px 6px, + 6px 6px; background-repeat: no-repeat; padding-right: 2.25rem; } select:focus { background-image: - linear-gradient(45deg, transparent 50%, #FFFFFF 50%), - linear-gradient(135deg, #FFFFFF 50%, transparent 50%); + linear-gradient(45deg, transparent 50%, #ffffff 50%), + linear-gradient(135deg, #ffffff 50%, transparent 50%); } /* macOS/Safari: custom checkbox styling (Safari ignores border-radius without appearance reset) */ @@ -214,7 +226,7 @@ input[type="checkbox"]:checked::after { top: 0px; width: 5px; height: 9px; - border: 2px solid #FFFFFF; + border: 2px solid #ffffff; border-top: none; border-left: none; transform: rotate(45deg); @@ -250,7 +262,7 @@ button { button:hover { background-color: #000000; - color: #FFFFFF; + color: #ffffff; } /* Monospace label utility */ From 74290ef408017ad0c2bf61825ecdabf8e31e9d41 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 24 Jun 2026 15:18:40 +0700 Subject: [PATCH 2/6] refactor(personal-info): remove inline focus blackout styles --- .../builder/forms/personal-info-form.tsx | 238 ++++++++++++------ 1 file changed, 159 insertions(+), 79 deletions(-) diff --git a/src/components/builder/forms/personal-info-form.tsx b/src/components/builder/forms/personal-info-form.tsx index 03e0bb9..f500599 100644 --- a/src/components/builder/forms/personal-info-form.tsx +++ b/src/components/builder/forms/personal-info-form.tsx @@ -10,10 +10,7 @@ import { importFromGitHub, type SocialImportResult, } from "@/actions/import"; -import { - getForgProductOptions, - importFromForg, -} from "@/actions/forg"; +import { getForgProductOptions, importFromForg } from "@/actions/forg"; import type { ForgProductOption } from "@/actions/import"; import { parseLinkedInZip } from "@/lib/linkedin-zip-parser"; import type { ResumeData } from "@/db/schema"; @@ -78,10 +75,21 @@ export function PersonalInfoForm() { const [forgOptions, setForgOptions] = useState([]); const [selectedForgProductId, setSelectedForgProductId] = useState(""); const [importStatus, setImportStatus] = useState(""); - const [previewDraft, setPreviewDraft] = useState(null); - const [previewSelection, setPreviewSelection] = useState(createEmptySelection()); + const [previewDraft, setPreviewDraft] = useState( + null, + ); + const [previewSelection, setPreviewSelection] = useState( + createEmptySelection(), + ); - const trackImport = (eventName: typeof USAGE_EVENTS.IMPORT_STARTED | typeof USAGE_EVENTS.IMPORT_PREVIEW_READY | typeof USAGE_EVENTS.IMPORT_APPLIED, source: string, extra: Record = {}) => { + const trackImport = ( + eventName: + | typeof USAGE_EVENTS.IMPORT_STARTED + | typeof USAGE_EVENTS.IMPORT_PREVIEW_READY + | typeof USAGE_EVENTS.IMPORT_APPLIED, + source: string, + extra: Record = {}, + ) => { void trackUsageEvent(eventName, { source, resumeId: resumeId ?? "guest", @@ -91,7 +99,7 @@ export function PersonalInfoForm() { const mergeImportedSkills = ( currentSkills: ResumeData["skills"], - importedSkills: Array<{ category: string; items: string[] }> + importedSkills: Array<{ category: string; items: string[] }>, ): ResumeData["skills"] => { const next = [...currentSkills]; @@ -99,13 +107,19 @@ export function PersonalInfoForm() { if (!imported.items.length) continue; const matchIndex = next.findIndex( - (skill) => skill.category.trim().toLowerCase() === imported.category.trim().toLowerCase() + (skill) => + skill.category.trim().toLowerCase() === + imported.category.trim().toLowerCase(), ); if (matchIndex >= 0) { const mergedItems = [...next[matchIndex].items]; for (const item of imported.items) { - if (!mergedItems.some((existing) => existing.toLowerCase() === item.toLowerCase())) { + if ( + !mergedItems.some( + (existing) => existing.toLowerCase() === item.toLowerCase(), + ) + ) { mergedItems.push(item); } } @@ -127,7 +141,7 @@ export function PersonalInfoForm() { }; const mapImportedSkills = ( - importedSkills: Array<{ category: string; items: string[] }> + importedSkills: Array<{ category: string; items: string[] }>, ): ResumeData["skills"] => importedSkills .filter((skill) => skill.items.length > 0) @@ -147,17 +161,23 @@ export function PersonalInfoForm() { websiteUrl: string; technologies: string[]; highlights: string[]; - }> + }>, ): ResumeData["projects"] => { const existingProjectKeys = new Set( currentProjects - .map((project) => (project.githubUrl || project.url || project.name).trim().toLowerCase()) - .filter(Boolean) + .map((project) => + (project.githubUrl || project.url || project.name) + .trim() + .toLowerCase(), + ) + .filter(Boolean), ); const newProjects = importedProjects .filter((project) => { - const key = (project.githubUrl || project.url || project.name).trim().toLowerCase(); + const key = (project.githubUrl || project.url || project.name) + .trim() + .toLowerCase(); return key ? !existingProjectKeys.has(key) : true; }) .map((project) => ({ @@ -183,7 +203,7 @@ export function PersonalInfoForm() { websiteUrl: string; technologies: string[]; highlights: string[]; - }> + }>, ): ResumeData["projects"] => importedProjects.map((project) => ({ id: crypto.randomUUID(), @@ -198,17 +218,20 @@ export function PersonalInfoForm() { const mergeImportedExperience = ( currentExperience: ResumeData["experience"], - importedExperience: SocialImportResult["experience"] + importedExperience: SocialImportResult["experience"], ): ResumeData["experience"] => { const existingKeys = new Set( currentExperience - .map((exp) => `${exp.company}|${exp.position}|${exp.startDate}`.toLowerCase()) - .filter(Boolean) + .map((exp) => + `${exp.company}|${exp.position}|${exp.startDate}`.toLowerCase(), + ) + .filter(Boolean), ); const next = [...currentExperience]; for (const exp of importedExperience) { - const key = `${exp.company}|${exp.position}|${exp.startDate}`.toLowerCase(); + const key = + `${exp.company}|${exp.position}|${exp.startDate}`.toLowerCase(); if (existingKeys.has(key)) continue; next.push({ id: crypto.randomUUID(), @@ -227,7 +250,7 @@ export function PersonalInfoForm() { }; const mapImportedExperience = ( - importedExperience: SocialImportResult["experience"] + importedExperience: SocialImportResult["experience"], ): ResumeData["experience"] => importedExperience.map((exp) => ({ id: crypto.randomUUID(), @@ -243,12 +266,12 @@ export function PersonalInfoForm() { const mergeImportedCertifications = ( currentCertifications: ResumeData["certifications"], - importedCertifications: SocialImportResult["certifications"] + importedCertifications: SocialImportResult["certifications"], ): ResumeData["certifications"] => { const existingKeys = new Set( currentCertifications .map((cert) => `${cert.name}|${cert.issuer}|${cert.date}`.toLowerCase()) - .filter(Boolean) + .filter(Boolean), ); const next = [...currentCertifications]; @@ -268,7 +291,7 @@ export function PersonalInfoForm() { }; const mapImportedCertifications = ( - importedCertifications: SocialImportResult["certifications"] + importedCertifications: SocialImportResult["certifications"], ): ResumeData["certifications"] => importedCertifications.map((cert) => ({ id: crypto.randomUUID(), @@ -300,11 +323,13 @@ export function PersonalInfoForm() { switch (field) { case "fullName": if (!value.trim()) error = "Name is required"; - else if (value.length > MAX_NAME_LENGTH) error = `Max ${MAX_NAME_LENGTH} characters`; + else if (value.length > MAX_NAME_LENGTH) + error = `Max ${MAX_NAME_LENGTH} characters`; break; case "email": if (!value.trim()) error = "Email is required"; - else if (!EMAIL_REGEX.test(value)) error = "Enter a valid email address"; + else if (!EMAIL_REGEX.test(value)) + error = "Enter a valid email address"; break; case "phone": if (value && !PHONE_REGEX.test(value)) @@ -355,7 +380,9 @@ export function PersonalInfoForm() { const { url } = await res.json(); updateField("avatarUrl", url); } catch (err: unknown) { - alert((err as Error).message || "Failed to upload image. Please try again."); + alert( + (err as Error).message || "Failed to upload image. Please try again.", + ); } finally { setIsUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; @@ -386,7 +413,9 @@ export function PersonalInfoForm() { } }; - const buildInitialSelection = (draft: SocialImportResult): ImportSelection => { + const buildInitialSelection = ( + draft: SocialImportResult, + ): ImportSelection => { const next = createEmptySelection(); const personalKeys = Object.keys(next.personal) as PersonalFieldKey[]; @@ -427,7 +456,9 @@ export function PersonalInfoForm() { trackImport(USAGE_EVENTS.IMPORT_PREVIEW_READY, "github", { username: imported.username, }); - setImportStatus(t("previewReady", { source: "GitHub", username: imported.username })); + setImportStatus( + t("previewReady", { source: "GitHub", username: imported.username }), + ); } catch (err: unknown) { setImportStatus((err as Error).message || t("githubFailed")); } finally { @@ -435,7 +466,9 @@ export function PersonalInfoForm() { } }; - const handleLinkedInZipImport = async (e: React.ChangeEvent) => { + const handleLinkedInZipImport = async ( + e: React.ChangeEvent, + ) => { const file = e.target.files?.[0]; if (!file) return; @@ -454,7 +487,9 @@ export function PersonalInfoForm() { trackImport(USAGE_EVENTS.IMPORT_PREVIEW_READY, "linkedin_zip", { username: imported.username, }); - setImportStatus(t("previewReady", { source: "LinkedIn", username: imported.username })); + setImportStatus( + t("previewReady", { source: "LinkedIn", username: imported.username }), + ); } catch (err: unknown) { setImportStatus((err as Error).message || t("linkedinZipFailed")); } finally { @@ -484,7 +519,9 @@ export function PersonalInfoForm() { trackImport(USAGE_EVENTS.IMPORT_PREVIEW_READY, "behance", { username: imported.username, }); - setImportStatus(t("previewReady", { source: "Behance", username: imported.username })); + setImportStatus( + t("previewReady", { source: "Behance", username: imported.username }), + ); } catch (err: unknown) { setImportStatus((err as Error).message || t("behanceFailed")); } finally { @@ -512,10 +549,15 @@ export function PersonalInfoForm() { if (!resOptions.success) throw new Error(resOptions.error); const optionsResult = resOptions.data; - if (optionsResult.target === "profile" && optionsResult.options.length > 1) { + if ( + optionsResult.target === "profile" && + optionsResult.options.length > 1 + ) { setForgOptions(optionsResult.options); setSelectedForgProductId(optionsResult.options[0].id); - setImportStatus(t("forgMultiFound", { username: optionsResult.username })); + setImportStatus( + t("forgMultiFound", { username: optionsResult.username }), + ); return; } @@ -527,7 +569,9 @@ export function PersonalInfoForm() { trackImport(USAGE_EVENTS.IMPORT_PREVIEW_READY, "forg", { username: imported.username, }); - setImportStatus(t("previewReady", { source: "Forg", username: imported.username })); + setImportStatus( + t("previewReady", { source: "Forg", username: imported.username }), + ); } catch (err: unknown) { setImportStatus((err as Error).message || t("forgFailed")); } finally { @@ -544,7 +588,10 @@ export function PersonalInfoForm() { selectedProductId: selectedForgProductId, }); try { - const result = await importFromForg(forgInput.trim(), selectedForgProductId); + const result = await importFromForg( + forgInput.trim(), + selectedForgProductId, + ); if (!result.success) throw new Error(result.error); const imported = result.data; openPreview(imported); @@ -552,7 +599,9 @@ export function PersonalInfoForm() { username: imported.username, selectedProductId: selectedForgProductId, }); - setImportStatus(t("previewReady", { source: "Forg", username: imported.username })); + setImportStatus( + t("previewReady", { source: "Forg", username: imported.username }), + ); setForgOptions([]); setSelectedForgProductId(""); } catch (err: unknown) { @@ -571,7 +620,9 @@ export function PersonalInfoForm() { ...data.personalInfo, }; - const selectedKeys = Object.keys(previewSelection.personal) as PersonalFieldKey[]; + const selectedKeys = Object.keys( + previewSelection.personal, + ) as PersonalFieldKey[]; for (const key of selectedKeys) { if (!previewSelection.personal[key]) continue; const incoming = (previewDraft.personalInfo[key] ?? "").toString().trim(); @@ -600,7 +651,10 @@ export function PersonalInfoForm() { const nextCertifications = previewSelection.certifications ? previewSelection.certificationsMode === "replace" ? mapImportedCertifications(previewDraft.certifications) - : mergeImportedCertifications(data.certifications, previewDraft.certifications) + : mergeImportedCertifications( + data.certifications, + previewDraft.certifications, + ) : data.certifications; setData({ @@ -612,10 +666,12 @@ export function PersonalInfoForm() { certifications: nextCertifications, }); - setImportStatus(t("mergedSelected", { - source: previewDraft.source.toUpperCase(), - username: previewDraft.username, - })); + setImportStatus( + t("mergedSelected", { + source: previewDraft.source.toUpperCase(), + username: previewDraft.username, + }), + ); trackImport(USAGE_EVENTS.IMPORT_APPLIED, previewDraft.source, { username: previewDraft.username, }); @@ -625,9 +681,10 @@ export function PersonalInfoForm() { /** Helper: input class with error state */ const inputClass = (field: string) => - `w-full border bg-transparent px-4 py-3 transition-all duration-150 ${errors[field] - ? "border-red-500 focus:bg-red-50 focus:text-red-900" - : "border-black focus:bg-black focus:text-white" + `w-full border bg-transparent px-4 py-3 transition-all duration-150 ${ + errors[field] + ? "border-red-500 focus:bg-red-50 focus:text-red-900" + : "border-black focus:border-black" }`; return ( @@ -643,25 +700,32 @@ export function PersonalInfoForm() {
- {/* ── Import Profile ─────────────────────────────── */}
- {t("importSectionTitle")} + + {t("importSectionTitle")} +

{t("importSectionHint")}

{/* LinkedIn ZIP */}
- LINKEDIN + + LINKEDIN + - {t("linkedinZipHint")} + + {t("linkedinZipHint")} +
setGithubInput(e.target.value)} placeholder={t("githubPlaceholder")} - className="flex-1 min-w-0 border border-black bg-transparent px-3 py-1.5 text-sm focus:bg-black focus:text-white transition-all duration-150" + className="flex-1 min-w-0 border border-black bg-transparent px-3 py-1.5 text-sm focus:border-black transition-all duration-150" />
-

- {t("forgDevNotice")} -

+

{t("forgDevNotice")}

{forgOptions.length > 1 && (
@@ -765,9 +829,7 @@ export function PersonalInfoForm() { {isImportingForg ? t("importing") : t("forgUseSelected")}
-

- {t("forgPickerHint")} -

+

{t("forgPickerHint")}

)} @@ -811,12 +873,17 @@ export function PersonalInfoForm() {
{errors.fullName ? ( - {errors.fullName} + + {errors.fullName} + ) : ( )} @@ -869,7 +938,9 @@ export function PersonalInfoForm() { className={inputClass("email")} /> {errors.email && ( - {errors.email} + + {errors.email} + )}
@@ -891,7 +962,9 @@ export function PersonalInfoForm() { className={inputClass("phone")} /> {errors.phone ? ( - {errors.phone} + + {errors.phone} + ) : ( Digits, spaces, +, -, or parentheses @@ -908,7 +981,7 @@ export function PersonalInfoForm() { onChange={(e) => updateField("location", e.target.value)} placeholder="San Francisco, CA" maxLength={100} - className="w-full border border-black bg-transparent px-4 py-3 focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-black bg-transparent px-4 py-3 focus:border-black transition-all duration-150" /> @@ -920,21 +993,25 @@ export function PersonalInfoForm() { value={personalInfo.website || ""} onChange={(e) => updateField("website", e.target.value)} placeholder="https://example.com" - className="w-full border border-black bg-transparent px-4 py-3 focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-black bg-transparent px-4 py-3 focus:border-black transition-all duration-150" /> {/* LinkedIn */} {/* LinkedIn URL — shown in CV header */}
- -

{t("linkedinUrlHint")}

+ +

+ {t("linkedinUrlHint")} +

updateField("linkedin", e.target.value)} placeholder={t("linkedinPlaceholder")} - className="w-full border border-black bg-transparent px-4 py-3 focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-black bg-transparent px-4 py-3 focus:border-black transition-all duration-150" />
@@ -948,7 +1025,7 @@ export function PersonalInfoForm() { value={personalInfo.github || ""} onChange={(e) => updateField("github", e.target.value)} placeholder={t("githubPlaceholder")} - className="w-full border border-black bg-transparent px-4 py-3 focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-black bg-transparent px-4 py-3 focus:border-black transition-all duration-150" /> @@ -974,10 +1051,11 @@ export function PersonalInfoForm() { placeholder="A brief professional summary or objective statement..." rows={6} maxLength={MAX_SUMMARY_LENGTH} - className="w-full border border-black bg-transparent px-4 py-3 focus:bg-black focus:text-white transition-all duration-150 resize-none" + className="w-full border border-black bg-transparent px-4 py-3 focus:border-black transition-all duration-150 resize-none" /> - {personalInfo.summary?.length || 0} / {MAX_SUMMARY_LENGTH} characters + {personalInfo.summary?.length || 0} / {MAX_SUMMARY_LENGTH}{" "} + characters @@ -986,10 +1064,12 @@ export function PersonalInfoForm() { updateField("bulletSymbol", e.target.value.slice(0, 3))} + onChange={(e) => + updateField("bulletSymbol", e.target.value.slice(0, 3)) + } placeholder="•" maxLength={3} - className="w-full border border-black bg-transparent px-4 py-3 focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-black bg-transparent px-4 py-3 focus:border-black transition-all duration-150" /> Use any symbol for CV bullets, for example: •, -, ▪, or → From b73184eb4f9a8ef913bf11aca01c57c090362da9 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 24 Jun 2026 15:19:08 +0700 Subject: [PATCH 3/6] refactor(builder-forms): remove inline focus blackout styles from inputs --- src/components/builder/builder-header.tsx | 204 +++++++++++------- .../builder/forms/certifications-form.tsx | 20 +- .../builder/forms/competitions-form.tsx | 75 +++++-- .../builder/forms/education-form.tsx | 39 ++-- .../builder/forms/experience-form.tsx | 80 ++++--- .../builder/forms/languages-form.tsx | 10 +- .../builder/forms/projects-form.tsx | 12 +- src/components/builder/forms/skills-form.tsx | 10 +- 8 files changed, 285 insertions(+), 165 deletions(-) diff --git a/src/components/builder/builder-header.tsx b/src/components/builder/builder-header.tsx index 02522b3..cb6f433 100644 --- a/src/components/builder/builder-header.tsx +++ b/src/components/builder/builder-header.tsx @@ -26,12 +26,28 @@ export function BuilderHeader({ saveValidationError, isGuest = false, }: BuilderHeaderProps) { - const { title, setTitle, templateId, setTemplateId, fontFamily, setFontFamily, pdfLocale, setPdfLocale, isSaving, isDirty, lastSavedAt, data, setData } = - useResumeStore(); + const { + title, + setTitle, + templateId, + setTemplateId, + fontFamily, + setFontFamily, + pdfLocale, + setPdfLocale, + isSaving, + isDirty, + lastSavedAt, + data, + setData, + } = useResumeStore(); const [isExporting, setIsExporting] = useState(false); const [isCheckingWriting, setIsCheckingWriting] = useState(false); - const [writingStatus, setWritingStatus] = useState<{ type: "success" | "error"; message: string } | null>(null); + const [writingStatus, setWritingStatus] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); const [previewPdfUrl, setPreviewPdfUrl] = useState(null); const [previewFilename, setPreviewFilename] = useState("resume.pdf"); const [exportError, setExportError] = useState(null); @@ -47,7 +63,12 @@ export function BuilderHeader({ ) { setExportError(null); } - }, [exportError, templateId, data.personalInfo.fullName, data.personalInfo.email]); + }, [ + exportError, + templateId, + data.personalInfo.fullName, + data.personalInfo.email, + ]); useEffect(() => { if (!writingStatus) return; @@ -80,7 +101,9 @@ export function BuilderHeader({ setIsExporting(true); try { - const response = await fetch(`/api/export/${resumeId}?locale=${pdfLocale}`); + const response = await fetch( + `/api/export/${resumeId}?locale=${pdfLocale}`, + ); if (!response.ok) { throw new Error("Export failed"); } @@ -130,7 +153,9 @@ export function BuilderHeader({ console.error("Failed to check grammar/spelling:", error); setWritingStatus({ type: "error", - message: (error as Error).message || "Failed to check writing. Please try again.", + message: + (error as Error).message || + "Failed to check writing. Please try again.", }); } finally { setIsCheckingWriting(false); @@ -153,7 +178,10 @@ export function BuilderHeader({ }); }; - const activeBulletSymbol = (data.personalInfo.bulletSymbol || "•").slice(0, 3); + const activeBulletSymbol = (data.personalInfo.bulletSymbol || "•").slice( + 0, + 3, + ); return ( <> @@ -168,7 +196,9 @@ export function BuilderHeader({ > ← {isGuest ? "Home" : "Back"} - {isGuest ? "GUEST_MODE" : "BUILDER_MODE"} + + {isGuest ? "GUEST_MODE" : "BUILDER_MODE"} +
@@ -177,17 +207,23 @@ export function BuilderHeader({ {isGuest ? ( <> - NOT SAVED · GUEST + + NOT SAVED · GUEST + ) : saveValidationError ? ( <> - {saveValidationError} + + {saveValidationError} + ) : isSaving ? ( <> - SAVING... + + SAVING... + ) : isDirty ? ( <> @@ -216,7 +252,10 @@ export function BuilderHeader({
{isGuest ? (
- + {isCheckingWriting ? "CHECKING..." : "CHECK GRAMMAR"}
{writingStatus && (

{writingStatus.message}

@@ -277,7 +321,7 @@ export function BuilderHeader({ value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Resume Title" - className="w-full border border-gray-300 bg-transparent px-3 py-2 text-lg font-bold focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-300 bg-transparent px-3 py-2 text-lg font-bold focus:border-black transition-all duration-150" />
@@ -291,7 +335,7 @@ export function BuilderHeader({ title="Font family" value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} - className="border border-black bg-transparent px-3 py-2 text-xs font-bold tracking-wider focus:bg-black focus:text-white transition-all duration-150" + className="border border-black bg-transparent px-3 py-2 text-xs font-bold tracking-wider transition-all duration-150" > {CV_FONTS.map((font) => (
diff --git a/src/components/builder/forms/certifications-form.tsx b/src/components/builder/forms/certifications-form.tsx index bb51f91..f594bdd 100644 --- a/src/components/builder/forms/certifications-form.tsx +++ b/src/components/builder/forms/certifications-form.tsx @@ -27,12 +27,12 @@ export function CertificationsForm() { const updateCertification = ( id: string, - updates: Partial + updates: Partial, ) => { setData({ ...data, certifications: certifications.map((c) => - c.id === id ? { ...c, ...updates } : c + c.id === id ? { ...c, ...updates } : c, ), }); }; @@ -40,8 +40,12 @@ export function CertificationsForm() { return (
- SECTION_06 // CERTIFICATIONS -

Certifications

+ + SECTION_06 // CERTIFICATIONS + +

+ Certifications +

Professional certifications, licenses, and credentials

@@ -72,7 +76,7 @@ export function CertificationsForm() { updateCertification(cert.id, { name: e.target.value }) } placeholder="AWS Solutions Architect Professional" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -86,7 +90,7 @@ export function CertificationsForm() { updateCertification(cert.id, { issuer: e.target.value }) } placeholder="Amazon Web Services" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -98,7 +102,7 @@ export function CertificationsForm() { updateCertification(cert.id, { date: value }) } title="Certification date" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -112,7 +116,7 @@ export function CertificationsForm() { updateCertification(cert.id, { url: e.target.value }) } placeholder="https://www.credly.com/badges/..." - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" /> diff --git a/src/components/builder/forms/competitions-form.tsx b/src/components/builder/forms/competitions-form.tsx index f443d6f..e3217a7 100644 --- a/src/components/builder/forms/competitions-form.tsx +++ b/src/components/builder/forms/competitions-form.tsx @@ -10,21 +10,25 @@ export function CompetitionsForm() { const { data, setData } = useResumeStore(); const { competitions = [] } = data; // Default fallback for older resumes const [improvingBullet, setImprovingBullet] = useState(null); - const [improvingDescription, setImprovingDescription] = useState(null); + const [improvingDescription, setImprovingDescription] = useState< + string | null + >(null); const handleImproveBullet = async ( compId: string, idx: number, text: string, compName: string, - role: string + role: string, ) => { if (!text.trim() || improvingBullet) return; const key = `${compId}-${idx}`; setImprovingBullet(key); try { const positionContext = role ? `${role} at ${compName}` : compName; - const { result } = await improveBullet(text, { position: positionContext }); + const { result } = await improveBullet(text, { + position: positionContext, + }); updateHighlight(compId, idx, result); } catch (err: unknown) { alert((err as Error).message || "AI improvement failed."); @@ -37,14 +41,18 @@ export function CompetitionsForm() { compId: string, text: string, compName: string, - role: string + role: string, ) => { if (!text.trim() || improvingDescription) return; setImprovingDescription(compId); try { const titleContext = role ? `${role} at ${compName}` : compName; - const { result } = await improveDescription(text, { title: titleContext }); - updateCompetition(compId, { description: result.slice(0, MAX_DESCRIPTION_LENGTH) }); + const { result } = await improveDescription(text, { + title: titleContext, + }); + updateCompetition(compId, { + description: result.slice(0, MAX_DESCRIPTION_LENGTH), + }); } catch (err: unknown) { alert((err as Error).message || "AI improvement failed."); } finally { @@ -89,12 +97,12 @@ export function CompetitionsForm() { const updateCompetition = ( id: string, - updates: Partial + updates: Partial, ) => { setData({ ...data, competitions: competitions.map((comp) => - comp.id === id ? { ...comp, ...updates } : comp + comp.id === id ? { ...comp, ...updates } : comp, ), }); }; @@ -127,8 +135,12 @@ export function CompetitionsForm() { return (
- SECTION_OPT // COMPETITIONS -

Competitions

+ + SECTION_OPT // COMPETITIONS + +

+ Competitions +

Hackathons, case studies, academic or professional competitions

@@ -170,7 +182,9 @@ export function CompetitionsForm() {
- +
@@ -190,7 +204,7 @@ export function CompetitionsForm() { updateCompetition(comp.id, { role: e.target.value }) } placeholder="1st Place, Team Lead" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -205,7 +219,7 @@ export function CompetitionsForm() { updateCompetition(comp.id, { date: newDate }); }} required - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -217,7 +231,7 @@ export function CompetitionsForm() { updateCompetition(comp.id, { location: e.target.value }) } placeholder="San Francisco, CA" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -229,7 +243,7 @@ export function CompetitionsForm() { updateCompetition(comp.id, { url: e.target.value }) } placeholder="https://devpost.com/..." - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -239,8 +253,17 @@ export function CompetitionsForm() {
@@ -167,7 +169,7 @@ export function EducationForm() { updateEducation(edu.id, { degree: e.target.value }) } placeholder="Bachelor of Science" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -179,7 +181,7 @@ export function EducationForm() { updateEducation(edu.id, { field: e.target.value }) } placeholder="Computer Science" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -194,7 +196,7 @@ export function EducationForm() { updateEducation(edu.id, { location: e.target.value }) } placeholder="Berkeley, CA" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -206,7 +208,7 @@ export function EducationForm() { updateEducation(edu.id, { gpa: e.target.value }) } placeholder="3.8" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -221,7 +223,7 @@ export function EducationForm() { updateEducation(edu.id, { startDate: value }) } required - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -233,7 +235,7 @@ export function EducationForm() { updateEducation(edu.id, { endDate: value }) } disabled={edu.current} - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150 disabled:opacity-50" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150 disabled:opacity-50" />
@@ -260,11 +262,15 @@ export function EducationForm() {
- +
{(edu.coursework ?? []).length === 0 && ( - No coursework added yet. + + No coursework added yet. + )} {(edu.coursework ?? []).map((course, idx) => ( handleCourseworkKeyDown(e, edu.id)} placeholder="Add a course (e.g. Data Structures)" - className="flex-1 border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="flex-1 border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -243,7 +250,7 @@ export function ExperienceForm() { updateExperience(exp.id, { company: e.target.value }) } placeholder="Tech Corp Inc." - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -257,7 +264,7 @@ export function ExperienceForm() { updateExperience(exp.id, { location: e.target.value }) } placeholder="New York, NY" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -275,12 +282,16 @@ export function ExperienceForm() { [exp.id]: "End date cannot be before start date.", })); } else { - setDateErrors((prev) => { const next = { ...prev }; delete next[exp.id]; return next; }); + setDateErrors((prev) => { + const next = { ...prev }; + delete next[exp.id]; + return next; + }); } updateExperience(exp.id, { startDate: newStart }); }} required - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -296,11 +307,15 @@ export function ExperienceForm() { })); return; // block storing an invalid end date } - setDateErrors((prev) => { const next = { ...prev }; delete next[exp.id]; return next; }); + setDateErrors((prev) => { + const next = { ...prev }; + delete next[exp.id]; + return next; + }); updateExperience(exp.id, { endDate: newEnd }); }} disabled={exp.current} - className={`w-full border bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150 disabled:opacity-50 ${ + className={`w-full border bg-transparent px-3 py-2 focus:border-black transition-all duration-150 disabled:opacity-50 ${ dateErrors[exp.id] ? "border-red-500" : "border-gray-400" }`} /> @@ -340,7 +355,14 @@ export function ExperienceForm() {
@@ -98,7 +96,7 @@ export function LanguagesForm() { .value as ResumeData["languages"][0]["proficiency"], }) } - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" > {PROFICIENCY_LEVELS.map((level) => (
@@ -294,7 +294,7 @@ export function ProjectsForm() { updateProject(project.id, { url: e.target.value }) } placeholder="https://myproject.com" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -306,7 +306,7 @@ export function ProjectsForm() { updateProject(project.id, { githubUrl: e.target.value }) } placeholder="https://github.com/you/repo" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -318,7 +318,7 @@ export function ProjectsForm() { updateProject(project.id, { websiteUrl: e.target.value }) } placeholder="https://myproject.vercel.app" - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" />
@@ -355,7 +355,7 @@ export function ProjectsForm() { placeholder="A full-stack e-commerce platform built with modern technologies..." rows={3} maxLength={MAX_DESCRIPTION_LENGTH} - className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150 resize-none" + className="w-full border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150 resize-none" /> {project.description.length}/{MAX_DESCRIPTION_LENGTH} @@ -430,7 +430,7 @@ export function ProjectsForm() { ) } placeholder="Built scalable microservices architecture..." - className="flex-1 border border-gray-400 bg-transparent px-3 py-2 focus:border-black focus:bg-black focus:text-white transition-all duration-150" + className="flex-1 border border-gray-400 bg-transparent px-3 py-2 focus:border-black transition-all duration-150" /> + + {showSectionManager && ( +
+
+

+ TOGGLE SECTIONS +

+

+ Click to show/hide +

+
+
+ {SECTIONS.map((section) => { + const isHidden = hiddenSections.includes(section.id); + const isPersonal = section.id === "personal"; + return ( + + ); + })} +
+
+ )} + + + + +
+ {visibleSections.map((section, index) => { + const isJustMoved = recentlyMovedId === section.id; + const isActive = activeSection === section.id; + return ( + + ); + })} +
+ + {/* Sidebar Footer with reorder buttons */} +
+
+ + +
+
+ Reordering: {activeSectionLabel} +
+
+ + + {/* MOBILE LAYOUT TABS - Sticky at the top */} +

SECTION NAVIGATION

- Section {Math.max(activeIndex + 1, 1)} of {visibleSections.length} - {hiddenCount > 0 && · {hiddenCount} hidden} + Section {Math.max(activeIndex + 1, 1)} of{" "} + {visibleSections.length} + {hiddenCount > 0 && ( + · {hiddenCount} hidden + )}

-
+
-
-
-
+
+
+
{visibleSections.map((section, index) => { const isJustMoved = recentlyMovedId === section.id; return ( @@ -204,52 +449,30 @@ export function BuilderForm() { key={section.id} data-section-id={section.id} onClick={() => setActiveSection(section.id)} - className={`snap-start px-3 py-2 text-[11px] sm:text-xs font-bold uppercase tracking-wider whitespace-nowrap transition-all duration-300 border ${ + className={`snap-start px-3 py-2 text-[11px] sm:text-xs font-bold uppercase tracking-wider whitespace-nowrap transition-all duration-300 border flex items-center gap-1.5 ${ isJustMoved ? "bg-yellow-400 text-black border-yellow-500 ring-2 ring-yellow-300 scale-105" : activeSection === section.id - ? "bg-black text-white border-black" - : "bg-white text-gray-700 border-gray-300 hover:bg-gray-100" + ? "bg-black text-white border-black" + : "bg-white text-gray-700 border-gray-300 hover:bg-gray-100" }`} - aria-current={activeSection === section.id ? "page" : undefined} + aria-current={ + activeSection === section.id ? "page" : undefined + } > - {String(index + 1).padStart(2, "0")} - {section.label} + + {String(index + 1).padStart(2, "0")} + + {section.label} + {getSectionBadge(section.id)} ); })}
- {/* Reorder controls — clearly labeled with the active section name */} -
- - - {activeSectionLabel} - - -
- - {/* Manage Sections toggle */} -
+ {/* Manage Sections toggle - Mobile */} +
{showSectionManager && ( -
+
-

TOGGLE SECTIONS

-

Click to show/hide

+

+ TOGGLE SECTIONS +

+

+ Click to show/hide +

{SECTIONS.map((section) => { @@ -277,23 +504,35 @@ export function BuilderForm() { ); })} @@ -302,43 +541,25 @@ export function BuilderForm() { )}
+
- {/* Move feedback toast — appears inside the nav area */} -
-
- - {moveFeedback} + {/* Main Content Area */} +
+ {/* Move feedback toast — appears inside the main area top */} + {moveFeedback && ( +
+
+ + + {moveFeedback} + +
+ )} +
+
- -
- - -
- - {/* Form content */} -
- -
); } From 4ace1cf65aecd65ffd84b58034c4a75e82abf4ee Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 24 Jun 2026 15:20:21 +0700 Subject: [PATCH 6/6] feat(pdf): convert ATS PDF template to single column layout --- src/components/pdf/ats-pdf.tsx | 635 ++++++++++++++++++++++++--------- 1 file changed, 476 insertions(+), 159 deletions(-) diff --git a/src/components/pdf/ats-pdf.tsx b/src/components/pdf/ats-pdf.tsx index 076ad6a..3bdda0e 100644 --- a/src/components/pdf/ats-pdf.tsx +++ b/src/components/pdf/ats-pdf.tsx @@ -11,208 +11,525 @@ import { ensureHref } from "@/lib/url-helpers"; import { type PdfLabels, getPdfLabels, getDateLocale } from "@/lib/pdf-labels"; import { getResumeBulletSymbol } from "@/lib/bullet-symbol"; +const s = StyleSheet.create({ + page: { + backgroundColor: "#FFFFFF", + fontFamily: "Helvetica", + fontSize: 8, + paddingHorizontal: 40, + paddingVertical: 40, + }, + /* Header — clean centered layout for standard ATS */ + header: { + alignItems: "center", + marginBottom: 8, + }, + name: { + fontSize: 18, + fontWeight: 900, + textTransform: "uppercase", + letterSpacing: 0.5, + marginBottom: 4, + }, + contactRow: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "center", + gap: 8, + fontSize: 8, + color: "#444444", + }, + contactItem: { + fontSize: 8, + color: "#444444", + textDecoration: "none", + }, + bulletSeparator: { + color: "#888888", + }, + /* Section title — standard ATS format */ + sectionTitle: { + fontSize: 9, + fontWeight: 900, + textTransform: "uppercase", + letterSpacing: 1, + marginTop: 10, + marginBottom: 6, + borderBottomWidth: 0.75, + borderBottomColor: "#111111", + paddingBottom: 2, + }, -const s = StyleSheet.create({ - page: { backgroundColor: "#FFFFFF", fontFamily: "Helvetica", fontSize: 8, padding: 28 }, - /* Header — single dense row */ - headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 2 }, - name: { fontSize: 16, fontWeight: 900, letterSpacing: -0.5 }, - contactRow: { flexDirection: "row", flexWrap: "wrap", gap: 10 }, - contactText: { fontSize: 7, color: "#666666" }, - divider: { height: 0.5, backgroundColor: "#000000", marginVertical: 6 }, - /* Summary — compact */ - summary: { fontSize: 8, lineHeight: 1.4, color: "#555555", marginBottom: 6 }, - /* Two-column layout */ - columns: { flexDirection: "row", gap: 20 }, - mainCol: { flex: 3 }, - sideCol: { flex: 1, borderLeftWidth: 0.5, borderLeftColor: "#DDDDDD", paddingLeft: 14 }, - /* Section */ - sectionTitle: { fontSize: 8, fontWeight: 900, textTransform: "uppercase", letterSpacing: 2, marginBottom: 5, borderBottomWidth: 0.5, borderBottomColor: "#DDDDDD", paddingBottom: 3 }, - /* Experience */ - expItem: { marginBottom: 7 }, - expTopRow: { flexDirection: "row", justifyContent: "space-between" }, - expPosition: { fontSize: 9, fontWeight: 700 }, - expDate: { fontSize: 7, color: "#999999" }, - expCompany: { fontSize: 7, color: "#888888", marginBottom: 2 }, - expDesc: { fontSize: 7.5, lineHeight: 1.4, color: "#555555", marginBottom: 2 }, - bulletRow: { flexDirection: "row", marginBottom: 1 }, - bulletDot: { width: 6, fontSize: 7, color: "#000000" }, - bulletText: { flex: 1, fontSize: 7.5, lineHeight: 1.3, color: "#555555" }, - /* Projects */ - projItem: { marginBottom: 6 }, - projName: { fontSize: 8, fontWeight: 700, marginBottom: 1 }, - projTech: { fontSize: 6.5, color: "#AAAAAA", marginBottom: 2 }, - projDesc: { fontSize: 7.5, lineHeight: 1.3, color: "#555555" }, - /* Side sections */ - skillCat: { marginBottom: 5 }, - skillCatName: { fontSize: 7, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 2 }, - skillItems: { fontSize: 7, color: "#666666", lineHeight: 1.3 }, - eduItem: { marginBottom: 5 }, - eduDegree: { fontSize: 8, fontWeight: 700, marginBottom: 1 }, - eduSchool: { fontSize: 7, color: "#666666", marginBottom: 1 }, - eduDate: { fontSize: 6.5, color: "#999999" }, - sectionGap: { marginTop: 10 }, + /* Summary */ + summaryText: { + fontSize: 8, + lineHeight: 1.4, + color: "#222222", + }, + + /* Experience & standard item layout */ + itemContainer: { + marginBottom: 8, + }, + rowBetween: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "baseline", + marginBottom: 2, + }, + boldText: { + fontSize: 8.5, + fontWeight: 700, + color: "#111111", + }, + italicText: { + fontSize: 8, + fontStyle: "italic", + color: "#444444", + }, + dateText: { + fontSize: 8, + color: "#444444", + }, + descriptionText: { + fontSize: 8, + lineHeight: 1.3, + color: "#222222", + marginBottom: 3, + }, + bulletRow: { + flexDirection: "row", + marginBottom: 2, + paddingLeft: 8, + }, + bulletDot: { + width: 8, + fontSize: 8, + color: "#111111", + }, + bulletText: { + flex: 1, + fontSize: 8, + lineHeight: 1.3, + color: "#222222", + }, + + /* Skills */ + skillsGrid: { + flexDirection: "column", + gap: 3, + }, + skillsRow: { + flexDirection: "row", + fontSize: 8, + lineHeight: 1.3, + }, + skillCategory: { + fontWeight: 700, + width: 100, + color: "#111111", + }, + skillList: { + flex: 1, + color: "#222222", + }, + + /* Languages */ + languagesList: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + fontSize: 8, + }, }); -interface PDFTemplateProps { data: ResumeData; fontFamily?: string; labels?: PdfLabels; dateLocale?: string } +interface PDFTemplateProps { + data: ResumeData; + fontFamily?: string; + labels?: PdfLabels; + dateLocale?: string; +} + +export function AtsPDF({ + data, + fontFamily, + labels, + dateLocale, +}: PDFTemplateProps) { + const { + personalInfo, + experience, + education, + skills, + projects, + certifications, + competitions = [], + languages, + } = data; -export function AtsPDF({ data, fontFamily, labels, dateLocale }: PDFTemplateProps) { - const { personalInfo, experience, education, skills, projects, certifications, languages } = data; const bulletSymbol = getResumeBulletSymbol(data, "•"); + const completeEducation = education.filter( - (edu) => edu.institution.trim() && edu.degree.trim() && edu.field.trim() && edu.startDate.trim() + (edu) => + edu.institution.trim() && + edu.degree.trim() && + edu.field.trim() && + edu.startDate.trim(), ); + const l = labels ?? getPdfLabels("en"); const dl = dateLocale ?? getDateLocale("en"); const fmtDate = (d: string) => { if (!d) return ""; - return new Date(d + "-01").toLocaleDateString(dl, { month: "short", year: "numeric" }); + return new Date(d + "-01").toLocaleDateString(dl, { + month: "short", + year: "numeric", + timeZone: "UTC", + }); }; - const fmtRange = (st: string, en: string, c: boolean) => `${fmtDate(st)} – ${c ? l.present : fmtDate(en)}`; + + const fmtRange = (st: string, en: string, c: boolean) => + `${fmtDate(st)} – ${c ? l.present : fmtDate(en)}`; return ( - - {/* Header row */} - + + {/* Header */} + {personalInfo.fullName || l.yourName} - {personalInfo.email && {personalInfo.email}} - {personalInfo.phone && {personalInfo.phone}} - {personalInfo.location && {personalInfo.location}} - {personalInfo.website && {l.portfolio}} - {personalInfo.linkedin && LinkedIn} - {personalInfo.github && GitHub} + {personalInfo.email && ( + {personalInfo.email} + )} + {personalInfo.email && personalInfo.phone && ( + + )} + {personalInfo.phone && ( + {personalInfo.phone} + )} + {personalInfo.phone && personalInfo.location && ( + + )} + {personalInfo.location && ( + {personalInfo.location} + )} + {personalInfo.location && personalInfo.website && ( + + )} + {personalInfo.website && ( + + {l.portfolio} + + )} + {(personalInfo.website || personalInfo.location) && + personalInfo.linkedin && } + {personalInfo.linkedin && ( + + LinkedIn + + )} + {personalInfo.linkedin && personalInfo.github && ( + + )} + {personalInfo.github && ( + + GitHub + + )} - {/* Summary */} - {personalInfo.summary && {personalInfo.summary}} - - {/* Two columns */} - - {/* Main column */} - - {/* Experience */} - {experience.length > 0 && ( - - {l.experience} - {experience.map((exp) => ( - - - {exp.position} - {fmtRange(exp.startDate, exp.endDate || "", exp.current)} - - {exp.company}{exp.location ? ` · ${exp.location}` : ""} - {exp.description ? {exp.description} : null} - {exp.highlights.map((h, i) => ( - - {bulletSymbol} - {h} - - ))} + {personalInfo.summary && ( + + {l.summary} + {personalInfo.summary} + + )} + + {/* Skills */} + {skills.length > 0 && ( + + {l.skills} + + {skills.map((cat) => ( + + {cat.category}: + {cat.items.join(", ")} + + ))} + + + )} + + {/* Experience */} + {experience.length > 0 && ( + + {l.experience} + {experience.map((exp) => ( + + + + {exp.position}{" "} + + at {exp.company} + + + + {fmtRange(exp.startDate, exp.endDate || "", exp.current)} + + + {exp.location ? ( + + {exp.location} + + ) : null} + {exp.description ? ( + {exp.description} + ) : null} + {exp.highlights.map((h, i) => ( + + {bulletSymbol} + {h} ))} - )} + ))} + + )} - {/* Projects */} - {projects.length > 0 && ( - - {l.projects} - {projects.map((p) => ( - - {p.name} - {(p.url || p.githubUrl || p.websiteUrl) && ( - - {p.url && {l.project}} - {p.url && (p.githubUrl || p.websiteUrl) && |} - {p.githubUrl && GitHub} - {p.githubUrl && p.websiteUrl && |} - {p.websiteUrl && {l.website}} - + {/* Projects */} + {projects.length > 0 && ( + + {l.projects} + {projects.map((p) => ( + + + + {p.name} + {p.technologies.length > 0 && ( + + [{p.technologies.join(" · ")}] + )} - {p.technologies.length > 0 && {p.technologies.join(" · ")}} - {p.description ? {p.description} : null} - {p.highlights.map((h, i) => ( - - {bulletSymbol} - {h} - - ))} - ))} - - )} - - {/* Certifications */} - {certifications.length > 0 && ( - - {l.certifications} - {certifications.map((cert) => ( - - - {cert.name} - {cert.date ? {fmtDate(cert.date)} : null} - - {cert.issuer ? {cert.issuer} : null} - {cert.url ? {l.viewCertificate} : null} + + {p.url && ( + + {l.project} + + )} + {p.url && (p.githubUrl || p.websiteUrl) && ( + | + )} + {p.githubUrl && ( + + GitHub + + )} + {p.githubUrl && p.websiteUrl && ( + | + )} + {p.websiteUrl && ( + + {l.website} + + )} + + + {p.description ? ( + {p.description} + ) : null} + {p.highlights.map((h, i) => ( + + {bulletSymbol} + {h} ))} - )} + ))} + )} - {/* Sidebar */} - - {/* Skills */} - {skills.length > 0 && ( - - {l.skills} - {skills.map((cat) => ( - - {cat.category} - {cat.items.join(", ")} + {/* Competitions */} + {competitions.length > 0 && ( + + {l.competitions} + {competitions.map((comp) => ( + + + + {comp.name} + {comp.role && ( + + — {comp.role} + + )} + + {comp.date && ( + {fmtDate(comp.date)} + )} + + {comp.location && ( + {comp.location} + )} + {comp.url && ( + + {l.project} + + )} + {comp.description ? ( + {comp.description} + ) : null} + {comp.highlights.map((h, i) => ( + + {bulletSymbol} + {h} ))} - )} + ))} + + )} - {/* Education */} - {completeEducation.length > 0 && ( - - {l.education} - {completeEducation.map((edu) => ( - - {edu.degree} - {edu.field} — {edu.institution} - {fmtRange(edu.startDate, edu.endDate || "", edu.current)} - {edu.gpa ? {l.gpa}: {edu.gpa} : null} - {(edu.coursework ?? []).length > 0 ? ( - Relevant Coursework: {(edu.coursework ?? []).join(", ")} + {/* Certifications */} + {certifications.length > 0 && ( + + {l.certifications} + {certifications.map((cert) => ( + + + + {cert.name} + {cert.issuer ? ( + + {" "} + — {cert.issuer} + ) : null} - - ))} + + {cert.date && ( + {fmtDate(cert.date)} + )} + + {cert.url && ( + + {l.viewCertificate} + + )} - )} + ))} + + )} - {/* Languages */} - {languages.length > 0 && ( - - {l.languages} - {languages.map((lang) => ( - - {lang.language} - {lang.proficiency.charAt(0).toUpperCase() + lang.proficiency.slice(1)} - - ))} + {/* Education */} + {completeEducation.length > 0 && ( + + {l.education} + {completeEducation.map((edu) => ( + + + + {edu.degree} in {edu.field}{" "} + + — {edu.institution} + + + + {fmtRange(edu.startDate, edu.endDate || "", edu.current)} + + + {edu.gpa && ( + + {l.gpa}: {edu.gpa} + + )} + {(edu.coursework ?? []).length > 0 && ( + + Relevant Coursework: {(edu.coursework ?? []).join(", ")} + + )} - )} + ))} - + )} + + {/* Languages */} + {languages.length > 0 && ( + + {l.languages} + + {languages.map((lang) => ( + + {lang.language}:{" "} + {lang.proficiency.charAt(0).toUpperCase() + + lang.proficiency.slice(1)} + + ))} + + + )} );