mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
fix: link cover letters to templates and tidy builder UI
- Fix Grid/Compact/List tab overlap on the resumes dashboard: the fixed three-column grid forced cells narrower than their labels, so tab content spilled into neighboring cells. - Replace the "Resume styling" resume picker with a template picker in the cover-letter create form and editor. The API accepts a `template` on create and update, and refreshing style from a resume no longer overwrites it. The resume control remains in the editor as "Sender details" since it is the only source for the letter header. - Remove the cover-letter library button from the builder sidebar and add an "Import from library" option to the create-cover-letter dialog. Resume to library copying stays in the library with its own resume picker. - Remove the authored-pages/PDF-overflow note from the layout sidebar.
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import type z from "zod";
|
||||
import type { DialogProps } from "@/dialogs/store";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { coverLetterItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
import { SectionItemDialog } from "./section-item-dialog";
|
||||
@@ -55,11 +60,51 @@ export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
|
||||
submitLabel={<Trans>Create</Trans>}
|
||||
singleColumn
|
||||
>
|
||||
<ImportFromLibrary
|
||||
onImport={(letter) => {
|
||||
form.setFieldValue("recipient", letter.recipient);
|
||||
form.setFieldValue("content", letter.content);
|
||||
}}
|
||||
/>
|
||||
<CoverLetterForm form={form} />
|
||||
</SectionItemDialog>
|
||||
);
|
||||
}
|
||||
|
||||
type ImportFromLibraryProps = {
|
||||
onImport: (letter: { recipient: string; content: string }) => void;
|
||||
};
|
||||
|
||||
function ImportFromLibrary({ onImport }: ImportFromLibraryProps) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const letters = useQuery(orpc.coverLetters.list.queryOptions({ input: { limit: 100 } }));
|
||||
|
||||
if (!letters.data?.items.length) return null;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Import from library</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={selectedId}
|
||||
placeholder={t`Choose a saved cover letter`}
|
||||
options={letters.data.items.map((letter) => ({ value: letter.id, label: letter.name }))}
|
||||
onValueChange={(id) => {
|
||||
setSelectedId(id);
|
||||
const letter = letters.data.items.find((item) => item.id === id);
|
||||
if (letter) onImport(letter);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ClientOnly, useBlocker } from "@tanstack/react-router";
|
||||
import { lazy, Suspense, useCallback, useId, useMemo, useRef, useState } from "react";
|
||||
import { createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter";
|
||||
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +20,7 @@ import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -180,6 +182,7 @@ function CoverLetterActions({
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const styleId = useId();
|
||||
const templateId = useId();
|
||||
const applicationId = useId();
|
||||
const [resumeId, setResumeId] = useState<string | null>(letter.sourceResumeId);
|
||||
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(letter.sourceApplicationId);
|
||||
@@ -188,6 +191,7 @@ function CoverLetterActions({
|
||||
const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} }));
|
||||
const applications = useQuery(orpc.applications.list.queryOptions({ input: { includeArchived: false } }));
|
||||
const data = useMemo(() => createCoverLetterResumeData(letter), [letter]);
|
||||
const templateOptions = templateSchema.options.map((value) => ({ value, label: templates[value].name }));
|
||||
const createPdf = async () => {
|
||||
const { createResumePdfBlob } = await import("@/features/resume/export/pdf-document");
|
||||
return createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true });
|
||||
@@ -259,9 +263,34 @@ function CoverLetterActions({
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={templateId}>
|
||||
<Trans>Template</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
id={templateId}
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
options={templateOptions}
|
||||
value={letter.style.metadata.template}
|
||||
onValueChange={(value) => {
|
||||
const template = templateSchema.safeParse(value);
|
||||
if (!template.success) return;
|
||||
void run(async () => {
|
||||
onUpdated(
|
||||
await orpc.coverLetters.update.call({
|
||||
id: letter.id,
|
||||
expectedRevision: letter.revision,
|
||||
template: template.data,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={styleId}>
|
||||
<Trans>Resume styling</Trans>
|
||||
<Trans>Sender details</Trans>
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Combobox
|
||||
@@ -294,7 +323,7 @@ function CoverLetterActions({
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Refresh from resume</Trans>
|
||||
<Trans>Copy from resume</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -3,19 +3,14 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { coverLetterDocumentSchema } from "@reactive-resume/schema/cover-letter/data";
|
||||
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { CoverLetterEditorDialog } from "./editor-dialog";
|
||||
@@ -30,6 +25,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
const queryClient = useQueryClient();
|
||||
const nameId = useId();
|
||||
const resumeInputId = useId();
|
||||
const templateInputId = useId();
|
||||
const embeddedId = useId();
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
const running = useRef(false);
|
||||
@@ -40,6 +36,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [resumeId, setResumeId] = useState<string | null>(initialResumeId ?? null);
|
||||
const [template, setTemplate] = useState<string | null>(null);
|
||||
const [embeddedKey, setEmbeddedKey] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -65,6 +62,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
: [],
|
||||
);
|
||||
const selectedEmbedded = embedded.find((item) => item.value === embeddedKey);
|
||||
const templateOptions = templateSchema.options.map((value) => ({ value, label: templates[value].name }));
|
||||
const sourceReady = resumeId !== initialResumeId || resumeReady;
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
if (running.current) return;
|
||||
@@ -136,7 +134,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
void run(async () => {
|
||||
const letter = await orpc.coverLetters.create.call({
|
||||
name: name.trim(),
|
||||
...(resumeId ? { resumeId } : {}),
|
||||
...(templateSchema.safeParse(template).success ? { template: templateSchema.parse(template) } : {}),
|
||||
});
|
||||
await created(letter.id);
|
||||
});
|
||||
@@ -156,21 +154,18 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={resumeInputId}>
|
||||
<Trans>Resume styling</Trans>
|
||||
<Label htmlFor={templateInputId}>
|
||||
<Trans>Template</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
id={resumeInputId}
|
||||
id={templateInputId}
|
||||
disabled={busy}
|
||||
className="w-full"
|
||||
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
|
||||
value={resumeId}
|
||||
onValueChange={(id) => {
|
||||
setResumeId(id);
|
||||
setEmbeddedKey(null);
|
||||
}}
|
||||
options={templateOptions}
|
||||
value={template}
|
||||
onValueChange={setTemplate}
|
||||
showClear
|
||||
placeholder={t`Default styling`}
|
||||
placeholder={t`Default template`}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={busy || !name.trim() || !sourceReady}>
|
||||
@@ -178,16 +173,29 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
{resumeId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={resumeInputId}>
|
||||
<Trans>Copy an existing letter from a resume</Trans>
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={embeddedId}>
|
||||
<Trans>Copy an existing letter from this resume</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
id={resumeInputId}
|
||||
className="w-full"
|
||||
disabled={busy}
|
||||
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
|
||||
value={resumeId}
|
||||
onValueChange={(id) => {
|
||||
setResumeId(id);
|
||||
setEmbeddedKey(null);
|
||||
}}
|
||||
showClear
|
||||
placeholder={t`Choose a resume`}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Combobox
|
||||
id={embeddedId}
|
||||
className="min-w-48 flex-1"
|
||||
disabled={busy || !sourceReady}
|
||||
disabled={busy || !sourceReady || !resumeId}
|
||||
options={embedded}
|
||||
value={embeddedKey}
|
||||
onValueChange={setEmbeddedKey}
|
||||
@@ -196,10 +204,10 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !sourceReady || !selectedEmbedded}
|
||||
disabled={busy || !sourceReady || !selectedEmbedded || !resumeId}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
if (!selectedEmbedded) return;
|
||||
if (!selectedEmbedded || !resumeId) return;
|
||||
const letter = await orpc.coverLetters.copyEmbedded.call({
|
||||
resumeId,
|
||||
sectionId: selectedEmbedded.sectionId,
|
||||
@@ -225,7 +233,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
<p role="alert">{getReadableErrorMessage(source.error, t`Could not load the selected resume.`)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{query.isPending ? (
|
||||
@@ -293,35 +301,3 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CoverLetterLibraryDialogProps = Pick<CoverLetterLibraryProps, "initialResumeId" | "resumeReady">;
|
||||
|
||||
export function CoverLetterLibraryDialog(props: CoverLetterLibraryDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" className="w-full" onClick={() => setOpen(true)}>
|
||||
<Trans>Cover-letter library</Trans>
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!editing) setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="lg:max-w-3xl xl:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Cover Letters</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Create and edit letters using your resume’s styling.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && <CoverLetterLibrary {...props} onEditingChange={setEditing} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { CoverLetterLibraryDialog } from "@/features/cover-letters/library";
|
||||
import { useCurrentResume, useIsResumeLocked, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useCurrentResume, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft";
|
||||
import { focusLeftSidebarSection, SectionEditorList } from "@/features/resume/builder/section-recovery";
|
||||
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
@@ -61,8 +60,6 @@ function getSectionComponent(type: LeftSidebarSection) {
|
||||
export function BuilderSidebarLeft() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const isLocked = useIsResumeLocked();
|
||||
const resume = useCurrentResume();
|
||||
const resumeReady = useResumeStore((state) => state.saveStatus !== "saving" && state.saveStatus !== "error");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,7 +68,6 @@ export function BuilderSidebarLeft() {
|
||||
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
|
||||
<div className="space-y-4 p-4">
|
||||
{isLocked && <LockBanner />}
|
||||
<CoverLetterLibraryDialog initialResumeId={resume.id} resumeReady={resumeReady} />
|
||||
|
||||
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
|
||||
<SectionEditorList renderSection={getSectionComponent} />
|
||||
|
||||
+1
-71
@@ -1,43 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = await import("./pages?raw").then((module) => module.default);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resume: undefined as unknown as { data: ResumeData; isLocked: boolean },
|
||||
updateResumeData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => mocks.resume,
|
||||
useUpdateResumeData: () => mocks.updateResumeData,
|
||||
}));
|
||||
|
||||
const { LayoutPages } = await import("./pages");
|
||||
|
||||
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.layout.pages = [{ fullWidth: false, main: ["summary"], sidebar: ["profiles"] }];
|
||||
mocks.resume = { data, isLocked: false };
|
||||
});
|
||||
|
||||
const renderLayoutPages = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LayoutPages />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("layout page header", () => {
|
||||
it("uses container queries to prevent narrow sidebar control collisions", () => {
|
||||
expect(source).toContain("@container bg-secondary/50");
|
||||
@@ -46,37 +10,3 @@ describe("layout page header", () => {
|
||||
expect(source).toContain("flex min-w-0 flex-wrap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authored page guidance", () => {
|
||||
it("explains why one authored page can produce multiple non-editable PDF overflow pages without changing layout data", () => {
|
||||
const physicalRenderEvidence = { pageCount: 3 };
|
||||
const pagesBeforeRender = structuredClone(mocks.resume.data.metadata.layout.pages);
|
||||
|
||||
expect(mocks.resume.data.metadata.layout.pages).toHaveLength(1);
|
||||
expect(physicalRenderEvidence.pageCount).toBeGreaterThan(mocks.resume.data.metadata.layout.pages.length);
|
||||
|
||||
renderLayoutPages();
|
||||
|
||||
const guidance = screen.getByRole("note", { name: "Authored pages and PDF overflow" });
|
||||
expect(guidance).toHaveTextContent(
|
||||
"Pages listed here are authored pages saved with your resume. A long authored page may continue onto extra PDF pages automatically; those overflow pages are not saved or editable separately.",
|
||||
);
|
||||
expect(guidance).toHaveTextContent("Move to");
|
||||
expect(guidance).toHaveTextContent("New Page");
|
||||
expect(guidance).toHaveTextContent("Full Width");
|
||||
expect(mocks.updateResumeData).not.toHaveBeenCalled();
|
||||
expect(mocks.resume.data.metadata.layout.pages).toEqual(pagesBeforeRender);
|
||||
});
|
||||
|
||||
it("keeps guidance available in locked resumes and out of keyboard tab order", async () => {
|
||||
mocks.resume.isLocked = true;
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderLayoutPages();
|
||||
|
||||
expect(screen.getByRole("note", { name: "Authored pages and PDF overflow" })).toBeVisible();
|
||||
await user.tab();
|
||||
expect(screen.getByRole("switch", { name: "Full Width" })).toHaveFocus();
|
||||
expect(mocks.updateResumeData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,14 +19,12 @@ import {
|
||||
DotsSixVerticalIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
FileIcon,
|
||||
InfoIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -250,8 +248,6 @@ export function LayoutPages() {
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<AuthoredPageGuidance />
|
||||
|
||||
{layout.pages.map((page, pageIndex) => (
|
||||
<PageContainer
|
||||
key={`page-${pageIndex}`}
|
||||
@@ -279,33 +275,6 @@ export function LayoutPages() {
|
||||
);
|
||||
}
|
||||
|
||||
function AuthoredPageGuidance() {
|
||||
const titleId = useId();
|
||||
|
||||
return (
|
||||
<Alert role="note" aria-labelledby={titleId}>
|
||||
<InfoIcon />
|
||||
<AlertTitle id={titleId}>
|
||||
<Trans>Authored pages and PDF overflow</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Pages listed here are authored pages saved with your resume. A long authored page may continue onto extra
|
||||
PDF pages automatically; those overflow pages are not saved or editable separately.
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans>
|
||||
For a controlled continuation, open a section's menu and choose Move to → New Page, then turn on Full Width
|
||||
for that authored page.
|
||||
</Trans>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
type PageContainerProps = {
|
||||
pageIndex: number;
|
||||
page: { fullWidth: boolean; main: string[]; sidebar: string[] };
|
||||
|
||||
@@ -157,7 +157,7 @@ function RouteComponent() {
|
||||
)}
|
||||
|
||||
<Tabs className="w-full sm:w-auto ltr:sm:ms-auto rtl:sm:me-auto" value={view}>
|
||||
<TabsList className="grid w-full grid-cols-3 sm:inline-flex sm:w-fit">
|
||||
<TabsList className="w-full max-w-full overflow-x-auto sm:w-fit">
|
||||
<TabsTrigger
|
||||
value="grid"
|
||||
nativeButton={false}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
coverLetterDocumentSchema,
|
||||
coverLetterSchema,
|
||||
} from "@reactive-resume/schema/cover-letter/data";
|
||||
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||
|
||||
const idSchema = z.object({ id: z.string().min(1) });
|
||||
const revisionSchema = idSchema.extend({ expectedRevision: z.number().int().min(1) });
|
||||
@@ -29,10 +30,14 @@ export const coverLetterDto = {
|
||||
content: editableSchema.shape.content.default(""),
|
||||
resumeId: z.string().min(1).optional(),
|
||||
applicationId: z.string().min(1).optional(),
|
||||
template: templateSchema.optional(),
|
||||
}),
|
||||
output: coverLetterSchema,
|
||||
},
|
||||
update: { input: revisionSchema.extend(editableSchema.partial().shape), output: coverLetterSchema },
|
||||
update: {
|
||||
input: revisionSchema.extend(editableSchema.partial().shape).extend({ template: templateSchema.optional() }),
|
||||
output: coverLetterSchema,
|
||||
},
|
||||
refreshStyle: { input: revisionSchema.extend({ resumeId: z.string().min(1) }), output: coverLetterSchema },
|
||||
duplicate: { input: idSchema.extend({ name: editableSchema.shape.name.optional() }), output: coverLetterSchema },
|
||||
delete: { input: revisionSchema, output: z.void() },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CoverLetter, CoverLetterDocument, CoverLetterStyle } from "@reactive-resume/schema/cover-letter/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { CoverLetterListInput, CoverLetterUpdateInput } from "../../dto/cover-letter";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, count, desc, eq, ilike, sql } from "drizzle-orm";
|
||||
@@ -24,6 +25,7 @@ type CreateInput = {
|
||||
content?: string | undefined;
|
||||
resumeId?: string | undefined;
|
||||
applicationId?: string | undefined;
|
||||
template?: Template | undefined;
|
||||
};
|
||||
|
||||
async function getById(input: OwnedId): Promise<CoverLetter> {
|
||||
@@ -119,6 +121,7 @@ export const coverLetterService = {
|
||||
create: async (input: CreateInput) => {
|
||||
await assertOwnedApplication(input.userId, input.applicationId);
|
||||
const style = await getResumeStyle(input.userId, input.resumeId);
|
||||
if (input.template) style.metadata.template = input.template;
|
||||
return insert({
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
@@ -129,8 +132,12 @@ export const coverLetterService = {
|
||||
sourceApplicationId: input.applicationId ?? null,
|
||||
});
|
||||
},
|
||||
update: (input: CoverLetterUpdateInput & { userId: string }) => {
|
||||
update: async (input: CoverLetterUpdateInput & { userId: string }) => {
|
||||
const changes: Partial<typeof schema.coverLetter.$inferInsert> = {};
|
||||
if (input.template) {
|
||||
const letter = await getById(input);
|
||||
changes.style = { ...letter.style, metadata: { ...letter.style.metadata, template: input.template } };
|
||||
}
|
||||
if (input.name !== undefined) changes.name = coverLetterContentSchema.shape.name.parse(input.name);
|
||||
if (input.recipient !== undefined)
|
||||
changes.recipient = sanitizeCoverLetterHtml(coverLetterContentSchema.shape.recipient.parse(input.recipient));
|
||||
@@ -141,6 +148,7 @@ export const coverLetterService = {
|
||||
refreshStyle: async (input: RevisionInput & { resumeId: string }) => {
|
||||
const letter = await getById(input);
|
||||
const style = await getResumeStyle(input.userId, input.resumeId, letter.style.sectionId, letter.style.itemId);
|
||||
style.metadata.template = letter.style.metadata.template;
|
||||
return updateRevision(input, { style, sourceResumeId: input.resumeId });
|
||||
},
|
||||
duplicate: async (input: OwnedId & { name?: string | undefined }) => {
|
||||
|
||||
Reference in New Issue
Block a user