fix(web): label the application form and fix react doctor findings in applications and documents

- Every field in the add/edit application sheet gets an accessible name.
- Enter no longer commits half-converted IME text (new isImeComposing helper).
- Relative-time formatters are cached per locale instead of built per row.
- Import, font fetch and interview/list state handle errors and prop changes without a stale frame.
- The remaining React Compiler bailouts in these features are gone, and the application form and new-document dialog are split into smaller parts.
This commit is contained in:
Amruth Pillai
2026-09-29 18:13:07 +02:00
parent 861feb22eb
commit 38447bd041
19 changed files with 745 additions and 557 deletions
@@ -109,22 +109,23 @@ function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded
const add = async (tailor: boolean) => {
if (!ready) return;
const posting = {
company: company.trim(),
role: role.trim(),
status: stage,
...(parsed?.location ? { location: parsed.location } : {}),
...(parsed?.salary ? { salary: parsed.salary } : {}),
...(parsed?.requirements.length ? { requirements: parsed.requirements } : {}),
...(link ? { sourceUrl: text } : {}),
// The posting's text: read from the page for a link, or what was pasted.
...(parsed?.jobDescription
? { jobDescription: parsed.jobDescription }
: !link && text
? { jobDescription: text.slice(0, MAX_POSTING_CHARS) }
: {}),
};
try {
const applicationId = await create.mutateAsync({
company: company.trim(),
role: role.trim(),
status: stage,
...(parsed?.location ? { location: parsed.location } : {}),
...(parsed?.salary ? { salary: parsed.salary } : {}),
...(parsed?.requirements.length ? { requirements: parsed.requirements } : {}),
...(link ? { sourceUrl: text } : {}),
// The posting's text: read from the page for a link, or what was pasted.
...(parsed?.jobDescription
? { jobDescription: parsed.jobDescription }
: !link && text
? { jobDescription: text.slice(0, MAX_POSTING_CHARS) }
: {}),
});
const applicationId = await create.mutateAsync(posting);
invalidate();
toast.add({ description: t`Added ${role.trim()} at ${company.trim()}` });
onClose();
@@ -30,6 +30,7 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useClosingValue } from "@/hooks/use-closing-value";
import { useConfirm } from "@/hooks/use-confirm";
import { isImeComposing } from "@/libs/keyboard";
import { orpc } from "@/libs/orpc/client";
import { stageSince } from "../next-step";
import { getClosedReasonLabel, getNextStage, getStageColor, getStageLabel, PIPELINE } from "../stages";
@@ -231,7 +232,7 @@ function Detail({ application, onEditDetails, onDeleted }: DetailProps) {
</div>
</header>
<div className="grid gap-6 px-5 py-5">
<div className="grid gap-6 p-5">
<NextStepCard application={application} onScheduleInterview={(entry) => setInterview({ open: true, entry })} />
<SentDocuments application={application} disabled={remove.isPending} />
<Facts application={application} locale={i18n.locale} />
@@ -353,6 +354,7 @@ function InlineFact({ label, value, onSave }: InlineFactProps) {
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (isImeComposing(event)) return;
if (event.key === "Enter") commit();
if (event.key === "Escape") {
event.stopPropagation();
@@ -442,7 +444,6 @@ function Notes({ application }: { application: Application }) {
// Closing the sheet mid-sentence still saves what was typed.
const latest = useRef(notes);
latest.current = notes;
useEffect(
() => () => {
if (latest.current !== saved.current)
@@ -461,7 +462,10 @@ function Notes({ application }: { application: Application }) {
rows={3}
value={notes}
placeholder={t`Anything to remember about this job`}
onChange={(event) => setNotes(event.target.value)}
onChange={(event) => {
latest.current = event.target.value;
setNotes(event.target.value);
}}
/>
</section>
);
@@ -4,7 +4,7 @@ import type { FileAttachment } from "./file-attachment-field";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useId, useState } from "react";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Button } from "@reactive-resume/ui/components/button";
@@ -23,6 +23,7 @@ import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { Combobox } from "@/components/ui/combobox";
import { useClosingValue } from "@/hooks/use-closing-value";
import { isImeComposing } from "@/libs/keyboard";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { FileAttachmentField } from "./file-attachment-field";
@@ -80,6 +81,112 @@ function toForm(app: Application): FormState {
};
}
function toPayload(form: FormState) {
return {
company: form.company.trim(),
role: form.role.trim(),
status: form.status,
location: form.location.trim() || null,
salary: form.salary.trim() || null,
source: form.source.trim() || null,
resumeId: form.resumeId || null,
tags: form.tags,
sourceUrl: form.sourceUrl.trim() || null,
jobDescription: form.jobDescription.trim() || null,
notes: form.notes.trim() || null,
followUpNote: form.followUpNote.trim() || null,
followUpAt: form.followUpAt ? new Date(form.followUpAt) : null,
resumeFileUrl: form.resumeFile?.url ?? null,
resumeFileName: form.resumeFile?.name ?? null,
coverLetterUrl: form.coverLetter?.url ?? null,
coverLetterName: form.coverLetter?.name ?? null,
};
}
type AutofillResult = {
company?: string | null;
role?: string | null;
location?: string | null;
salary?: string | null;
};
type JobDescriptionAutofillProps = {
value: string;
onChange: (value: string) => void;
onFill: (result: AutofillResult) => void;
};
// Pasted job description: stored with the application and used for every AI action.
// Collapsed by default so the form stays short.
function JobDescriptionAutofill({ value, onChange, onFill }: JobDescriptionAutofillProps) {
const autofill = useMutation(
orpc.applications.ai.autofill.mutationOptions({
onSuccess: (result) => {
onFill(result);
toast.add({ type: "success", description: t`Filled in what we could from the posting.` });
},
onError: (error) => toast.add({ type: "error", description: error.message || t`Auto-fill failed.` }),
}),
);
const runAutofill = (jobDescription: string) => {
const posting = jobDescription.trim();
if (posting.length < MIN_AUTOFILL_CHARS || autofill.isPending) return;
autofill.mutate({ jobDescription: posting.slice(0, MAX_JOB_DESCRIPTION_CHARS) });
};
return (
<Accordion className="rounded-lg border border-line border-dashed px-3">
<AccordionItem value="job-description">
<AccordionTrigger>
<span className="flex items-center gap-1.5">
<Icon name="auto_awesome" size={16} className="text-accent-text" />
<Trans>Job description</Trans>
</span>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2">
<p className="text-ink-3 text-xs">
<Trans>
Copy the entire job description from the posting and paste it below. We'll fill in the fields for you and
keep the text with this application for match scoring and tailoring.
</Trans>
</p>
<Textarea
// Fixed height: the accordion panel measures its content once, so a textarea that
// grew with the pasted text would overflow the clipped panel.
className="field-sizing-fixed h-40"
value={value}
rows={8}
maxLength={MAX_JOB_DESCRIPTION_CHARS}
placeholder={t`Paste the full job description here…`}
onChange={(event) => onChange(event.target.value)}
onPaste={(event) => runAutofill(event.clipboardData.getData("text"))}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-ink-3">
{autofill.isPending ? (
<Trans>Reading the posting…</Trans>
) : (
<Trans>Pasting fills the fields automatically.</Trans>
)}
</p>
<Button
type="button"
size="sm"
variant="secondary"
disabled={value.trim().length < MIN_AUTOFILL_CHARS || autofill.isPending}
onClick={() => runAutofill(value)}
>
<Icon name="auto_awesome" size={16} />
<Trans>Fill fields</Trans>
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -147,51 +254,11 @@ export function ApplicationFormSheet({ open, onOpenChange, application: requeste
}),
);
const autofill = useMutation(
orpc.applications.ai.autofill.mutationOptions({
onSuccess: (result) => {
setForm((prev) => ({
...prev,
company: result.company || prev.company,
role: result.role || prev.role,
location: result.location || prev.location,
salary: result.salary || prev.salary,
}));
toast.add({ type: "success", description: t`Filled in what we could from the posting.` });
},
onError: (error) => toast.add({ type: "error", description: error.message || t`Auto-fill failed.` }),
}),
);
const runAutofill = (jobDescription: string) => {
const posting = jobDescription.trim();
if (posting.length < MIN_AUTOFILL_CHARS || autofill.isPending) return;
autofill.mutate({ jobDescription: posting.slice(0, MAX_JOB_DESCRIPTION_CHARS) });
};
const pending = create.isPending || update.isPending;
const submit = () => {
if (!form.company.trim() || !form.role.trim()) return;
const payload = {
company: form.company.trim(),
role: form.role.trim(),
status: form.status,
location: form.location.trim() || null,
salary: form.salary.trim() || null,
source: form.source.trim() || null,
resumeId: form.resumeId || null,
tags: form.tags,
sourceUrl: form.sourceUrl.trim() || null,
jobDescription: form.jobDescription.trim() || null,
notes: form.notes.trim() || null,
followUpNote: form.followUpNote.trim() || null,
followUpAt: form.followUpAt ? new Date(form.followUpAt) : null,
resumeFileUrl: form.resumeFile?.url ?? null,
resumeFileName: form.resumeFile?.name ?? null,
coverLetterUrl: form.coverLetter?.url ?? null,
coverLetterName: form.coverLetter?.name ?? null,
};
const payload = toPayload(form);
if (application) update.mutate({ id: application.id, ...payload });
else create.mutate({ ...payload, stageEnteredAt: form.stageEnteredAt || undefined });
};
@@ -222,179 +289,186 @@ export function ApplicationFormSheet({ open, onOpenChange, application: requeste
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4 [&>*]:shrink-0">
{/* Pasted job description: stored with the application and used for every AI action.
Collapsed by default so the form stays short; hidden entirely when AI is off. */}
{/* Hidden entirely when AI is off. */}
{aiEnabled && (
<Accordion className="rounded-lg border border-line border-dashed px-3">
<AccordionItem value="job-description">
<AccordionTrigger>
<span className="flex items-center gap-1.5">
<Icon name="auto_awesome" size={16} className="text-accent-text" />
<Trans>Job description</Trans>
</span>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2">
<p className="text-ink-3 text-xs">
<Trans>
Copy the entire job description from the posting and paste it below. We'll fill in the fields for
you and keep the text with this application for match scoring and tailoring.
</Trans>
</p>
<Textarea
// Fixed height: the accordion panel measures its content once, so a textarea that
// grew with the pasted text would overflow the clipped panel.
className="field-sizing-fixed h-40"
value={form.jobDescription}
rows={8}
maxLength={MAX_JOB_DESCRIPTION_CHARS}
placeholder={t`Paste the full job description here…`}
onChange={(event) => set("jobDescription", event.target.value)}
onPaste={(event) => runAutofill(event.clipboardData.getData("text"))}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-ink-3">
{autofill.isPending ? (
<Trans>Reading the posting…</Trans>
) : (
<Trans>Pasting fills the fields automatically.</Trans>
)}
</p>
<Button
type="button"
size="sm"
variant="secondary"
disabled={form.jobDescription.trim().length < MIN_AUTOFILL_CHARS || autofill.isPending}
onClick={() => runAutofill(form.jobDescription)}
>
<Icon name="auto_awesome" size={16} />
<Trans>Fill fields</Trans>
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<JobDescriptionAutofill
value={form.jobDescription}
onChange={(value) => set("jobDescription", value)}
onFill={(result) =>
setForm((prev) => ({
...prev,
company: result.company || prev.company,
role: result.role || prev.role,
location: result.location || prev.location,
salary: result.salary || prev.salary,
}))
}
/>
)}
<Field label={t`Company`} required>
<Input value={form.company} onChange={(event) => set("company", event.target.value)} />
{(id) => <Input id={id} value={form.company} onChange={(event) => set("company", event.target.value)} />}
</Field>
<Field label={t`Role / title`} required>
<Input value={form.role} onChange={(event) => set("role", event.target.value)} />
{(id) => <Input id={id} value={form.role} onChange={(event) => set("role", event.target.value)} />}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t`Location`}>
<Input
value={form.location}
list="application-locations"
placeholder={t`Remote, Hybrid, a city…`}
onChange={(event) => set("location", event.target.value)}
/>
<datalist id="application-locations">
<option value="Remote" />
<option value="Hybrid" />
<option value="In-office" />
</datalist>
{(id) => (
<>
<Input
id={id}
value={form.location}
list="application-locations"
placeholder={t`Remote, Hybrid, a city…`}
onChange={(event) => set("location", event.target.value)}
/>
<datalist id="application-locations">
<option value="Remote" />
<option value="Hybrid" />
<option value="In-office" />
</datalist>
</>
)}
</Field>
<Field label={t`Salary range`}>
<Input value={form.salary} onChange={(event) => set("salary", event.target.value)} />
{(id) => <Input id={id} value={form.salary} onChange={(event) => set("salary", event.target.value)} />}
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={t`Source`}>
<Input
value={form.source}
list="application-sources"
placeholder={t`LinkedIn, Referral…`}
onChange={(event) => set("source", event.target.value)}
/>
<datalist id="application-sources">
{SOURCE_OPTIONS.map((option) => (
<option key={option} value={option} />
))}
</datalist>
{(id) => (
<>
<Input
id={id}
value={form.source}
list="application-sources"
placeholder={t`LinkedIn, Referral…`}
onChange={(event) => set("source", event.target.value)}
/>
<datalist id="application-sources">
{SOURCE_OPTIONS.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</>
)}
</Field>
<Field label={t`Stage`}>
<Combobox
className="w-full"
value={form.status}
options={STAGES.map((s) => ({ value: s.value, label: s.label }))}
onValueChange={(value) => value && set("status", value)}
/>
{(id) => (
<Combobox
id={id}
className="w-full"
value={form.status}
options={STAGES.map((s) => ({ value: s.value, label: s.label }))}
onValueChange={(value) => value && set("status", value)}
/>
)}
</Field>
</div>
<Field label={t`Job posting link`}>
<Input
type="url"
value={form.sourceUrl}
placeholder="https://…"
onChange={(event) => set("sourceUrl", event.target.value)}
/>
{(id) => (
<Input
id={id}
type="url"
value={form.sourceUrl}
placeholder="https://…"
onChange={(event) => set("sourceUrl", event.target.value)}
/>
)}
</Field>
{!isEditing && (
<Field label={t`Stage date`}>
<Input
type="date"
value={form.stageEnteredAt}
onChange={(event) => set("stageEnteredAt", event.target.value)}
/>
{(id) => (
<Input
id={id}
type="date"
value={form.stageEnteredAt}
onChange={(event) => set("stageEnteredAt", event.target.value)}
/>
)}
</Field>
)}
{/* Resume: link a live Reactive Resume (unlocks AI) or upload the exact PDF you sent. */}
<Field label={t`Resume`}>
<div className="flex flex-col gap-2">
<Combobox
className="w-full"
value={form.resumeId || null}
options={resumeOptions}
placeholder={t`Link a Reactive Resume (recommended)`}
showClear
emptyMessage={t`No resumes yet.`}
onValueChange={(value) => set("resumeId", value ?? "")}
/>
<FileAttachmentField
value={form.resumeFile}
attachLabel={t`Or upload a resume PDF`}
onChange={(value) => set("resumeFile", value)}
/>
<p className="text-[11px] text-ink-3">
<Trans>Link a Reactive Resume to use AI match scoring and tailoring.</Trans>
</p>
</div>
{(id) => (
<div className="flex flex-col gap-2">
<Combobox
id={id}
className="w-full"
value={form.resumeId || null}
options={resumeOptions}
placeholder={t`Link a Reactive Resume (recommended)`}
showClear
emptyMessage={t`No resumes yet.`}
onValueChange={(value) => set("resumeId", value ?? "")}
/>
<FileAttachmentField
value={form.resumeFile}
attachLabel={t`Or upload a resume PDF`}
onChange={(value) => set("resumeFile", value)}
/>
<p className="text-[11px] text-ink-3">
<Trans>Link a Reactive Resume to use AI match scoring and tailoring.</Trans>
</p>
</div>
)}
</Field>
{/* The attach button below names itself; the file picker has no labelable control. */}
<Field label={t`Cover letter`}>
<FileAttachmentField
value={form.coverLetter}
attachLabel={t`Attach a cover letter (PDF)`}
onChange={(value) => set("coverLetter", value)}
/>
{() => (
<FileAttachmentField
value={form.coverLetter}
attachLabel={t`Attach a cover letter (PDF)`}
onChange={(value) => set("coverLetter", value)}
/>
)}
</Field>
<Field label={t`Tags`}>
<TagsField value={form.tags} suggestions={allTags ?? []} onChange={(tags) => set("tags", tags)} />
{(id) => (
<TagsField id={id} value={form.tags} suggestions={allTags ?? []} onChange={(tags) => set("tags", tags)} />
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t`Follow-up date`}>
<Input type="date" value={form.followUpAt} onChange={(event) => set("followUpAt", event.target.value)} />
{(id) => (
<Input
id={id}
type="date"
value={form.followUpAt}
onChange={(event) => set("followUpAt", event.target.value)}
/>
)}
</Field>
<Field label={t`Follow-up note`}>
<Input value={form.followUpNote} onChange={(event) => set("followUpNote", event.target.value)} />
{(id) => (
<Input
id={id}
value={form.followUpNote}
onChange={(event) => set("followUpNote", event.target.value)}
/>
)}
</Field>
</div>
<Field label={t`Notes`}>
<Textarea
value={form.notes}
rows={3}
placeholder={t`Referred by…, things to emphasize, etc.`}
onChange={(event) => set("notes", event.target.value)}
/>
{(id) => (
<Textarea
id={id}
value={form.notes}
rows={3}
placeholder={t`Referred by…, things to emphasize, etc.`}
onChange={(event) => set("notes", event.target.value)}
/>
)}
</Field>
</div>
@@ -411,26 +485,35 @@ export function ApplicationFormSheet({ open, onOpenChange, application: requeste
);
}
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
type FieldProps = {
label: string;
required?: boolean;
// Render prop: receives the id to put on the labelled control.
children: (id: string) => React.ReactNode;
};
function Field({ label, required, children }: FieldProps) {
const id = useId();
return (
<div className="grid gap-1.5">
<Label className="text-ink-3 text-xs">
<Label htmlFor={id} className="text-ink-3 text-xs">
{label}
{required && <span className="text-danger-text"> *</span>}
</Label>
{children}
{children(id)}
</div>
);
}
type TagsFieldProps = {
id: string;
value: string[];
suggestions: string[];
onChange: (tags: string[]) => void;
};
// Type-and-Enter tag input with chips + an autocomplete datalist of the user's existing tags.
function TagsField({ value, suggestions, onChange }: TagsFieldProps) {
function TagsField({ id, value, suggestions, onChange }: TagsFieldProps) {
const [draft, setDraft] = useState("");
const add = () => {
@@ -446,11 +529,13 @@ function TagsField({ value, suggestions, onChange }: TagsFieldProps) {
return (
<div className="flex flex-col gap-2">
<Input
id={id}
value={draft}
list="application-tags"
placeholder={t`Add a tag and press Enter…`}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (isImeComposing(event)) return;
if (event.key === "Enter") {
event.preventDefault();
add();
@@ -125,7 +125,7 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A
{days.slice(0, 7).map((day) => (
<div
key={`weekday-${day.getDay()}`}
className="border-line border-b px-2 py-2 text-center font-medium text-[11px] text-ink-3 uppercase tracking-wide"
className="border-line border-b p-2 text-center font-medium text-[11px] text-ink-3 uppercase tracking-wide"
>
{weekdayFormat.format(day)}
</div>
@@ -1,3 +1,4 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { Application } from "../../types";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
@@ -24,6 +25,26 @@ function sentOn(application: Application) {
return new Date(entry?.at ?? application.appliedAt);
}
type SentDocumentRowProps = {
icon: IconName;
name: string;
status: React.ReactNode;
children: React.ReactNode;
};
function SentDocumentRow({ icon, name, status, children }: SentDocumentRowProps) {
return (
<div className="flex items-center gap-2.5 rounded-xl border border-line p-3">
<Icon name={icon} className="shrink-0 text-ink-2" />
<div className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{name}</span>
<span className="text-ink-3 text-xs">{status}</span>
</div>
{children}
</div>
);
}
type SentDocumentsProps = { application: Application; disabled: boolean };
/**
@@ -52,12 +73,13 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) {
// A structured letter: the server fills the recipient from the application and makes it the application's letter.
const writeLetter = async () => {
const letterInput = {
name: t`Cover letter — ${application.company}`.slice(0, 100),
applicationId: application.id,
...(application.resumeId ? { resumeId: application.resumeId } : {}),
};
try {
const created = await createLetter.mutateAsync({
name: t`Cover letter — ${application.company}`.slice(0, 100),
applicationId: application.id,
...(application.resumeId ? { resumeId: application.resumeId } : {}),
});
const created = await createLetter.mutateAsync(letterInput);
invalidate(application.id);
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: created.id } });
} catch (error) {
@@ -75,24 +97,23 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) {
</h3>
{application.resumeId && (
<div className="flex items-center gap-2.5 rounded-xl border border-line p-3">
<Icon name="description" className="shrink-0 text-ink-2" />
<div className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{resume?.name ?? t`Linked resume`}</span>
<span className="text-ink-3 text-xs">
{application.sentResumeVersionId ? (
application.sentCheckScore !== null ? (
<Trans>
Version sent {date} · Check {application.sentCheckScore}
</Trans>
) : (
<Trans>Version sent {date}</Trans>
)
<SentDocumentRow
icon="description"
name={resume?.name ?? t`Linked resume`}
status={
application.sentResumeVersionId ? (
application.sentCheckScore !== null ? (
<Trans>
Version sent {date} · Check {application.sentCheckScore}
</Trans>
) : (
<Trans>Linked · not sent yet</Trans>
)}
</span>
</div>
<Trans>Version sent {date}</Trans>
)
) : (
<Trans>Linked · not sent yet</Trans>
)
}
>
<Link
to="/builder/$resumeId"
params={{ resumeId: application.resumeId }}
@@ -101,22 +122,21 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) {
>
<Trans>Open</Trans>
</Link>
</div>
</SentDocumentRow>
)}
{application.coverLetterId && (
<div className="flex items-center gap-2.5 rounded-xl border border-line p-3">
<Icon name="mail" className="shrink-0 text-ink-2" />
<div className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{letter?.name ?? t`Cover letter`}</span>
<span className="text-ink-3 text-xs">
{application.sentCoverLetterVersionId ? (
<Trans>Version sent {date}</Trans>
) : (
<Trans>Linked · not sent yet</Trans>
)}
</span>
</div>
<SentDocumentRow
icon="mail"
name={letter?.name ?? t`Cover letter`}
status={
application.sentCoverLetterVersionId ? (
<Trans>Version sent {date}</Trans>
) : (
<Trans>Linked · not sent yet</Trans>
)
}
>
<Link
to="/builder/letter/$coverLetterId"
params={{ coverLetterId: application.coverLetterId }}
@@ -125,7 +145,7 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) {
>
<Trans>Open</Trans>
</Link>
</div>
</SentDocumentRow>
)}
{(!application.resumeId || !application.coverLetterId) && (
@@ -137,10 +137,13 @@ export function ImportApplicationsSheet({ open, onOpenChange }: ImportSheetProps
accept=".csv,text/csv"
className="hidden"
onChange={(event) =>
void event.target.files?.[0]?.text().then((value) => {
setOverrides({});
setText(value);
})
void event.target.files?.[0]
?.text()
.then((value) => {
setOverrides({});
setText(value);
})
.catch(() => toast.add({ type: "error", description: t`Couldn't read that file.` }))
}
/>
</div>
@@ -244,7 +244,9 @@ async function uiFontFace(): Promise<string | null> {
const url = rule.style.getPropertyValue("src").match(/url\(["']?([^"')]+\.woff2)["']?\)/)?.[1];
if (!url) continue;
try {
const buffer = await (await fetch(url)).arrayBuffer();
const res = await fetch(url);
if (!res.ok) return null;
const buffer = await res.arrayBuffer();
return `@font-face{font-family:"Hanken Grotesk Variable";font-style:normal;font-weight:100 900;src:url(data:font/woff2;base64,${toBase64(buffer)}) format("woff2");}`;
} catch {
return null;
@@ -3,7 +3,7 @@ import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useState } from "react";
import { useId, useState } from "react";
import { INTERVIEW_KINDS } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
@@ -92,11 +92,15 @@ export function InterviewDialog({
const [draft, setDraft] = useState<Draft>(() => emptyDraft(application?.id ?? ""));
const isEditing = !!interview;
useEffect(() => {
if (!open) return;
const applicationId = application?.id ?? "";
setDraft(interview ? draftFrom(applicationId, interview) : emptyDraft(applicationId, day));
}, [open, interview, application?.id, day]);
// A fresh draft each time the dialog opens, adjusted during render so the first frame is never stale.
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
const applicationId = application?.id ?? "";
setDraft(interview ? draftFrom(applicationId, interview) : emptyDraft(applicationId, day));
}
}
const onSuccess = (_data: unknown, variables: { id: string }) => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
@@ -5,7 +5,7 @@ import { useLingui } from "@lingui/react";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Checkbox } from "@reactive-resume/ui/components/checkbox";
import {
@@ -61,13 +61,9 @@ export function ApplicationList({ applications, showClosed, selectedId, onOpen }
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
// Selections only cover what's on screen.
useEffect(() => {
setChecked((current) => {
const visible = new Set(applications.map((application) => application.id));
const next = new Set([...current].filter((id) => visible.has(id)));
return next.size === current.size ? current : next;
});
}, [applications]);
const checkedIds = applications
.filter((application) => checked.has(application.id))
.map((application) => application.id);
const groups = useMemo(() => {
const sign = sort.direction === "asc" ? 1 : -1;
@@ -193,7 +189,7 @@ export function ApplicationList({ applications, showClosed, selectedId, onOpen }
{/* The bulk bar rises from the bottom edge and floats over the list, so the rows never move. */}
<AnimatePresence>
{checked.size > 0 && (
{checkedIds.length > 0 && (
<m.div
key="bulk-bar"
initial={{ opacity: 0, transform: "translateY(100%)" }}
@@ -202,7 +198,7 @@ export function ApplicationList({ applications, showClosed, selectedId, onOpen }
transition={{ duration: D2, ease: EASE }}
className="sticky bottom-4 z-20 justify-self-center max-sm:bottom-[calc(69px+env(safe-area-inset-bottom))]"
>
<BulkBar ids={[...checked]} onDone={() => setChecked(new Set())} />
<BulkBar ids={checkedIds} onDone={() => setChecked(new Set())} />
</m.div>
)}
</AnimatePresence>
@@ -222,7 +218,6 @@ type ApplicationRowProps = {
function ApplicationRow({ application, phone, selected, checked, onCheck, onOpen }: ApplicationRowProps) {
const { i18n } = useLingui();
const next = describeNextStep(getNextStep(application), application, i18n.locale);
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const sent = application.status !== "saved";
const tone = next.tone === "warn" ? "text-warn-text" : next.tone === "muted" ? "text-ink-3" : "text-ink-2";
@@ -309,7 +304,7 @@ function ApplicationRow({ application, phone, selected, checked, onCheck, onOpen
)}
</td>
<td className="whitespace-nowrap px-2 text-ink-3 text-xs max-md:hidden">
{formatRelativeTime(application.updatedAt, formatter)}
{formatRelativeTime(application.updatedAt, i18n.locale)}
</td>
</tr>
);
@@ -67,16 +67,12 @@ export function useDocumentActions() {
},
duplicate: async (document: DocumentSummary) => {
const copied =
document.type === "resume"
? duplicateResume.mutateAsync({ id: document.id, name: t`${document.name} (copy)`, tags: document.tags })
: duplicateLetter.mutateAsync({ id: document.id }).then((letter) => letter.id);
try {
const id =
document.type === "resume"
? await duplicateResume.mutateAsync({
id: document.id,
name: t`${document.name} (copy)`,
tags: document.tags,
})
: (await duplicateLetter.mutateAsync({ id: document.id })).id;
markNew(id);
markNew(await copied);
toast.add({ description: t`Duplicated` });
} catch (error) {
failed(error);
@@ -215,8 +211,9 @@ export function TagsDialog({ document: requested, onClose }: TagsDialogProps) {
const save = async () => {
if (!document) return;
const nextTags = tags ?? document.tags;
try {
await setDocumentTags.mutateAsync({ type: document.type, id: document.id, tags: tags ?? document.tags });
await setDocumentTags.mutateAsync({ type: document.type, id: document.id, tags: nextTags });
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
onClose();
} catch (error) {
@@ -9,6 +9,7 @@ import { DropdownMenu, DropdownMenuTrigger } from "@reactive-resume/ui/component
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { cn } from "@reactive-resume/utils/style";
import { isImeComposing } from "@/libs/keyboard";
import { formatRelativeTime } from "@/libs/locale";
import { ENTER_CLASS, stagger } from "@/libs/motion";
import { DocumentMenuContent, useDocumentActions } from "./document-actions";
@@ -27,10 +28,9 @@ export type DocumentItemProps = {
/** "Resume · Edited 2h ago", or the days left for a document in Trash. */
function useDocumentMeta(document: DocumentSummary) {
const { i18n } = useLingui();
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const type = document.type === "resume" ? t`Resume` : t`Letter`;
if (document.trashedAt) return t`${type} · ${daysLeftInTrash(document.trashedAt)} days left`;
return t`${type} · Edited ${formatRelativeTime(document.updatedAt, formatter)}`;
return t`${type} · Edited ${formatRelativeTime(document.updatedAt, i18n.locale)}`;
}
/** Opens the document in its editor: resumes and letters share the editor shell. */
@@ -78,6 +78,7 @@ function RenameInput({ document, onDone }: { document: DocumentSummary; onDone:
onFocus={(event) => event.target.select()}
onBlur={commit}
onKeyDown={(event) => {
if (isImeComposing(event)) return;
if (event.key === "Enter") commit();
if (event.key === "Escape") {
event.stopPropagation();
@@ -207,7 +208,6 @@ export function DocumentRow({ document, onTags, onLink, introIndex }: DocumentIt
const openDocument = useOpenDocument();
const [renaming, setRenaming] = useState(false);
const isNew = useNewDocumentsStore((state) => state.ids.includes(document.id)) && !document.trashedAt;
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const menuProps = {
document,
onOpen: () => openDocument(document),
@@ -259,7 +259,7 @@ export function DocumentRow({ document, onTags, onLink, introIndex }: DocumentIt
{document.trashedAt ? (
<Trans>{daysLeftInTrash(document.trashedAt)} days left</Trans>
) : (
formatRelativeTime(document.updatedAt, formatter)
formatRelativeTime(document.updatedAt, i18n.locale)
)}
</td>
<td className="w-10 pe-2 text-end">
@@ -151,56 +151,11 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
</div>
{tags.length > 0 && (
<fieldset className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
<legend className="sr-only">
<Trans>Filter by tag</Trans>
</legend>
{tags.map((tag) => {
const active = search.tags.includes(tag);
return (
<button
key={tag}
type="button"
aria-pressed={active}
onClick={() =>
onSearchChange({
tags: active ? search.tags.filter((known) => known !== tag) : [...search.tags, tag],
})
}
className={cn(
"h-7 rounded-full border px-3 text-[13px] transition-[background-color,border-color,color,scale] duration-quick ease-enter active:scale-[0.97]",
active
? "border-accent bg-accent-soft text-accent-text"
: "border-line-2 text-ink-2 hover:bg-hover",
)}
>
#{tag}
</button>
);
})}
</fieldset>
<TagFilter tags={tags} active={search.tags} onChange={(next) => onSearchChange({ tags: next })} />
)}
{isPending ? (
view === "list" ? (
<div className="grid">
<div className="h-10 border-line border-b" />
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex h-[45px] items-center border-line border-b ps-3">
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="grid gap-2">
<Skeleton className="aspect-page rounded-[6px]" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
))}
</div>
)
<LibrarySkeleton view={view} />
) : shown.length === 0 ? (
<div className="grid justify-items-center gap-2 py-16 text-center">
<Icon name="search_off" size={28} className="text-ink-3" />
@@ -226,6 +181,9 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
</div>
) : view === "list" ? (
<table className={cn("w-full border-collapse", viewEnter)}>
<caption className="sr-only">
<Trans>Documents</Trans>
</caption>
<thead>
<tr className="border-line border-b text-start font-medium text-ink-3 text-xs">
<th className="h-10 ps-3 text-start font-medium">
@@ -352,6 +310,60 @@ function FirstRun({ onChooseFile }: { onChooseFile: () => void }) {
);
}
type TagFilterProps = { tags: string[]; active: string[]; onChange: (tags: string[]) => void };
function TagFilter({ tags, active, onChange }: TagFilterProps) {
return (
<fieldset className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
<legend className="sr-only">
<Trans>Filter by tag</Trans>
</legend>
{tags.map((tag) => {
const isActive = active.includes(tag);
return (
<button
key={tag}
type="button"
aria-pressed={isActive}
onClick={() => onChange(isActive ? active.filter((known) => known !== tag) : [...active, tag])}
className={cn(
"h-7 rounded-full border px-3 text-[13px] transition-[background-color,border-color,color,scale] duration-quick ease-enter active:scale-[0.97]",
isActive ? "border-accent bg-accent-soft text-accent-text" : "border-line-2 text-ink-2 hover:bg-hover",
)}
>
#{tag}
</button>
);
})}
</fieldset>
);
}
function LibrarySkeleton({ view }: { view: "grid" | "list" }) {
if (view === "list") {
return (
<div className="grid">
<div className="h-10 border-line border-b" />
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex h-[45px] items-center border-line border-b ps-3">
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="grid gap-2">
<Skeleton className="aspect-page rounded-[6px]" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
))}
</div>
);
}
/** A file dragged anywhere over the page shows the drop target, and dropping it starts the import. */
function DropToImport() {
const openDialog = useDialogStore((state) => state.openDialog);
@@ -362,7 +374,7 @@ function DropToImport() {
const hasFiles = (event: DragEvent) => Array.from(event.dataTransfer?.types ?? []).includes("Files");
const onEnter = (event: DragEvent) => {
if (!hasFiles(event)) return;
depth++;
depth += 1;
setDragging(true);
};
const onLeave = () => {
@@ -1,4 +1,4 @@
import type { CSSProperties } from "react";
import type { CSSProperties, Dispatch, SetStateAction } from "react";
import type { ImportKind } from "@/features/resume/import/read-file";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
@@ -49,56 +49,38 @@ const ACCEPT = ".pdf,.doc,.docx,.json,.zip,application/pdf,application/json,appl
const formatSize = (bytes: number) =>
bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
/**
* New: import a file, copy a resume for a job, or start blank; no name, slug or tags are asked for first.
* Importing shows three labelled steps rather than a spinner, so a slow parse still looks like progress.
*/
export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | undefined }) {
const navigate = useNavigate();
function requireImportKind(kind: ImportKind | null): ImportKind {
if (!kind) {
throw new ImportError(
t`This file type can't be imported. Use a PDF, Word, Reactive Resume or JSON Resume file, or a LinkedIn export (.zip).`,
);
}
return kind;
}
/** Runs an import and reports each stage into the dialog's step; a newer run or a cancel drops the older one's updates. */
function useResumeImport(setStep: Dispatch<SetStateAction<Step>>, openLetter: (coverLetterId: string) => void) {
const queryClient = useQueryClient();
const closeDialog = useDialogStore((state) => state.closeDialog);
const markNew = useNewDocumentsStore((state) => state.markNew);
const { hasUsableProvider } = useHasUsableAiProvider();
const [step, setStep] = useState<Step>({ name: data?.step ?? "choose" } as Step);
const run = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const refreshDocuments = () => queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
// A copy made for a job opens with the assistant ready to tailor it.
const openResume = (resumeId: string, { withAssistant = false, importedFrom }: OpenResumeOptions = {}) => {
closeDialog();
void navigate({
to: "/builder/$resumeId",
params: { resumeId },
search: {
...(withAssistant ? { assistant: "new" } : {}),
...(importedFrom ? { imported: importedFrom } : {}),
},
});
};
const openLetter = (coverLetterId: string) => {
closeDialog();
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId } });
};
const importFile = async (file: File) => {
const attempt = ++run.current;
const current = () => run.current === attempt;
const advance = (stage: number, note: string) =>
const advance = (stage: number, note: string) => {
const index = stage - 1;
setStep((previous) =>
previous.name === "importing" && current()
? { ...previous, stage, notes: Object.assign([...previous.notes], { [stage - 1]: note }) }
? { ...previous, stage, notes: Object.assign([...previous.notes], { [index]: note }) }
: previous,
);
};
setStep({ name: "importing", file, stage: 0, notes: [] });
try {
const kind: ImportKind | null = await detectImportKind(file);
if (!kind) {
throw new ImportError(
t`This file type can't be imported. Use a PDF, Word, Reactive Resume or JSON Resume file, or a LinkedIn export (.zip).`,
);
}
const kind = requireImportKind(await detectImportKind(file));
if (kind === "cover-letter-json") {
const letter = await client.coverLetters.import({ document: JSON.parse(await file.text()) });
@@ -140,6 +122,44 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
}
};
const cancel = () => {
run.current++;
setStep({ name: "choose" });
};
return { importFile, cancel, refreshDocuments };
}
/**
* New: import a file, copy a resume for a job, or start blank; no name, slug or tags are asked for first.
* Importing shows three labelled steps rather than a spinner, so a slow parse still looks like progress.
*/
export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | undefined }) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
const markNew = useNewDocumentsStore((state) => state.markNew);
const [step, setStep] = useState<Step>({ name: data?.step ?? "choose" } as Step);
const inputRef = useRef<HTMLInputElement>(null);
// A copy made for a job opens with the assistant ready to tailor it.
const openResume = (resumeId: string, { withAssistant = false, importedFrom }: OpenResumeOptions = {}) => {
closeDialog();
void navigate({
to: "/builder/$resumeId",
params: { resumeId },
search: {
...(withAssistant ? { assistant: "new" } : {}),
...(importedFrom ? { imported: importedFrom } : {}),
},
});
};
const openLetter = (coverLetterId: string) => {
closeDialog();
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId } });
};
const { importFile, cancel, refreshDocuments } = useResumeImport(setStep, openLetter);
// A file dropped on the page starts importing as soon as the dialog opens.
const dropped = useRef(data?.file);
useEffect(() => {
@@ -152,25 +172,23 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
const { startBlank, trySample, newLetter, creating } = useStartDocument();
const chooseFile = () => inputRef.current?.click();
const fileInput = (
<input
ref={inputRef}
type="file"
accept={ACCEPT}
className="hidden"
aria-label={t`Choose a file to import`}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void importFile(file);
}}
/>
);
if (step.name === "copy") {
return (
<DialogContent className="sm:max-w-[640px]">
{/* Each step fades up into place as it replaces the last; the dialog's height changes in the same frame. */}
return (
<DialogContent className="sm:max-w-[640px]">
<input
ref={inputRef}
type="file"
accept={ACCEPT}
className="hidden"
aria-label={t`Choose a file to import`}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void importFile(file);
}}
/>
{/* Each step fades up into place as it replaces the last; the dialog's height changes in the same frame. */}
{step.name === "copy" ? (
<div key="copy" className={cn(ENTER_CLASS, "grid gap-4")}>
<CopyForJob
initialSourceId={data?.sourceResumeId}
@@ -183,184 +201,191 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
}}
/>
</div>
</DialogContent>
);
}
if (step.name !== "choose") {
return (
<DialogContent className="sm:max-w-[640px]">
<div key="progress" className={cn(ENTER_CLASS, "grid gap-4")}>
) : step.name !== "choose" ? (
<ImportStep
key="progress"
step={step}
creating={creating}
onCancel={cancel}
onStartBlank={() => void startBlank()}
onChooseFile={chooseFile}
onClose={closeDialog}
onOpen={(resumeId, importedFrom) => openResume(resumeId, { importedFrom })}
/>
) : (
<div key="choose" className={cn(ENTER_CLASS, "grid gap-4")}>
<DialogHeader>
<DialogTitle className="font-display font-medium text-[22px]">
{step.name === "failed" ? <Trans>Couldn't import</Trans> : <Trans>Importing</Trans>}
<Trans>New document</Trans>
</DialogTitle>
<DialogDescription className="sr-only">
<Trans>Import a resume, copy one for a job, or start blank.</Trans>
</DialogDescription>
</DialogHeader>
{fileInput}
<div className="flex items-center gap-3 rounded-[10px] border border-line p-3">
<Icon name="picture_as_pdf" className="text-ink-2" />
<span className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{step.file.name}</span>
<span className="text-ink-3 text-xs">{formatSize(step.file.size)}</span>
<button
type="button"
onClick={chooseFile}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) void importFile(file);
}}
className="flex items-start gap-4 rounded-xl border-[1.5px] border-line-2 border-dashed p-5 text-start transition-[background-color,border-color,scale] duration-quick ease-enter hover:border-accent hover:bg-accent-soft active:scale-[0.98]"
>
<span className="grid size-11 shrink-0 place-items-center rounded-[10px] bg-sunken text-ink-2">
<Icon name="upload_file" size={24} />
</span>
{step.name === "importing" && (
<Button
variant="ghost"
size="sm"
onClick={() => {
run.current++;
setStep({ name: "choose" });
}}
>
<Trans>Cancel</Trans>
</Button>
)}
<span className="grid gap-1">
<span className="font-semibold text-[15px]">
<Trans>Import a resume</Trans>
</span>
<span className="text-[13px] text-ink-2 leading-[19px]">
<Trans>
Drop a file here or browse. PDF, Word, Reactive Resume or JSON Resume. We fill in every section and
flag anything we're unsure of.
</Trans>
</span>
</span>
</button>
<div className="grid gap-3 sm:grid-cols-2">
<ChoiceTile
icon="content_copy"
title={t`Copy a resume for a job`}
description={t`Start from one you have and link the application.`}
onClick={() => setStep({ name: "copy" })}
/>
<ChoiceTile
icon="note_add"
title={t`Start blank`}
description={t`Opens the editor on your name. Nothing else to fill in first.`}
disabled={creating}
onClick={() => void startBlank()}
/>
</div>
{/* One ImportProgress for importing and imported, so its checks don't replay when the import finishes. */}
{step.name !== "failed" && (
<ImportProgress
stage={step.name === "imported" ? 3 : step.stage}
notes={
step.name === "imported"
? [t`file read`, t`${step.sections} sections`, t`${step.entries} entries`]
: step.notes
}
/>
)}
{step.name === "failed" && (
<>
<div
role="alert"
className={cn(
ENTER_CLASS,
"flex gap-2.5 rounded-[10px] bg-danger-soft p-3 text-[13px] text-danger-text leading-[19px]",
)}
>
<Icon name="error" size={20} />
<span>{step.message}</span>
</div>
<div className={cn(ENTER_CLASS, "flex flex-wrap justify-end gap-2")}>
<Button variant="secondary" onClick={() => void startBlank()} disabled={creating}>
<Trans>Start blank</Trans>
</Button>
<Button onClick={chooseFile}>
<Trans>Choose another file</Trans>
</Button>
</div>
</>
)}
{step.name === "imported" && (
<>
<p role="status" className={cn(ENTER_CLASS, "flex gap-2 text-[13px] leading-[19px]")}>
<Icon
name="check_circle"
size={20}
className="starting:scale-80 text-accent-text starting:opacity-0 transition-[opacity,scale] duration-standard ease-enter"
/>
<span>
<Trans>
{step.sections} sections and {step.entries} entries found.
</Trans>{" "}
{step.flagged > 0 && <Trans>{step.flagged} fields are flagged for a quick look in the editor.</Trans>}
</span>
</p>
<div className={cn(ENTER_CLASS, "flex flex-wrap justify-end gap-2")}>
<Button variant="secondary" onClick={closeDialog}>
<Trans>Stay here</Trans>
</Button>
<Button onClick={() => openResume(step.resumeId, { importedFrom: step.file.name })}>
<Trans>Open in editor</Trans>
</Button>
</div>
</>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-line border-t pt-4 text-[13px]">
<button
type="button"
className="flex items-center gap-1.5 text-ink-2 hover:text-ink"
onClick={() => void newLetter()}
>
<Icon name="mail" size={18} />
<Trans>New cover letter instead</Trans>
</button>
<button
type="button"
className="text-ink-2 underline underline-offset-2 hover:text-ink"
disabled={creating}
onClick={() => void trySample()}
>
<Trans>Try with a sample resume</Trans>
</button>
</div>
</div>
</DialogContent>
);
}
return (
<DialogContent className="sm:max-w-[640px]">
<div key="choose" className={cn(ENTER_CLASS, "grid gap-4")}>
<DialogHeader>
<DialogTitle className="font-display font-medium text-[22px]">
<Trans>New document</Trans>
</DialogTitle>
<DialogDescription className="sr-only">
<Trans>Import a resume, copy one for a job, or start blank.</Trans>
</DialogDescription>
</DialogHeader>
{fileInput}
<button
type="button"
onClick={chooseFile}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) void importFile(file);
}}
className="flex items-start gap-4 rounded-xl border-[1.5px] border-line-2 border-dashed p-5 text-start transition-[background-color,border-color,scale] duration-quick ease-enter hover:border-accent hover:bg-accent-soft active:scale-[0.98]"
>
<span className="grid size-11 shrink-0 place-items-center rounded-[10px] bg-sunken text-ink-2">
<Icon name="upload_file" size={24} />
</span>
<span className="grid gap-1">
<span className="font-semibold text-[15px]">
<Trans>Import a resume</Trans>
</span>
<span className="text-[13px] text-ink-2 leading-[19px]">
<Trans>
Drop a file here or browse. PDF, Word, Reactive Resume or JSON Resume. We fill in every section and flag
anything we're unsure of.
</Trans>
</span>
</span>
</button>
<div className="grid gap-3 sm:grid-cols-2">
<ChoiceTile
icon="content_copy"
title={t`Copy a resume for a job`}
description={t`Start from one you have and link the application.`}
onClick={() => setStep({ name: "copy" })}
/>
<ChoiceTile
icon="note_add"
title={t`Start blank`}
description={t`Opens the editor on your name. Nothing else to fill in first.`}
disabled={creating}
onClick={() => void startBlank()}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-line border-t pt-4 text-[13px]">
<button
type="button"
className="flex items-center gap-1.5 text-ink-2 hover:text-ink"
onClick={() => void newLetter()}
>
<Icon name="mail" size={18} />
<Trans>New cover letter instead</Trans>
</button>
<button
type="button"
className="text-ink-2 underline underline-offset-2 hover:text-ink"
disabled={creating}
onClick={() => void trySample()}
>
<Trans>Try with a sample resume</Trans>
</button>
</div>
</div>
)}
</DialogContent>
);
}
type ImportStepProps = {
step: Extract<Step, { name: "importing" | "imported" | "failed" }>;
creating: boolean;
onCancel: () => void;
onStartBlank: () => void;
onChooseFile: () => void;
onClose: () => void;
onOpen: (resumeId: string, importedFrom: string) => void;
};
/** The import's progress, then its outcome: found counts with Open in editor, or the error with a way forward. */
function ImportStep({ step, creating, onCancel, onStartBlank, onChooseFile, onClose, onOpen }: ImportStepProps) {
return (
<div className={cn(ENTER_CLASS, "grid gap-4")}>
<DialogHeader>
<DialogTitle className="font-display font-medium text-[22px]">
{step.name === "failed" ? <Trans>Couldn't import</Trans> : <Trans>Importing</Trans>}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-3 rounded-[10px] border border-line p-3">
<Icon name="picture_as_pdf" className="text-ink-2" />
<span className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{step.file.name}</span>
<span className="text-ink-3 text-xs">{formatSize(step.file.size)}</span>
</span>
{step.name === "importing" && (
<Button variant="ghost" size="sm" onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
)}
</div>
{/* One ImportProgress for importing and imported, so its checks don't replay when the import finishes. */}
{step.name !== "failed" && (
<ImportProgress
stage={step.name === "imported" ? 3 : step.stage}
notes={
step.name === "imported"
? [t`file read`, t`${step.sections} sections`, t`${step.entries} entries`]
: step.notes
}
/>
)}
{step.name === "failed" && (
<>
<div
role="alert"
className={cn(
ENTER_CLASS,
"flex gap-2.5 rounded-[10px] bg-danger-soft p-3 text-[13px] text-danger-text leading-[19px]",
)}
>
<Icon name="error" size={20} />
<span>{step.message}</span>
</div>
<div className={cn(ENTER_CLASS, "flex flex-wrap justify-end gap-2")}>
<Button variant="secondary" onClick={onStartBlank} disabled={creating}>
<Trans>Start blank</Trans>
</Button>
<Button onClick={onChooseFile}>
<Trans>Choose another file</Trans>
</Button>
</div>
</>
)}
{step.name === "imported" && (
<>
<p role="status" className={cn(ENTER_CLASS, "flex gap-2 text-[13px] leading-[19px]")}>
<Icon
name="check_circle"
size={20}
className="starting:scale-80 text-accent-text starting:opacity-0 transition-[opacity,scale] duration-standard ease-enter"
/>
<span>
<Trans>
{step.sections} sections and {step.entries} entries found.
</Trans>{" "}
{step.flagged > 0 && <Trans>{step.flagged} fields are flagged for a quick look in the editor.</Trans>}
</span>
</p>
<div className={cn(ENTER_CLASS, "flex flex-wrap justify-end gap-2")}>
<Button variant="secondary" onClick={onClose}>
<Trans>Stay here</Trans>
</Button>
<Button onClick={() => onOpen(step.resumeId, step.file.name)}>
<Trans>Open in editor</Trans>
</Button>
</div>
</>
)}
</div>
);
}
/** Start blank, try a sample, or a new letter: each creates the document at once and opens it. */
export function useStartDocument() {
const navigate = useNavigate();
@@ -408,18 +433,19 @@ export function useStartDocument() {
}
},
newLetter: async () => {
// A new letter takes its sender details and design from the resume edited most recently.
const documents = queryClient.getQueryData(orpc.documents.list.queryKey({ input: { trashed: false } }));
const resume = documents
?.filter((document) => document.type === "resume")
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())[0];
const input = {
name: t`Untitled letter`,
recipient: "",
content: "",
...(resume ? { resumeId: resume.id } : {}),
};
try {
// A new letter takes its sender details and design from the resume edited most recently.
const documents = queryClient.getQueryData(orpc.documents.list.queryKey({ input: { trashed: false } }));
const resume = documents
?.filter((document) => document.type === "resume")
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())[0];
const letter = await createLetter({
name: t`Untitled letter`,
recipient: "",
content: "",
...(resume ? { resumeId: resume.id } : {}),
});
const letter = await createLetter(input);
created(letter.id);
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: letter.id } });
} catch (error) {
@@ -519,7 +545,6 @@ function CopyForJob({ initialSourceId, initialJobId, onBack, onCreated }: CopyFo
const [jobId, setJobId] = useState<string | null>(initialJobId ?? null);
const [name, setName] = useState<string | null>(null);
const { mutateAsync: copyForJob, isPending } = useMutation(orpc.documents.copyForJob.mutationOptions());
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const source = resumes.find((resume) => resume.id === sourceId) ?? resumes[0];
const job = jobs.find((application) => application.id === jobId);
@@ -529,13 +554,15 @@ function CopyForJob({ initialSourceId, initialJobId, onBack, onCreated }: CopyFo
const create = async () => {
if (!source) return;
const input = {
resumeId: source.id,
...(job ? { applicationId: job.id } : {}),
...(finalName ? { name: finalName } : {}),
};
const message = job ? t`Created and linked to ${job.company}` : t`Created “${finalName}”`;
try {
const resumeId = await copyForJob({
resumeId: source.id,
...(job ? { applicationId: job.id } : {}),
...(finalName ? { name: finalName } : {}),
});
toast.add({ description: job ? t`Created and linked to ${job.company}` : t`Created “${finalName}”` });
const resumeId = await copyForJob(input);
toast.add({ description: message });
onCreated(resumeId, Boolean(job));
} catch (error) {
toast.add({ type: "error", description: getOrpcErrorMessage(error, { fallback: t`Couldn't copy the resume.` }) });
@@ -576,7 +603,7 @@ function CopyForJob({ initialSourceId, initialJobId, onBack, onCreated }: CopyFo
<Icon name="description" className="text-ink-2" />
<span className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{resume.name}</span>
<span className="text-ink-3 text-xs">{formatRelativeTime(resume.updatedAt, formatter)}</span>
<span className="text-ink-3 text-xs">{formatRelativeTime(resume.updatedAt, i18n.locale)}</span>
</span>
</label>
))}
@@ -42,6 +42,9 @@ export function TrashPage() {
</div>
) : (
<table className="w-full border-collapse">
<caption className="sr-only">
<Trans>Trash</Trans>
</caption>
<thead>
<tr className="border-line border-b font-medium text-ink-3 text-xs">
<th className="h-10 ps-3 text-start font-medium">
+17
View File
@@ -0,0 +1,17 @@
import type { KeyboardEvent } from "react";
import { describe, expect, it } from "vitest";
import { isImeComposing } from "./keyboard";
const keydown = (init: { isComposing?: boolean; keyCode?: number }) =>
({ nativeEvent: { isComposing: init.isComposing ?? false }, keyCode: init.keyCode ?? 13 }) as KeyboardEvent;
describe("isImeComposing", () => {
it("is true while composing or for Safari's post-composition Enter", () => {
expect(isImeComposing(keydown({ isComposing: true }))).toBe(true);
expect(isImeComposing(keydown({ keyCode: 229 }))).toBe(true);
});
it("is false for a plain Enter", () => {
expect(isImeComposing(keydown({}))).toBe(false);
});
});
+7
View File
@@ -0,0 +1,7 @@
import type { KeyboardEvent } from "react";
/**
* True while an IME (Japanese, Chinese, Korean input) is composing. The Enter that confirms a conversion must not also
* submit. Safari reports that Enter after `compositionend` with `isComposing` false but `keyCode` 229.
*/
export const isImeComposing = (event: KeyboardEvent) => event.nativeEvent.isComposing || event.keyCode === 229;
+3 -6
View File
@@ -59,16 +59,13 @@ describe("formatRelativeTime", () => {
it("selects the largest matching unit", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-02T12:00:00Z"));
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
expect(formatRelativeTime("2026-01-02T10:00:00Z", formatter)).toBe("2 hours ago");
expect(formatRelativeTime("2026-01-02T11:59:45Z", formatter)).toBe("now");
expect(formatRelativeTime("2026-01-02T10:00:00Z", "en")).toBe("2 hours ago");
expect(formatRelativeTime("2026-01-02T11:59:45Z", "en")).toBe("now");
});
it("reads an invalid date as now", () => {
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
expect(formatRelativeTime("invalid", formatter)).toBe("now");
expect(formatRelativeTime("invalid", "en")).toBe("now");
});
});
+14 -1
View File
@@ -78,7 +78,20 @@ export const resolveLocale = (locale: string): Locale => {
return isLocale(locale) ? locale : defaultLocale;
};
export function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
// Changing the locale reloads the page (`changeLocale`), so one formatter per locale is enough.
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
function getRelativeTimeFormatter(locale: string) {
let formatter = relativeTimeFormatters.get(locale);
if (!formatter) {
formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
relativeTimeFormatters.set(locale, formatter);
}
return formatter;
}
export function formatRelativeTime(value: Date | string, locale: string) {
const formatter = getRelativeTimeFormatter(locale);
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
if (Number.isNaN(diffMs)) return formatter.format(0, "second");
@@ -297,8 +297,9 @@ function readDismissed(): string[] {
function FollowUpNudge({ applications, onOpen }: { applications: Application[]; onOpen: (id: string) => void }) {
const [dismissed, setDismissed] = useState(readDismissed);
const dismissedIds = new Set(dismissed);
const waiting = applications
.filter((application) => !dismissed.includes(application.id))
.filter((application) => !dismissedIds.has(application.id))
.flatMap((application) => {
const step = getNextStep(application);
return step.kind === "no-reply" ? [{ application, days: step.days }] : [];