feat(cv): redesign document editing with revision-safe letters and writing tools
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user