feat(cv): redesign document editing with revision-safe letters and writing tools

This commit is contained in:
cesnimda
2026-10-25 22:44:14 +01:00
parent 01e10996c5
commit 2bfaf6c14b
57 changed files with 2859 additions and 118 deletions
@@ -1,10 +1,13 @@
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Stack, TextField, Tooltip, Typography,
Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery, useTheme,
} from "@mui/material";
import RichTextField from "./RichTextField";
import Markdown from "./Markdown";
import { CoverLetterDocumentFields, CoverLetterDocumentPreview } from "./CoverLetterDocument";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RestoreIcon from "@mui/icons-material/Restore";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
@@ -13,7 +16,7 @@ import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
import { getApiErrorMessage } from "../api";
import {
ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi,
ApplicationCv, CoverLetter, CoverLetterDocumentSettings, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi,
} from "../applicationWorkspace";
import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
@@ -470,6 +473,7 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
// ---------- Cover letter ----------
const historySources = new Map<string, TranslationKey>([
["legacy", "historySourceLegacy"],
["manual", "historySourceManual"], ["ai", "historySourceAi"],
["template", "historySourceTemplate"], ["restore", "historySourceRestore"],
]);
@@ -509,13 +513,18 @@ Med vennlig hilsen
} as const;
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const [aiToolbarHost, setAiToolbarHost] = useState<HTMLDivElement | null>(null);
const { language, t } = useI18n();
const workspaceId = useId();
const wide = useMediaQuery(useTheme().breakpoints.up("md"));
const [pane, setPane] = useState("write");
const { confirm } = useConfirm();
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
);
const [draft, setDraft] = useState<string | null>(null);
const [settingsDraft, setSettingsDraft] = useState<CoverLetterDocumentSettings | null>(null);
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
@@ -525,27 +534,38 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
// The textarea is only seeded from the server until the user starts typing, so a reload never
// clobbers unsaved edits.
const text = draft ?? data?.text ?? "";
const dirty = draft !== null && draft !== (data?.text ?? "");
const settings = settingsDraft ?? data?.documentSettings;
const settingsDirty = settingsDraft !== null && JSON.stringify(settingsDraft) !== JSON.stringify(data?.documentSettings);
const dirty = (draft !== null && draft !== (data?.text ?? "")) || settingsDirty;
useEffect(() => {
onDirtyChange?.(dirty);
return () => onDirtyChange?.(false);
}, [dirty, onDirtyChange]);
const save = async (value: string, source = "manual", aiAction?: string) => {
const save = async (value: string, source = "manual", aiAction?: string, exportPdf = false) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction);
const next = !exportPdf || dirty ? await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction, settingsDirty ? settings : undefined) : data;
if (!isCurrent()) return;
setData(next);
setDraft(null);
setSettingsDraft(null);
setDraftAiAction(null);
setError(null);
if (exportPdf) {
if (!Number.isInteger(next.revision) || next.revision <= 0) throw new Error("Missing committed job revision");
const blob = await applicationAssetsApi.exportCoverLetter(jobId, next.revision);
if (!isCurrent()) return;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url; anchor.download = "cover-letter.pdf"; anchor.click(); URL.revokeObjectURL(url);
}
} catch (err) {
if (isCurrent()) setError(assetError(err, "assetsCoverSaveFailed"));
if (isCurrent()) setError(assetError(err, exportPdf ? "letterExportFailed" : "assetsCoverSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
@@ -572,6 +592,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
if (!isCurrent()) return;
setData(next);
setDraft(null);
setSettingsDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
@@ -595,22 +616,41 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy || confirming || loading}
onChange={setDraft}
placeholder={t("assetsCoverPlaceholder")}
/>
{!wide && <Tabs value={pane} onChange={(_, value) => setPane(value)} aria-label={t("assetsLetterWorkspace")} variant="fullWidth">
<Tab value="write" label={t("assetsLetterWrite")} id={`${workspaceId}-write`} aria-controls={`${workspaceId}-write-panel`} />
<Tab value="preview" label={t("assetsLetterPreview")} id={`${workspaceId}-preview`} aria-controls={`${workspaceId}-preview-panel`} />
</Tabs>}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
<Box hidden={!wide && pane !== "write"} id={`${workspaceId}-write-panel`} role={wide ? undefined : "tabpanel"} aria-labelledby={wide ? undefined : `${workspaceId}-write`} sx={{ minWidth: 0 }}>
{settings && <CoverLetterDocumentFields settings={settings} disabled={busy || confirming || loading} onChange={next => { if (!busyRef.current) setSettingsDraft(next); }} />}
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy || confirming || loading}
onChange={value => { if (!busyRef.current) setDraft(value); }}
placeholder={t("assetsCoverPlaceholder")}
/>
<div ref={setAiToolbarHost} />
</Box>
<Box hidden={!wide && pane !== "preview"} id={`${workspaceId}-preview-panel`} role={wide ? undefined : "tabpanel"} aria-labelledby={wide ? undefined : `${workspaceId}-preview`} sx={{ minWidth: 0 }}>
{settings && <Box sx={{ mb: 2 }}><CoverLetterDocumentPreview key={jobId} jobId={jobId} text={text} settings={settings} active={wide || pane === "preview"} /></Box>}
{!settings && <Paper variant="outlined" role="region" aria-labelledby={`${workspaceId}-preview-title`} sx={{ p: 2, borderRadius: 2, overflowWrap: "anywhere" }}>
<Typography id={`${workspaceId}-preview-title`} component="h4" variant="subtitle2">{t("assetsLetterTextPreview")}</Typography>
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2 }}>{t("assetsLetterTextPreviewHelp")}</Typography>
{text.trim() ? <Markdown text={text} /> : <Typography variant="body2" color="text.secondary">{t("assetsLetterTextPreviewEmpty")}</Typography>}
</Paper>}
</Box>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || confirming || loading || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
{t("save")}
</Button>
<Button disabled={busy || confirming || loading || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
<Button disabled={busy || confirming || loading || !dirty} onClick={() => { setDraft(null); setSettingsDraft(null); setDraftAiAction(null); }}>
{t("assetsDiscardChanges")}
</Button>
{settings && <Button variant="outlined" startIcon={<DownloadOutlinedIcon />} disabled={busy || confirming || loading} onClick={() => void save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined, true)}>{t("letterExportPdf")}</Button>}
<Button
disabled={busy || confirming || loading || text.trim().length > 0}
onClick={() => setDraft(COVER_LETTER_TEMPLATES[language])}
@@ -626,6 +666,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<CoverLetterAiAssistant
jobId={jobId}
toolbarHost={aiToolbarHost}
currentText={text}
disabled={busy || confirming || loading || !data}
onApply={(value, aiAction) => { if (!busyRef.current && !loading && data) { setDraft(value); setDraftAiAction(aiAction); } }}
@@ -696,7 +737,7 @@ const COVER_LETTER_ACTIONS = [
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void }) {
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
@@ -711,8 +752,18 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const generate = async () => {
const requestSequence = useRef(0);
useEffect(() => {
requestSequence.current += 1;
setSuggestion(null);
busyRef.current = false;
setBusy(false);
return () => { requestSequence.current += 1; };
}, [currentText, jobId, language, cv?.attachedVariantId, cv?.attachedVersion, canUseAi]);
const generate = async (requestedAction = action) => {
if (busyRef.current || disabled || loadingCv || !canUseAi || !cv?.attachedVariantId) return;
const request = ++requestSequence.current;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
@@ -721,23 +772,32 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
const result = await aiWorkspaceApi.generate(jobId, {
module: "cover-letter",
mode,
action,
action: requestedAction,
targetLanguage: language,
currentText: currentText.trim() || undefined,
extraContext: instructions.trim() || undefined,
});
if (!isCurrent()) return;
if (!isCurrent() || request !== requestSequence.current) return;
const text = result.result.text?.trim() ?? "";
setSuggestion(text ? { text, action } : null);
setSuggestion(text ? { text, action: requestedAction } : null);
} catch (err) {
if (isCurrent()) setError(assetError(err, "coverAiGenerateFailed"));
if (isCurrent() && request === requestSequence.current) {
const sourceRequired = (err as { response?: { data?: unknown } })?.response?.data === "cover_letter_source_required";
setError(sourceRequired ? { kind: "error", message: "", messageKey: "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
}
} finally {
busyRef.current = false;
setBusy(false);
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
}
};
const hasCv = !!cv?.attachedVariantId;
const toolbar = <Stack direction="row" gap={0.5} flexWrap="wrap">
{([['improve', 'cvAiImprove'], ['generate', 'cvAiSuggestContent'], ['grammar', 'cvAiGrammarCheck'], ['shorten', 'cvAiShorter']] as const).map(([key, label]) => (
<Button key={key} size="small" disabled={!canUseAi || !hasCv || busy || disabled || loadingCv || (key !== 'generate' && !currentText.trim())}
onClick={() => { setAction(key); void generate(key); }}>{t(label)}</Button>
))}
{busy && <Button size="small" onClick={() => { requestSequence.current += 1; busyRef.current = false; setBusy(false); setSuggestion(null); }}>{t("cancel")}</Button>}
</Stack>;
return (
<Shell
title={t("coverAiTitle")}
@@ -749,6 +809,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
contentRef={contentRef}
>
<Stack spacing={2}>
{toolbarHost ? createPortal(toolbar, toolbarHost) : toolbar}
{!hasCv ? (
<Alert severity="info">{t("coverAiSelectCv")}</Alert>
) : (
@@ -811,9 +872,10 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { job
</Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion.text}</Typography>
<TextField label={t("coverAiSuggestion")} fullWidth multiline minRows={3} value={suggestion.text} disabled={busy || disabled}
onChange={event => setSuggestion({ ...suggestion, text: event.target.value })} />
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
</Stack>
</Paper>