mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
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:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }] : [];
|
||||
|
||||
Reference in New Issue
Block a user