243 lines
9.1 KiB
TypeScript
243 lines
9.1 KiB
TypeScript
import { api } from "./api";
|
|
|
|
// Mirrors the backend CvVariantSettings (the lens over the master career profile).
|
|
export type CvSectionSetting = {
|
|
key: string;
|
|
hidden?: boolean;
|
|
title?: string;
|
|
itemOrder?: string[];
|
|
items?: string[];
|
|
presentation?: "rows" | "grid" | "compact" | "bubble" | null;
|
|
columns?: 1 | 2 | null;
|
|
};
|
|
export type CvItemOverride = { hidden?: boolean; title?: string; subtitle?: string; bullets?: string[] };
|
|
export type CvCustomSectionSetting = {
|
|
key: string;
|
|
title?: string;
|
|
items: string[];
|
|
hidden?: boolean;
|
|
presetKey?: string | null;
|
|
contentType?: "paragraphs" | "bullets" | "entries";
|
|
};
|
|
export type CvSkillGroupSetting = { name: string; items: string[] };
|
|
|
|
export type CvVariantSettings = {
|
|
themeId: string;
|
|
accentColor?: string | null;
|
|
headingFont?: string | null;
|
|
bodyFont?: string | null;
|
|
density?: string | null;
|
|
pageSize?: string | null;
|
|
dateFormat?: string | null;
|
|
language?: string | null;
|
|
headline?: string | null;
|
|
textColor?: string | null;
|
|
mutedColor?: string | null;
|
|
headingColor?: string | null;
|
|
backgroundColor?: string | null;
|
|
baseFontSizePt?: number | null;
|
|
headingSizePt?: number | null;
|
|
lineHeight?: number | null;
|
|
pageMarginMm?: number | null;
|
|
sectionGapMm?: number | null;
|
|
entryGapMm?: number | null;
|
|
headingStyle?: "caps-rule" | "underline" | "plain" | "bar" | null;
|
|
headerStyle?: "plain" | "band" | "centered" | "kicker" | null;
|
|
skillsStyle?: "tags" | "text" | "bullets" | "grouped" | null;
|
|
skillGroups?: CvSkillGroupSetting[] | null;
|
|
layout?: "single" | "sidebar-left" | "sidebar-right" | "header-band" | null;
|
|
sidebarWidthMm?: number | null;
|
|
sidebarSections?: string[] | null;
|
|
showPhoto: boolean;
|
|
showPageNumbers: boolean;
|
|
showIcons: boolean;
|
|
sections: CvSectionSetting[];
|
|
overrides: Record<string, CvItemOverride>;
|
|
customSections: CvCustomSectionSetting[];
|
|
};
|
|
|
|
export type CvTheme = {
|
|
id: string;
|
|
name: string;
|
|
category: string;
|
|
description: string;
|
|
layout: string;
|
|
accent: string;
|
|
photoShape: string;
|
|
supportsIcons: boolean;
|
|
atsFriendly: boolean;
|
|
requiresPro: boolean;
|
|
available: boolean;
|
|
swatches: string[];
|
|
supportedSettings?: string[];
|
|
pageMarginMm?: number;
|
|
};
|
|
|
|
// Master profile resolved to sections+entries (GET /api/cv/outline) — what the Content tab edits.
|
|
export type CvOutlineEntry = { key?: string; title?: string; subtitle?: string; meta?: string; bullets: string[]; tags: string[] };
|
|
export type CvOutlineSection = { key: string; title: string; kind: "bullets" | "tags" | "entries"; bullets: string[]; tags: string[]; entries: CvOutlineEntry[] };
|
|
export type CvOutline = { fullName: string; headline?: string; sections: CvOutlineSection[] };
|
|
|
|
export type CvVariantSummary = {
|
|
id: number;
|
|
name: string;
|
|
themeId: string;
|
|
language?: string | null;
|
|
publicSlug: string;
|
|
isPublic: boolean;
|
|
version: number;
|
|
jobApplicationId: number | null;
|
|
jobTitle?: string | null;
|
|
companyName?: string | null;
|
|
updatedAtUtc: string;
|
|
};
|
|
|
|
export type CvVariant = {
|
|
id: number;
|
|
name: string;
|
|
settings: CvVariantSettings;
|
|
isPublic: boolean;
|
|
publicSlug: string;
|
|
version: number;
|
|
jobApplicationId: number | null;
|
|
updatedAtUtc: string;
|
|
};
|
|
|
|
export type CvVariantVersionInfo = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
|
|
export type CvRender = { themeId: string; html: string; suggestedFileName: string };
|
|
|
|
export const AI_ACTIONS: { key: string; label: string }[] = [
|
|
{ key: "improve", label: "Improve writing" },
|
|
{ key: "professional", label: "Professional tone" },
|
|
{ key: "shorten", label: "Shorten" },
|
|
{ key: "expand", label: "Expand" },
|
|
{ key: "grammar", label: "Fix grammar" },
|
|
{ key: "impact", label: "Add measurable impact" },
|
|
{ key: "ats", label: "ATS optimise" },
|
|
{ key: "bullets", label: "Generate bullets" },
|
|
{ key: "summary", label: "Generate summary" },
|
|
{ key: "rewrite", label: "Rewrite" },
|
|
{ key: "tailor", label: "Tailor to role" },
|
|
];
|
|
|
|
export const DEFAULT_SECTION_ORDER = [
|
|
"summary", "experience", "education", "projects", "skills", "certifications", "languages", "awards",
|
|
"publications", "organisations", "interests", "references",
|
|
];
|
|
|
|
export const SECTION_LABELS: Record<string, string> = {
|
|
summary: "Professional Summary",
|
|
experience: "Experience",
|
|
education: "Education",
|
|
projects: "Projects",
|
|
skills: "Skills",
|
|
certifications: "Certifications",
|
|
languages: "Languages",
|
|
interests: "Interests",
|
|
awards: "Awards",
|
|
publications: "Publications",
|
|
organisations: "Organisations",
|
|
references: "References",
|
|
};
|
|
|
|
export const CUSTOM_SECTION_PRESETS = [
|
|
{ key: "courses", title: "Courses", contentType: "entries" as const },
|
|
{ key: "volunteer", title: "Volunteer Experience", contentType: "entries" as const },
|
|
{ key: "additional-experience", title: "Additional Experience", contentType: "entries" as const },
|
|
{ key: "declaration", title: "Declaration", contentType: "paragraphs" as const },
|
|
] as const;
|
|
|
|
const CSS_PIXELS_PER_MM = 96 / 25.4;
|
|
|
|
export type CvPageMetrics = {
|
|
widthMm: number;
|
|
heightMm: number;
|
|
widthPx: number;
|
|
heightPx: number;
|
|
};
|
|
|
|
export function getCvPageMetrics(pageSize?: string | null): CvPageMetrics {
|
|
const letter = pageSize?.trim().toLowerCase() === "letter";
|
|
const widthMm = letter ? 215.9 : 210;
|
|
const heightMm = letter ? 279.4 : 297;
|
|
return {
|
|
widthMm,
|
|
heightMm,
|
|
widthPx: widthMm * CSS_PIXELS_PER_MM,
|
|
heightPx: heightMm * CSS_PIXELS_PER_MM,
|
|
};
|
|
}
|
|
|
|
// Browser layout dimensions are rounded to whole pixels. The small tolerance prevents a page whose
|
|
// min-height rounds up by one pixel from being reported as two pages, while still using ceil for any
|
|
// genuine spill onto the next page.
|
|
export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): number {
|
|
if (!Number.isFinite(contentHeightPx) || !Number.isFinite(pageHeightPx) || pageHeightPx <= 0) return 1;
|
|
return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx));
|
|
}
|
|
|
|
export function normalizeCvLanguage(language?: string | null): string {
|
|
const normalized = language?.trim().toLowerCase();
|
|
return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en";
|
|
}
|
|
|
|
export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
|
|
return {
|
|
themeId,
|
|
showPhoto: false,
|
|
showPageNumbers: false,
|
|
showIcons: true,
|
|
sections: [],
|
|
overrides: {},
|
|
customSections: [],
|
|
};
|
|
}
|
|
|
|
// --- API ---
|
|
// --- Pure helpers (unit-tested) ---
|
|
|
|
// Move an array item from one index to another, returning a new array.
|
|
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
|
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr;
|
|
const next = [...arr];
|
|
const [item] = next.splice(from, 1);
|
|
next.splice(to, 0, item);
|
|
return next;
|
|
}
|
|
|
|
// Wrap the [start,end) selection of `text` with markdown markers, for the rich-text toolbar.
|
|
// Returns the new text and the selection to restore. If nothing is selected, inserts a placeholder.
|
|
export function wrapSelection(
|
|
text: string,
|
|
start: number,
|
|
end: number,
|
|
before: string,
|
|
after: string,
|
|
placeholder = "text",
|
|
): { text: string; selStart: number; selEnd: number } {
|
|
const selected = text.slice(start, end) || placeholder;
|
|
const next = text.slice(0, start) + before + selected + after + text.slice(end);
|
|
return { text: next, selStart: start + before.length, selEnd: start + before.length + selected.length };
|
|
}
|
|
|
|
export const cvBuilderApi = {
|
|
themes: () => api.get<CvTheme[]>("/cv/themes").then((r) => r.data),
|
|
themePreview: (themeId: string) => api.get<CvRender>(`/cv/themes/${encodeURIComponent(themeId)}/preview`).then((r) => r.data),
|
|
outline: () => api.get<CvOutline>("/cv/outline").then((r) => r.data),
|
|
list: () => api.get<CvVariantSummary[]>("/cv/variants").then((r) => r.data),
|
|
create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
|
|
api.post<CvVariant>("/cv/variants", body).then((r) => r.data),
|
|
get: (id: number) => api.get<CvVariant>(`/cv/variants/${id}`).then((r) => r.data),
|
|
save: (id: number, body: { name?: string; settings: CvVariantSettings; source?: string }) =>
|
|
api.put<CvVariant>(`/cv/variants/${id}`, body).then((r) => r.data),
|
|
remove: (id: number) => api.delete(`/cv/variants/${id}`),
|
|
duplicate: (id: number, name?: string) => api.post<CvVariant>(`/cv/variants/${id}/duplicate`, { name }).then((r) => r.data),
|
|
setPublic: (id: number, isPublic: boolean) => api.put<CvVariant>(`/cv/variants/${id}/public`, { isPublic }).then((r) => r.data),
|
|
versions: (id: number) => api.get<CvVariantVersionInfo[]>(`/cv/variants/${id}/versions`).then((r) => r.data),
|
|
restore: (id: number, version: number) => api.post<CvVariant>(`/cv/variants/${id}/versions/${version}/restore`).then((r) => r.data),
|
|
previewSettings: (settings: CvVariantSettings) => api.post<CvRender>("/cv/preview", { settings }).then((r) => r.data),
|
|
aiAssist: (body: { action: string; text: string; role?: string; language?: string; context?: string }) =>
|
|
api.post<{ original: string; result: string }>("/cv/ai/assist", body).then((r) => r.data),
|
|
exportPdfUrl: (id: number) => `/cv/variants/${id}/export-pdf`,
|
|
};
|