1250 lines
72 KiB
TypeScript
1250 lines
72 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
|
|
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
|
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import GoogleAuthCard from "../components/GoogleAuthCard";
|
|
import CropImageDialog from "../components/CropImageDialog";
|
|
import { useToast } from "../toast";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import {
|
|
emptyStructuredCv,
|
|
getStructuredCvFieldMetadata,
|
|
joinLines,
|
|
normalizeStructuredCv,
|
|
parseStructuredCvJson,
|
|
splitLines,
|
|
StructuredCvFieldMetadata,
|
|
StructuredCvProfile,
|
|
} from "../profileCv";
|
|
import { JobApplication } from "../types";
|
|
|
|
|
|
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
|
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
|
|
|
type ExtractionRun = {
|
|
id: number;
|
|
trigger: string;
|
|
status: string;
|
|
artifactFileName?: string;
|
|
startedAtUtc: string;
|
|
completedAtUtc?: string;
|
|
appliedAtUtc?: string;
|
|
parserVersion: string;
|
|
normalizerVersion: string;
|
|
llmPromptVersion: string;
|
|
errorMessage?: string;
|
|
};
|
|
|
|
type QueuedCvRunResponse = {
|
|
queued: boolean;
|
|
extractionRunId: number;
|
|
status: string;
|
|
};
|
|
|
|
type JobListResponse = {
|
|
items: JobApplication[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
};
|
|
|
|
type RewriteTemplateOption = {
|
|
id: CvSectionStyle;
|
|
title: string;
|
|
eyebrow: string;
|
|
accent: string;
|
|
blurb: string;
|
|
sampleHeading: string;
|
|
sampleMeta: string;
|
|
sampleBullets: string[];
|
|
};
|
|
|
|
type CvBuilderPreview = {
|
|
templateId: CvSectionStyle;
|
|
html: string;
|
|
suggestedFileName: string;
|
|
fullText: string;
|
|
rewrittenText: string;
|
|
structuredCv: StructuredCvProfile;
|
|
sectionName?: string | null;
|
|
targetRole?: string | null;
|
|
jobApplicationId?: number | null;
|
|
};
|
|
|
|
type PdfCarouselItem = {
|
|
templateId: CvSectionStyle;
|
|
title: string;
|
|
fileName: string;
|
|
pdfUrl?: string;
|
|
status: "loading" | "ready" | "error";
|
|
error?: string;
|
|
};
|
|
|
|
type RewriteRequestPayload = {
|
|
sectionName: string | null;
|
|
style: CvSectionStyle;
|
|
templateId: CvSectionStyle;
|
|
targetRole: string | null;
|
|
jobApplicationId: number | null;
|
|
sourceText: string | null;
|
|
};
|
|
|
|
type MeResponse = {
|
|
provider?: "local" | "google" | "external";
|
|
id?: string;
|
|
email?: string;
|
|
userName?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
displayName?: string;
|
|
profileCvText?: string;
|
|
profileCvStructureJson?: string;
|
|
avatarImageDataUrl?: string;
|
|
roles?: string[];
|
|
googleLink?: {
|
|
linked: boolean;
|
|
email?: string | null;
|
|
linkedAt?: string | null;
|
|
} | null;
|
|
};
|
|
|
|
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
|
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
|
|
const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
|
{
|
|
id: "ats-minimal",
|
|
title: "ATS Minimal",
|
|
eyebrow: "Scanner-friendly",
|
|
accent: "#0f172a",
|
|
blurb: "Compact, direct, and easy for screening systems to parse.",
|
|
sampleHeading: "Senior Backend Engineer",
|
|
sampleMeta: "Acme Systems · Oslo · 2021 - Present",
|
|
sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."]
|
|
},
|
|
{
|
|
id: "harvard",
|
|
title: "Harvard",
|
|
eyebrow: "Traditional",
|
|
accent: "#7f1d1d",
|
|
blurb: "Formal hierarchy and restrained tone for conservative hiring flows.",
|
|
sampleHeading: "Professional Summary",
|
|
sampleMeta: "Clear structure · precise dates · credible language",
|
|
sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."]
|
|
},
|
|
{
|
|
id: "auckland",
|
|
title: "Auckland",
|
|
eyebrow: "Modern sidebar",
|
|
accent: "#0f766e",
|
|
blurb: "Sharper highlights with a more contemporary, design-forward rhythm.",
|
|
sampleHeading: "Selected Impact",
|
|
sampleMeta: "Focused strengths · compact highlights",
|
|
sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."]
|
|
},
|
|
{
|
|
id: "edinburgh",
|
|
title: "Edinburgh",
|
|
eyebrow: "Editorial",
|
|
accent: "#5b21b6",
|
|
blurb: "More personality and stronger section contrast without losing clarity.",
|
|
sampleHeading: "Experience Highlights",
|
|
sampleMeta: "Premium spacing · stronger visual voice",
|
|
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
|
|
},
|
|
{
|
|
id: "monarch",
|
|
title: "Monarch",
|
|
eyebrow: "Executive",
|
|
accent: "#7c2d12",
|
|
blurb: "High-contrast premium presentation for leadership-heavy applications.",
|
|
sampleHeading: "Executive Profile",
|
|
sampleMeta: "Leadership clarity · premium hierarchy",
|
|
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
|
|
},
|
|
{
|
|
id: "fjord",
|
|
title: "Fjord",
|
|
eyebrow: "Technical",
|
|
accent: "#0f4c5c",
|
|
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
|
|
sampleHeading: "Projects & Systems",
|
|
sampleMeta: "Technical depth · practical readability",
|
|
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
|
|
},
|
|
];
|
|
|
|
function initialsFrom(values: Array<string | undefined>) {
|
|
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
|
|
if (joined.length === 0) return "?";
|
|
if (joined.length === 1) {
|
|
const parts = joined[0].split(/[\s@._-]+/).filter(Boolean);
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
}
|
|
return (joined[0][0] + joined[1][0]).toUpperCase();
|
|
}
|
|
|
|
function confidenceTone(confidence?: number) {
|
|
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
|
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
|
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
|
|
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
|
|
}
|
|
|
|
function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
|
|
if (!metadata) return null;
|
|
const tone = confidenceTone(metadata.confidence);
|
|
return (
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
|
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
|
|
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
|
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
|
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
|
|
{metadata.sourceSnippet ? (
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{metadata.sourceSnippet}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export default function ProfilePage() {
|
|
const { toast } = useToast();
|
|
const { t } = useI18n();
|
|
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [me, setMe] = useState<MeResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [uploadingCv, setUploadingCv] = useState(false);
|
|
const [improvingCv, setImprovingCv] = useState(false);
|
|
const [rebuildingCv, setRebuildingCv] = useState(false);
|
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
|
const [cropOpen, setCropOpen] = useState(false);
|
|
|
|
const [email, setEmail] = useState("");
|
|
const [userName, setUserName] = useState("");
|
|
const [firstName, setFirstName] = useState("");
|
|
const [lastName, setLastName] = useState("");
|
|
const [displayName, setDisplayName] = useState("");
|
|
const [headline, setHeadline] = useState("");
|
|
const [profileCvText, setProfileCvText] = useState("");
|
|
const [rewritingSection, setRewritingSection] = useState(false);
|
|
const [cvSection, setCvSection] = useState<CvSectionOption>("");
|
|
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
|
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
|
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
|
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
|
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
|
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
|
|
const [activePdfIndex, setActivePdfIndex] = useState(0);
|
|
const [buildingPdfDeck, setBuildingPdfDeck] = useState(false);
|
|
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
|
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
|
const [parsingCvSections, setParsingCvSections] = useState(false);
|
|
const [reprocessingCv, setReprocessingCv] = useState(false);
|
|
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
|
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
|
const runStatusRef = useRef<Record<number, string>>({});
|
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
pdfCarousel.forEach((item) => {
|
|
if (item.pdfUrl) {
|
|
window.URL.revokeObjectURL(item.pdfUrl);
|
|
}
|
|
});
|
|
};
|
|
}, [pdfCarousel]);
|
|
|
|
const loadProfile = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [profileResponse, runsResponse, jobsResponse] = await Promise.all([
|
|
api.get<MeResponse>("/auth/me"),
|
|
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
|
|
api.get<JobListResponse>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)),
|
|
]);
|
|
const r = profileResponse;
|
|
setMe(r.data);
|
|
setEmail(r.data?.email ?? "");
|
|
setUserName(r.data?.userName ?? "");
|
|
setFirstName(r.data?.firstName ?? "");
|
|
setLastName(r.data?.lastName ?? "");
|
|
setDisplayName(r.data?.displayName ?? "");
|
|
setProfileCvText(r.data?.profileCvText ?? "");
|
|
setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson));
|
|
setExtractionRuns(runsResponse.data ?? []);
|
|
setSavedJobs(jobsResponse.data?.items ?? []);
|
|
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
|
setLoadError(null);
|
|
} catch (error: any) {
|
|
setMe(null);
|
|
setExtractionRuns([]);
|
|
setSavedJobs([]);
|
|
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadProfile();
|
|
}, [loadProfile]);
|
|
|
|
useEffect(() => {
|
|
const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running");
|
|
if (activeRuns.length === 0) return;
|
|
|
|
const timer = window.setInterval(() => {
|
|
void loadProfile();
|
|
}, 4000);
|
|
|
|
return () => window.clearInterval(timer);
|
|
}, [extractionRuns, loadProfile]);
|
|
|
|
useEffect(() => {
|
|
const previous = runStatusRef.current;
|
|
for (const run of extractionRuns) {
|
|
const prior = previous[run.id];
|
|
if ((prior === "queued" || prior === "running") && run.status === "applied") {
|
|
toast(`CV ${run.trigger} completed.`, "success");
|
|
}
|
|
if ((prior === "queued" || prior === "running") && run.status === "failed") {
|
|
toast(run.errorMessage || `CV ${run.trigger} failed.`, "error");
|
|
}
|
|
previous[run.id] = run.status;
|
|
}
|
|
}, [extractionRuns, toast]);
|
|
|
|
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
|
|
const isLocal = me?.provider === "local";
|
|
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
|
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
|
|
|
|
const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession");
|
|
const googleLabel = me?.googleLink?.linked
|
|
? me.googleLink.email
|
|
? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email })
|
|
: t("profileGoogleLinked")
|
|
: t("profileGoogleNotLinked");
|
|
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
|
const latestRun = extractionRuns[0];
|
|
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
|
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
|
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
|
const activePdfItem = pdfCarousel[activePdfIndex] ?? null;
|
|
|
|
const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => {
|
|
items.forEach((item) => {
|
|
if (item.pdfUrl) {
|
|
window.URL.revokeObjectURL(item.pdfUrl);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const buildRewritePayload = useCallback((templateId: CvSectionStyle): RewriteRequestPayload => ({
|
|
sectionName: cvSection || null,
|
|
style: templateId,
|
|
templateId,
|
|
targetRole: cvSectionTargetRole.trim() || null,
|
|
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
|
sourceText: profileCvText.trim() || null,
|
|
}), [cvSection, cvSectionTargetRole, profileCvText, selectedRewriteJob]);
|
|
|
|
const resetPdfCarousel = useCallback(() => {
|
|
setPdfCarousel((current) => {
|
|
releasePdfCarousel(current);
|
|
return [];
|
|
});
|
|
setActivePdfIndex(0);
|
|
}, [releasePdfCarousel]);
|
|
|
|
const savePdfToCarousel = useCallback(async (templateId: CvSectionStyle, download = false) => {
|
|
const template = REWRITE_TEMPLATES.find((option) => option.id === templateId) ?? REWRITE_TEMPLATES[0];
|
|
const payload = buildRewritePayload(templateId);
|
|
const response = await api.post("/profile-cv/export-pdf", payload, { responseType: "blob" });
|
|
const blob = new Blob([response.data], { type: "application/pdf" });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const item: PdfCarouselItem = {
|
|
templateId,
|
|
title: template.title,
|
|
fileName: rewritePreview?.suggestedFileName || `${templateId}-cv.pdf`,
|
|
pdfUrl: url,
|
|
status: "ready",
|
|
};
|
|
|
|
setPdfCarousel((current) => {
|
|
const existing = current.find((entry) => entry.templateId === templateId);
|
|
if (existing?.pdfUrl) {
|
|
window.URL.revokeObjectURL(existing.pdfUrl);
|
|
}
|
|
const next = existing
|
|
? current.map((entry) => (entry.templateId === templateId ? item : entry))
|
|
: [...current, item];
|
|
setActivePdfIndex(next.findIndex((entry) => entry.templateId === templateId));
|
|
return next;
|
|
});
|
|
|
|
if (download) {
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = item.fileName;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
}
|
|
|
|
return item;
|
|
}, [buildRewritePayload, rewritePreview?.suggestedFileName]);
|
|
|
|
const buildPdfCarousel = useCallback(async () => {
|
|
setBuildingPdfDeck(true);
|
|
resetPdfCarousel();
|
|
const orderedTemplates = [selectedRewriteTemplate.id, ...REWRITE_TEMPLATES.map((option) => option.id).filter((id) => id !== selectedRewriteTemplate.id)];
|
|
const seedItems = orderedTemplates.map((templateId) => ({
|
|
templateId,
|
|
title: REWRITE_TEMPLATES.find((option) => option.id === templateId)?.title ?? templateId,
|
|
fileName: `${templateId}-cv.pdf`,
|
|
status: "loading" as const,
|
|
}));
|
|
setPdfCarousel(seedItems);
|
|
setActivePdfIndex(0);
|
|
|
|
for (const templateId of orderedTemplates) {
|
|
try {
|
|
const item = await savePdfToCarousel(templateId, false);
|
|
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? item : entry));
|
|
} catch (error: any) {
|
|
const message = getApiErrorMessage(error, `Failed to generate the ${templateId} PDF preview.`);
|
|
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? { ...entry, status: "error", error: message } : entry));
|
|
}
|
|
}
|
|
|
|
setBuildingPdfDeck(false);
|
|
}, [resetPdfCarousel, savePdfToCarousel, selectedRewriteTemplate.id]);
|
|
|
|
useEffect(() => {
|
|
resetPdfCarousel();
|
|
}, [rewritePreview?.fullText, rewritePreview?.templateId, rewritePreview?.targetRole, resetPdfCarousel]);
|
|
|
|
return (
|
|
<Paper sx={{ mt: 0, p: 2.5 }}>
|
|
<CropImageDialog
|
|
open={cropOpen}
|
|
file={avatarFile}
|
|
onClose={() => {
|
|
setCropOpen(false);
|
|
setAvatarFile(null);
|
|
}}
|
|
onSave={async (blob) => {
|
|
const file = new File([blob], "avatar.png", { type: "image/png" });
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
setUploadingAvatar(true);
|
|
try {
|
|
const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev));
|
|
setCropOpen(false);
|
|
setAvatarFile(null);
|
|
toast(t("profileImageUpdated"), "success");
|
|
} catch (e: any) {
|
|
toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error");
|
|
} finally {
|
|
setUploadingAvatar(false);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{loadError ? (
|
|
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
|
|
Unable to load profile.
|
|
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
|
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
|
|
<input
|
|
ref={avatarInputRef}
|
|
type="file"
|
|
accept={AVATAR_UPLOAD_ACCEPT}
|
|
style={{ display: "none" }}
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0] ?? null;
|
|
event.target.value = "";
|
|
if (!file) return;
|
|
setAvatarFile(file);
|
|
setCropOpen(true);
|
|
}}
|
|
/>
|
|
<Button variant="outlined" size="small" startIcon={<PhotoCameraOutlinedIcon />} disabled={!isLocal || uploadingAvatar} onClick={() => avatarInputRef.current?.click()}>
|
|
{uploadingAvatar ? t("profileUploading") : t("profileChangeImage")}
|
|
</Button>
|
|
{me?.avatarImageDataUrl ? (
|
|
<Button
|
|
variant="text"
|
|
size="small"
|
|
color="inherit"
|
|
startIcon={<DeleteOutlineIcon />}
|
|
disabled={!isLocal || uploadingAvatar}
|
|
onClick={async () => {
|
|
setUploadingAvatar(true);
|
|
try {
|
|
await api.delete("/auth/avatar");
|
|
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
|
|
toast(t("profileImageRemoved"), "success");
|
|
} catch (e: any) {
|
|
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
|
|
} finally {
|
|
setUploadingAvatar(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("profileRemoveImage")}
|
|
</Button>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
|
{t("profileTitle")}
|
|
</Typography>
|
|
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
|
|
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
|
|
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
|
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
|
</Box>
|
|
</Box>
|
|
|
|
<GoogleAuthCard />
|
|
|
|
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
|
<Typography variant="h6">{t("profileAccountSection")}</Typography>
|
|
{!isLocal ? (
|
|
<Alert severity="info" sx={{ mt: 1 }}>
|
|
{t("profileReadOnlyInfo")}
|
|
</Alert>
|
|
) : null}
|
|
</Box>
|
|
|
|
<TextField label={t("profileDisplayName")} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
|
|
<TextField label={t("profileUsername")} value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
|
|
<TextField label={t("profileFirstName")} value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
|
|
<TextField label={t("profileLastName")} value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
|
|
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
|
|
<TextField
|
|
label={t("profileHeadline")}
|
|
value={headline}
|
|
onChange={(e) => setHeadline(e.target.value)}
|
|
helperText={t("profileHeadlineHelp")}
|
|
fullWidth
|
|
/>
|
|
|
|
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
|
<Box>
|
|
<Typography variant="h6">{t("profileMasterCv")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{t("profileMasterCvBody")}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<input
|
|
ref={cvInputRef}
|
|
type="file"
|
|
accept={CV_UPLOAD_ACCEPT}
|
|
style={{ display: "none" }}
|
|
onChange={async (event) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = "";
|
|
if (!file) return;
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
setUploadingCv(true);
|
|
try {
|
|
await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
|
await loadProfile();
|
|
toast(t("profileCvUploaded"), "success");
|
|
} catch (e: any) {
|
|
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
|
|
} finally {
|
|
setUploadingCv(false);
|
|
}
|
|
}}
|
|
/>
|
|
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
|
|
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
|
onClick={async () => {
|
|
setRebuildingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
|
await loadProfile();
|
|
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
|
|
} catch (e: any) {
|
|
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
|
|
} finally {
|
|
setRebuildingCv(false);
|
|
}
|
|
}}
|
|
>
|
|
{rebuildingCv ? t("profileCvRebuilding") : t("profileCvRebuild")}
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
|
onClick={async () => {
|
|
setImprovingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
|
await loadProfile();
|
|
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
|
|
} catch (e: any) {
|
|
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
|
} finally {
|
|
setImprovingCv(false);
|
|
}
|
|
}}
|
|
>
|
|
{improvingCv ? t("profileCvImproving") : t("profileCvImprove")}
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
|
|
onClick={async () => {
|
|
setReprocessingCv(true);
|
|
try {
|
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
|
|
await loadProfile();
|
|
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
|
|
} catch (e: any) {
|
|
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
|
|
} finally {
|
|
setReprocessingCv(false);
|
|
}
|
|
}}
|
|
>
|
|
{reprocessingCv ? t("profileCvReprocessing") : t("profileCvReprocess")}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
|
|
<Alert severity="info" sx={{ mb: 2, borderRadius: 2.5 }}>
|
|
{t("profileCvStructuredDefaultHint")}
|
|
</Alert>
|
|
<Accordion disableGutters elevation={0} sx={{ mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", "&:before": { display: "none" } }}>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, alignItems: "center", width: "100%", pr: 1 }}>
|
|
<Box>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvRawPanelTitle")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvRawPanelHelp")}</Typography>
|
|
</Box>
|
|
<Chip size="small" label={t("profileCvSectionWordCount", { count: cvWordCount })} />
|
|
</Box>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<TextField
|
|
label={t("profileCvTextLabel")}
|
|
value={profileCvText}
|
|
onChange={(e) => setProfileCvText(e.target.value)}
|
|
helperText={t("profileCvTextHelp")}
|
|
multiline
|
|
minRows={12}
|
|
disabled={!isLocal}
|
|
fullWidth
|
|
/>
|
|
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
|
|
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
|
{t("profileCopyCvText")}
|
|
</Button>
|
|
</Box>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
|
<Box>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvExtractionHistory")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryHelp")}</Typography>
|
|
</Box>
|
|
{structuredCv.metadata.profileVersion ? <Chip label={t("profileCvProfileVersion", { count: structuredCv.metadata.profileVersion })} size="small" /> : null}
|
|
</Box>
|
|
{latestRun ? (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
|
|
{extractionRuns.map((run) => (
|
|
<Box key={run.id} sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: run.id === structuredCv.metadata.appliedExtractionRunId ? "primary.main" : "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 0.75 }}>
|
|
<Typography variant="overline">{run.trigger}</Typography>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
|
<Chip size="small" label={run.status} color={run.status === "applied" ? "success" : run.status === "failed" ? "error" : "default"} variant={run.status === "applied" ? "filled" : "outlined"} />
|
|
{run.id === structuredCv.metadata.appliedExtractionRunId ? <Chip size="small" color="primary" label={t("profileCvCurrentRun")} /> : null}
|
|
</Box>
|
|
</Box>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75 }}>
|
|
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()}
|
|
</Typography>
|
|
{run.errorMessage ? (
|
|
<Typography variant="caption" sx={{ color: "error.main", display: "block", mt: 0.75 }}>
|
|
{run.errorMessage}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
) : (
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryEmpty")}</Typography>
|
|
)}
|
|
</Box>
|
|
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
|
<Box>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructureOverview")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureOverviewHelp")}</Typography>
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
|
|
onClick={async () => {
|
|
setParsingCvSections(true);
|
|
try {
|
|
const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText });
|
|
setStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
|
|
toast(t("profileCvStructureParsed"), "success");
|
|
} catch (e: any) {
|
|
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
|
|
} finally {
|
|
setParsingCvSections(false);
|
|
}
|
|
}}
|
|
>
|
|
{parsingCvSections ? t("profileCvStructureParsing") : t("profileCvStructureParse")}
|
|
</Button>
|
|
</Box>
|
|
{structuredCv.sections.length > 0 ? (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
{structuredCv.sections.map((section) => {
|
|
const safeContent = typeof section.content === "string" ? section.content : "";
|
|
const safeWordCount = Number.isFinite(Number(section.wordCount)) ? Number(section.wordCount) : (safeContent.trim() ? safeContent.trim().split(/\s+/).length : 0);
|
|
return (
|
|
<Box key={section.name} sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 0.75 }}>
|
|
<Typography variant="overline">{section.name}</Typography>
|
|
<Chip size="small" label={t("profileCvSectionWordCount", { count: safeWordCount })} />
|
|
</Box>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", whiteSpace: "pre-wrap" }}>{safeContent.slice(0, 280)}{safeContent.length > 280 ? "…" : ""}</Typography>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
) : (
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureEmpty")}</Typography>
|
|
)}
|
|
</Box>
|
|
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
|
<Box sx={{ mb: 1.5 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
<Box>
|
|
<TextField label={t("profileCvContactFullName")} value={structuredCv.contact.fullName ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth />
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.fullName")} />
|
|
</Box>
|
|
<Box>
|
|
<TextField label={t("profileCvContactHeadline")} value={structuredCv.contact.headline ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth />
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.headline")} />
|
|
</Box>
|
|
<Box>
|
|
<TextField label={t("profileCvContactEmail")} value={structuredCv.contact.email ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth />
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.email")} />
|
|
</Box>
|
|
<Box>
|
|
<TextField label={t("profileCvContactPhone")} value={structuredCv.contact.phone ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth />
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.phone")} />
|
|
</Box>
|
|
<Box>
|
|
<TextField label={t("profileCvContactLocation")} value={structuredCv.contact.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth />
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.location")} />
|
|
</Box>
|
|
<TextField label={t("profileCvContactWebsite")} value={structuredCv.contact.website ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, website: e.target.value || undefined } }))} fullWidth />
|
|
<TextField label={t("profileCvContactLinkedIn")} value={structuredCv.contact.linkedIn ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, linkedIn: e.target.value || undefined } }))} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
<Box>
|
|
<TextField
|
|
label={t("profileCvStructuredSummary")}
|
|
value={joinLines(structuredCv.summary)}
|
|
onChange={(e) => setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))}
|
|
helperText={t("profileCvStructuredListHelp")}
|
|
multiline
|
|
minRows={5}
|
|
fullWidth
|
|
/>
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "summary")} />
|
|
</Box>
|
|
<Box>
|
|
<TextField
|
|
label={t("profileCvStructuredSkills")}
|
|
value={joinLines(structuredCv.skills)}
|
|
onChange={(e) => setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))}
|
|
helperText={t("profileCvStructuredListHelp")}
|
|
multiline
|
|
minRows={5}
|
|
fullWidth
|
|
/>
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "skills")} />
|
|
</Box>
|
|
<Box>
|
|
<TextField
|
|
label={t("profileCvStructuredInterests")}
|
|
value={joinLines(structuredCv.interests)}
|
|
onChange={(e) => setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))}
|
|
helperText={t("profileCvStructuredListHelp")}
|
|
multiline
|
|
minRows={4}
|
|
fullWidth
|
|
/>
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "interests")} />
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
|
|
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, languages: [...prev.languages, { name: "", level: "", notes: "" }] }))}>{t("profileCvStructuredAddLanguage")}</Button>
|
|
</Box>
|
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "languages")} />
|
|
{structuredCv.languages.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
|
{structuredCv.languages.map((language, index) => (
|
|
<Box key={`language-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr auto" }, gap: 1 }}>
|
|
<TextField label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, level: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, notes: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, languages: prev.languages.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
|
|
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, jobs: [...prev.jobs, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }] }))}>{t("profileCvStructuredAddJob")}</Button>
|
|
</Box>
|
|
{structuredCv.jobs.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
|
{structuredCv.jobs.map((job, index) => (
|
|
<Box key={`job-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
|
|
<TextField label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, company: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
<TextField label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || entry.isCurrent } : entry) }))} fullWidth />
|
|
</Box>
|
|
<TextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, bullets: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={5} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
|
<TextField label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, skills: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
|
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
|
|
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
|
|
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, education: [...prev.education, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }] }))}>{t("profileCvStructuredAddEducation")}</Button>
|
|
</Box>
|
|
{structuredCv.education.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
|
{structuredCv.education.map((education, index) => (
|
|
<Box key={`education-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
|
|
<TextField label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, qualification: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, institution: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
|
<TextField label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<TextField label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined } : entry) }))} fullWidth />
|
|
</Box>
|
|
<TextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, details: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
|
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
|
|
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, education: prev.education.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
|
|
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, otherSections: [...prev.otherSections, { title: "", items: [] }] }))}>{t("profileCvStructuredAddOtherSection")}</Button>
|
|
</Box>
|
|
{structuredCv.otherSections.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
|
{structuredCv.otherSections.map((section, index) => (
|
|
<Box key={`other-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto" }, gap: 1 }}>
|
|
<TextField label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth />
|
|
<Button color="inherit" onClick={() => setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.filter((_, entryIndex) => entryIndex !== index) }))}>{t("profileCvStructuredRemove")}</Button>
|
|
<TextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(e) => setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, items: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
|
|
<Box>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", maxWidth: 720 }}>
|
|
Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it.
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Chip size="small" variant="outlined" label={cvSection || "Whole CV rewrite"} />
|
|
{selectedRewriteJob ? <Chip size="small" color="primary" variant="outlined" label={`Saved job · ${selectedRewriteJob.jobTitle}`} /> : null}
|
|
{rewriteReady ? <Chip size="small" color="success" label="Preview ready" /> : null}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
|
{REWRITE_TEMPLATES.map((option) => {
|
|
const selected = option.id === cvSectionStyle;
|
|
return (
|
|
<Paper
|
|
key={option.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => setCvSectionStyle(option.id)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
setCvSectionStyle(option.id);
|
|
}
|
|
}}
|
|
sx={{
|
|
p: 1.5,
|
|
borderRadius: 3.5,
|
|
cursor: "pointer",
|
|
border: "1px solid",
|
|
borderColor: selected ? "primary.main" : "divider",
|
|
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.18), 0 12px 30px rgba(15,23,42,0.08)" : "0 6px 18px rgba(15,23,42,0.04)",
|
|
background: selected ? `linear-gradient(180deg, ${option.accent}12 0%, rgba(255,255,255,0.96) 100%)` : "background.paper",
|
|
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
|
|
'&:hover': { transform: 'translateY(-2px)' },
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.14em' }}>{option.eyebrow}</Typography>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
|
</Box>
|
|
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
|
|
<ZoomInOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
<Box sx={{ p: 1.25, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 160 }}>
|
|
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 800, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
|
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
|
|
{option.sampleBullets.map((bullet) => (
|
|
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}>• {bullet}</Typography>
|
|
))}
|
|
</Box>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{option.blurb}</Typography>
|
|
</Paper>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
|
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
|
<MenuItem value="">Whole CV</MenuItem>
|
|
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
|
|
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
|
|
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
|
|
<MenuItem value="Selected Achievements">{t("profileCvSectionAchievements")}</MenuItem>
|
|
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<TextField
|
|
label={t("profileCvSectionTargetRole")}
|
|
value={cvSectionTargetRole}
|
|
onChange={(e) => setCvSectionTargetRole(e.target.value)}
|
|
fullWidth
|
|
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
|
/>
|
|
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
|
<InputLabel>Saved job context</InputLabel>
|
|
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
|
<MenuItem value="">None</MenuItem>
|
|
{savedJobs.map((job) => (
|
|
<MenuItem key={job.id} value={String(job.id)}>{job.jobTitle} · {job.company?.name ?? "Unknown company"}</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Builder output</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Button
|
|
variant="contained"
|
|
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
|
onClick={async () => {
|
|
setRewritingSection(true);
|
|
resetPdfCarousel();
|
|
try {
|
|
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle));
|
|
setRewritePreview(res.data);
|
|
toast(t("profileCvSectionRewritten"), "success");
|
|
} catch (e: any) {
|
|
toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error");
|
|
} finally {
|
|
setRewritingSection(false);
|
|
}
|
|
}}
|
|
>
|
|
{rewritingSection ? t("profileCvSectionRewriting") : rewriteReady ? "Refresh preview" : "Build preview"}
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
disabled={!rewriteReady || downloadingPdf}
|
|
onClick={async () => {
|
|
setDownloadingPdf(true);
|
|
try {
|
|
await savePdfToCarousel(cvSectionStyle, true);
|
|
toast("CV PDF downloaded and added to the carousel.", "success");
|
|
} catch (e: any) {
|
|
toast(getApiErrorMessage(e, "Failed to export the CV PDF."), "error");
|
|
} finally {
|
|
setDownloadingPdf(false);
|
|
}
|
|
}}
|
|
>
|
|
{downloadingPdf ? "Generating PDF…" : "Download PDF"}
|
|
</Button>
|
|
<Button
|
|
variant="text"
|
|
disabled={!rewriteReady || buildingPdfDeck}
|
|
onClick={buildPdfCarousel}
|
|
>
|
|
{buildingPdfDeck ? "Building PDF carousel…" : "Build PDF carousel"}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
|
|
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
|
|
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
|
|
</Box>
|
|
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
|
{rewriteReady ? (
|
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
|
|
) : (
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
|
|
)}
|
|
</Box>
|
|
<Box sx={{ mt: 1.25, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
|
<Button variant="text" disabled={!rewriteReady} onClick={() => navigator.clipboard.writeText(rewritePreview?.fullText ?? "")}>{t("profileCopyCvText")}</Button>
|
|
<Button
|
|
variant="contained"
|
|
disabled={!rewriteReady}
|
|
onClick={() => {
|
|
setProfileCvText(rewritePreview?.fullText ?? "");
|
|
if (rewritePreview?.structuredCv) setStructuredCv(normalizeStructuredCv(rewritePreview.structuredCv));
|
|
}}
|
|
>
|
|
Replace master CV
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
|
|
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>PDF carousel</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`}
|
|
</Typography>
|
|
</Box>
|
|
{activePdfItem?.fileName ? <Chip size="small" variant="outlined" label={activePdfItem.fileName} /> : rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
|
</Box>
|
|
|
|
{pdfCarousel.length > 0 ? (
|
|
<>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.25 }}>
|
|
{pdfCarousel.map((item, index) => (
|
|
<Button
|
|
key={item.templateId}
|
|
size="small"
|
|
variant={index === activePdfIndex ? "contained" : "outlined"}
|
|
color={item.status === "error" ? "error" : item.status === "ready" ? "primary" : "inherit"}
|
|
onClick={() => setActivePdfIndex(index)}
|
|
>
|
|
{item.title}
|
|
</Button>
|
|
))}
|
|
</Box>
|
|
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
|
{activePdfItem?.status === "ready" && activePdfItem.pdfUrl ? (
|
|
<iframe title={`${activePdfItem.title} PDF preview`} src={activePdfItem.pdfUrl} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
|
) : activePdfItem?.status === "error" ? (
|
|
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
|
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem.title} PDF unavailable</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{activePdfItem.error || "This template could not be rendered as a PDF right now."}</Typography>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
|
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem?.title || "Preparing PDF preview"}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{buildingPdfDeck ? "The carousel is generating PDFs across the current template set." : "Generate the PDF carousel to inspect rendered export files without leaving the page."}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</>
|
|
) : (
|
|
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
|
{rewriteReady ? (
|
|
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
|
) : (
|
|
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
|
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, then generate the PDF carousel to compare rendered files template by template.
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
</Box>
|
|
|
|
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
|
|
<DialogContent>
|
|
{rewritePreviewTemplate ? (
|
|
<Box sx={{ p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${rewritePreviewTemplate.accent}12 0%, rgba(255,255,255,0) 100%)` }}>
|
|
<Typography variant="overline" sx={{ color: rewritePreviewTemplate.accent, fontWeight: 800 }}>{rewritePreviewTemplate.eyebrow}</Typography>
|
|
<Typography variant="h6" sx={{ fontWeight: 900, mb: 0.5 }}>{rewritePreviewTemplate.sampleHeading}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{rewritePreviewTemplate.sampleMeta}</Typography>
|
|
{rewritePreviewTemplate.sampleBullets.map((bullet) => (
|
|
<Typography key={bullet} variant="body2" sx={{ mb: 0.75 }}>• {bullet}</Typography>
|
|
))}
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1.5 }}>{rewritePreviewTemplate.blurb}</Typography>
|
|
</Box>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Box>
|
|
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{cvWordCount} words
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{t("profileCvPreferredUploads")}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Button
|
|
variant="contained"
|
|
disabled={!isLocal || loading}
|
|
onClick={async () => {
|
|
setLoading(true);
|
|
try {
|
|
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText, profileCvStructureJson: JSON.stringify(structuredCv) });
|
|
window.localStorage.setItem("profileHeadline", headline.trim());
|
|
await loadProfile();
|
|
toast(t("profileUpdated"), "success");
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
|
|
toast(String(msg), "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("profileSaveChanges")}
|
|
</Button>
|
|
</Box>
|
|
|
|
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
|
<Divider sx={{ mb: 2 }} />
|
|
<Typography variant="h6">{t("profileChangePassword")}</Typography>
|
|
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
|
|
</Box>
|
|
|
|
<TextField label={t("profileCurrentPassword")} type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
|
|
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
|
|
|
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
|
<Button
|
|
variant="outlined"
|
|
disabled={!isLocal || loading}
|
|
onClick={async () => {
|
|
setLoading(true);
|
|
try {
|
|
await api.post("/auth/change-password", { currentPassword, newPassword });
|
|
setCurrentPassword("");
|
|
setNewPassword("");
|
|
toast(t("profilePasswordUpdated"), "success");
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data || e?.message || t("profilePasswordUpdateFailed");
|
|
toast(String(msg), "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("profileUpdatePassword")}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
}
|