feat(cv): localise section AI output
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { api } from "./api";
|
||||||
|
import AiSectionAssistant from "./components/cv/AiSectionAssistant";
|
||||||
|
import { I18nProvider } from "./i18n/I18nProvider";
|
||||||
|
import { ToastProvider } from "./toast";
|
||||||
|
|
||||||
|
jest.mock("./accountPlan", () => ({ useAccountPlan: () => ({ canUseAi: true }) }));
|
||||||
|
jest.mock("./api", () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
getApiErrorMessage: (_error: unknown, fallback?: string) => fallback ?? "Request failed.",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderAssistant(onApply = jest.fn()) {
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<AiSectionAssistant
|
||||||
|
sectionName="Erfaring"
|
||||||
|
text="Built reliable APIs"
|
||||||
|
documentLanguage="nb-NO"
|
||||||
|
onApply={onApply}
|
||||||
|
/>
|
||||||
|
</ToastProvider>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
return onApply;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.removeItem("uiLanguage");
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => window.localStorage.removeItem("uiLanguage"));
|
||||||
|
|
||||||
|
test("section AI uses the CV document language and requires explicit apply", async () => {
|
||||||
|
mockedApi.post.mockResolvedValue({ data: { original: "Built reliable APIs", result: "Utviklet driftssikre API-er" } } as never);
|
||||||
|
const onApply = renderAssistant();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
|
||||||
|
expect(screen.getByLabelText("Output language")).toHaveTextContent("Norsk bokmål");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/cv/ai/assist", expect.objectContaining({
|
||||||
|
action: "improve",
|
||||||
|
language: "nb-NO",
|
||||||
|
text: "Built reliable APIs",
|
||||||
|
})));
|
||||||
|
expect(onApply).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByDisplayValue("Utviklet driftssikre API-er")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||||
|
expect(onApply).toHaveBeenCalledWith("Utviklet driftssikre API-er");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("section AI controls render in Bokmål without changing the document language", () => {
|
||||||
|
window.localStorage.setItem("uiLanguage", "nb");
|
||||||
|
renderAssistant();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "AI-forslag" }));
|
||||||
|
expect(screen.getByLabelText("AI-handling")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Språk for resultat")).toHaveTextContent("Norsk bokmål");
|
||||||
|
expect(screen.getByRole("button", { name: "Foreslå" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||||
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
|
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||||
@@ -8,23 +8,30 @@ import { useAccountPlan } from "../../accountPlan";
|
|||||||
import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder";
|
import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder";
|
||||||
import { useToast } from "../../toast";
|
import { useToast } from "../../toast";
|
||||||
import ProFeatureNotice from "../ProFeatureNotice";
|
import ProFeatureNotice from "../ProFeatureNotice";
|
||||||
|
import { useI18n } from "../../i18n/I18nProvider";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sectionName: string;
|
sectionName: string;
|
||||||
text: string;
|
text: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
|
documentLanguage?: string | null;
|
||||||
onApply: (suggestion: string) => void;
|
onApply: (suggestion: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
|
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
|
||||||
|
|
||||||
export default function AiSectionAssistant({ sectionName, text, context, onApply }: Props) {
|
export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, onApply }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { canUseAi } = useAccountPlan();
|
const { canUseAi } = useAccountPlan();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [action, setAction] = useState("improve");
|
const [action, setAction] = useState("improve");
|
||||||
const [suggestion, setSuggestion] = useState("");
|
const [suggestion, setSuggestion] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const normalizedLanguage = documentLanguage === "no" || documentLanguage === "nb" ? "nb-NO" : documentLanguage ?? "en";
|
||||||
|
const [targetLanguage, setTargetLanguage] = useState(normalizedLanguage);
|
||||||
|
|
||||||
|
useEffect(() => setTargetLanguage(normalizedLanguage), [normalizedLanguage]);
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (!text.trim() || !canUseAi) return;
|
if (!text.trim() || !canUseAi) return;
|
||||||
@@ -33,11 +40,12 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
|||||||
const response = await cvBuilderApi.aiAssist({
|
const response = await cvBuilderApi.aiAssist({
|
||||||
action,
|
action,
|
||||||
text,
|
text,
|
||||||
|
language: targetLanguage,
|
||||||
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
|
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
|
||||||
});
|
});
|
||||||
setSuggestion(response.result);
|
setSuggestion(response.result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "AI suggestion failed."), "error");
|
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -46,7 +54,7 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return (
|
return (
|
||||||
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
|
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
|
||||||
AI suggestions
|
{t("cvAiSuggestions")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,10 +62,10 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
|||||||
if (!canUseAi) {
|
if (!canUseAi) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine this section with Pro.">
|
<ProFeatureNotice featureKey="cv-writing-ai" title={t("cvAiProTitle")}>
|
||||||
AI only proposes wording; it never changes CV content without your approval.
|
{t("cvAiProBody")}
|
||||||
</ProFeatureNotice>
|
</ProFeatureNotice>
|
||||||
<Button size="small" onClick={() => setOpen(false)}>Close</Button>
|
<Button size="small" onClick={() => setOpen(false)}>{t("cvAiClose")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,25 +74,39 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
|||||||
<Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
|
<Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
||||||
<TextField select size="small" label="AI action" value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
|
<TextField select size="small" label={t("cvAiAction")} value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
|
||||||
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)}
|
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{actionLabel(item.key, item.label, t)}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? "Working…" : "Suggest"}</Button>
|
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
|
||||||
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>Close</Button>
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? t("cvAiWorking") : t("cvAiSuggest")}</Button>
|
||||||
|
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>{t("cvAiClose")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
{suggestion && <>
|
{suggestion && <>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="overline" color="text.secondary">Current</Typography>
|
<Typography variant="overline" color="text.secondary">{t("cvAiCurrent")}</Typography>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<TextField label="Suggested — edit before applying" multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
|
<TextField label={t("cvAiSuggested")} multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
|
||||||
<Alert severity="info" sx={{ py: 0 }}>Check every fact before applying this suggestion.</Alert>
|
<Alert severity="info" sx={{ py: 0 }}>{t("cvAiCheckFacts")}</Alert>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
<Button size="small" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(`Suggestion applied to ${sectionName}.`, "success"); }}>Apply</Button>
|
<Button size="small" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(t("cvAiApplied", { section: sectionName }), "success"); }}>{t("cvAiApply")}</Button>
|
||||||
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button>
|
<Button size="small" onClick={() => setSuggestion("")}>{t("cvAiReject")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>}
|
</>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Translate = ReturnType<typeof useI18n>["t"];
|
||||||
|
|
||||||
|
function actionLabel(key: string, fallback: string, t: Translate) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
improve: t("cvAiImprove"), professional: t("cvAiProfessional"), shorten: t("cvAiShorten"),
|
||||||
|
grammar: t("cvAiGrammar"), impact: t("cvAiImpact"), bullets: t("cvAiBullets"),
|
||||||
|
};
|
||||||
|
return labels[key] ?? fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -406,6 +406,27 @@ export const translations = {
|
|||||||
cvDashboardChooseTemplate: "Choose a template",
|
cvDashboardChooseTemplate: "Choose a template",
|
||||||
cvDashboardCreating: "Creating…",
|
cvDashboardCreating: "Creating…",
|
||||||
cvDashboardCreateCv: "Create CV",
|
cvDashboardCreateCv: "Create CV",
|
||||||
|
cvAiFailed: "AI suggestion failed.",
|
||||||
|
cvAiSuggestions: "AI suggestions",
|
||||||
|
cvAiProTitle: "Refine this section with Pro.",
|
||||||
|
cvAiProBody: "AI only proposes wording; it never changes CV content without your approval.",
|
||||||
|
cvAiClose: "Close",
|
||||||
|
cvAiAction: "AI action",
|
||||||
|
cvAiLanguage: "Output language",
|
||||||
|
cvAiWorking: "Working…",
|
||||||
|
cvAiSuggest: "Suggest",
|
||||||
|
cvAiCurrent: "Current",
|
||||||
|
cvAiSuggested: "Suggested — edit before applying",
|
||||||
|
cvAiCheckFacts: "Check every fact before applying this suggestion.",
|
||||||
|
cvAiApplied: "Suggestion applied to {section}.",
|
||||||
|
cvAiApply: "Apply",
|
||||||
|
cvAiReject: "Reject",
|
||||||
|
cvAiImprove: "Improve writing",
|
||||||
|
cvAiProfessional: "Make more professional",
|
||||||
|
cvAiShorten: "Make shorter",
|
||||||
|
cvAiGrammar: "Fix grammar",
|
||||||
|
cvAiImpact: "Add measurable impact",
|
||||||
|
cvAiBullets: "Suggest bullet points",
|
||||||
richTextBold: "Bold",
|
richTextBold: "Bold",
|
||||||
richTextBoldPlaceholder: "bold text",
|
richTextBoldPlaceholder: "bold text",
|
||||||
richTextItalic: "Italic",
|
richTextItalic: "Italic",
|
||||||
@@ -2011,6 +2032,27 @@ export const translations = {
|
|||||||
cvDashboardChooseTemplate: "Velg en mal",
|
cvDashboardChooseTemplate: "Velg en mal",
|
||||||
cvDashboardCreating: "Oppretter…",
|
cvDashboardCreating: "Oppretter…",
|
||||||
cvDashboardCreateCv: "Opprett CV",
|
cvDashboardCreateCv: "Opprett CV",
|
||||||
|
cvAiFailed: "AI-forslaget mislyktes.",
|
||||||
|
cvAiSuggestions: "AI-forslag",
|
||||||
|
cvAiProTitle: "Forbedre denne seksjonen med Pro.",
|
||||||
|
cvAiProBody: "AI foreslår bare formuleringer og endrer aldri CV-innhold uten godkjenning fra deg.",
|
||||||
|
cvAiClose: "Lukk",
|
||||||
|
cvAiAction: "AI-handling",
|
||||||
|
cvAiLanguage: "Språk for resultat",
|
||||||
|
cvAiWorking: "Arbeider…",
|
||||||
|
cvAiSuggest: "Foreslå",
|
||||||
|
cvAiCurrent: "Nåværende",
|
||||||
|
cvAiSuggested: "Forslag — rediger før du bruker det",
|
||||||
|
cvAiCheckFacts: "Kontroller alle fakta før du bruker dette forslaget.",
|
||||||
|
cvAiApplied: "Forslaget er brukt i {section}.",
|
||||||
|
cvAiApply: "Bruk",
|
||||||
|
cvAiReject: "Avvis",
|
||||||
|
cvAiImprove: "Forbedre teksten",
|
||||||
|
cvAiProfessional: "Gjør mer profesjonell",
|
||||||
|
cvAiShorten: "Gjør kortere",
|
||||||
|
cvAiGrammar: "Rett grammatikk",
|
||||||
|
cvAiImpact: "Legg til målbar effekt",
|
||||||
|
cvAiBullets: "Foreslå punkter",
|
||||||
richTextBold: "Fet",
|
richTextBold: "Fet",
|
||||||
richTextBoldPlaceholder: "fet tekst",
|
richTextBoldPlaceholder: "fet tekst",
|
||||||
richTextItalic: "Kursiv",
|
richTextItalic: "Kursiv",
|
||||||
|
|||||||
@@ -780,7 +780,7 @@ function SectionRow({
|
|||||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button>
|
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button>
|
||||||
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
||||||
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
||||||
<AiSectionAssistant sectionName={sectionLabel} text={customText} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
<AiSectionAssistant sectionName={sectionLabel} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
||||||
</Stack>
|
</Stack>
|
||||||
) : outlineSection?.kind === "entries" ? (
|
) : outlineSection?.kind === "entries" ? (
|
||||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||||
@@ -788,7 +788,7 @@ function SectionRow({
|
|||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
||||||
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>{t("cvEditorResetMasterProfile")}</Button>}
|
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>{t("cvEditorResetMasterProfile")}</Button>}
|
||||||
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -859,7 +859,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|||||||
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
|
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
|
||||||
value={(ov.bullets ?? entry.bullets).join("\n")}
|
value={(ov.bullets ?? entry.bullets).join("\n")}
|
||||||
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
||||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} documentLanguage={settings.language} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
||||||
{ov.bullets && (
|
{ov.bullets && (
|
||||||
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button>
|
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user