feat(web): one sheet for sharing, downloads and version history

Share & export is one 440px sheet (a full-height bottom sheet on phones)
with Link, Download and History tabs. Share opens Link, the ▾ beside
Download PDF opens Download, the clock opens History, and ⌘⇧S / ⌘⇧E open
their tabs. On desktop the page moves 120px aside so it stays visible.

- Link: the public switch card; the address with a live check (300 ms),
  the reason it can't be used and a suggestion, saved only once valid, so
  the old address stays live; Copy ("Copied" for 2 s); visitor downloads;
  the password (Q3a); Open public page; a QR code; Share via… where the
  platform offers it; and views, downloads and time since the last view
  over 30 days with a daily chart, or the explanation while the link is
  off.
- Download: format cards explained by when to use them, Resume or Cover
  letter (with the resume header option) when the resume has a letter,
  the file name recruiters see (First-Last-Resume by default, unsafe
  characters stripped), a non-blocking note about open Check issues, and
  progress inside the button; a failure offers Try again and PDF.
- History: name the current state; a timeline of Now, sessions, named
  versions, restores and where the document came from. Picking a version
  shows it on the page, read-only and outlined, with its banner; Restore
  saves "Before restore" first. Named versions can be renamed or deleted.

Also:
- Autosaves send this visit's session id, and naming or restoring a
  version saves pending edits first.
- The one-click PDF and every export use the First-Last-Resume name.
- Resume dialogs stop asking for a slug (it's edited in Share), which also
  stops Rename from overwriting a custom slug with one made from the name.
- The public route redirects a renamed resume's old address.
- Removed: the download dialog, the version-history menu and the sharing,
  statistics and export sections.
This commit is contained in:
Amruth Pillai
2026-09-28 19:48:08 +02:00
parent 060750a868
commit c8df749258
30 changed files with 1417 additions and 1315 deletions
+2 -72
View File
@@ -3,10 +3,9 @@ import type { DialogProps } from "../store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { useRef } from "react";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
@@ -25,18 +24,11 @@ import {
} from "@reactive-resume/ui/components/dropdown-menu";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { toast } from "@reactive-resume/ui/components/toast";
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
import { generateId, generateRandomName } from "@reactive-resume/utils/string";
import { ChipInput } from "@/components/input/chip-input";
import { usePatchResume } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/libs/auth/client";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useAppForm, withForm } from "@/libs/tanstack-form";
@@ -45,7 +37,6 @@ import { useDialogStore } from "../store";
const formSchema = z.object({
id: z.string(),
name: z.string().min(1).max(64),
slug: z.string().min(1).max(64).transform(slugify),
tags: z.array(z.string()),
});
@@ -54,7 +45,6 @@ type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
name: "",
slug: "",
tags: [],
};
@@ -70,7 +60,6 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
defaultValues: {
id: generateId(),
name: "",
slug: "",
tags: [] as string[],
},
validators: { onSubmit: formSchema },
@@ -91,12 +80,6 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
},
});
const name = useStore(form.store, (s) => s.values.name);
useEffect(() => {
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form, {
shouldBlock: () => !didCreateRef.current && form.state.isDirty && !form.state.isSubmitting,
});
@@ -107,7 +90,6 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const data = {
name: values.name || randomName,
slug: values.slug || slugify(randomName),
tags: values.tags,
withSampleData: true,
} satisfies RouterInput["resume"]["create"];
@@ -195,7 +177,6 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
defaultValues: {
id: data.id,
name: data.name,
slug: data.slug,
tags: data.tags,
},
validators: { onSubmit: formSchema },
@@ -225,13 +206,6 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
},
});
const name = useStore(form.store, (s) => s.values.name);
useEffect(() => {
if (!name) return;
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form);
return (
@@ -276,7 +250,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
defaultValues: {
id: data.id,
name: `${data.name} (Copy)`,
slug: `${data.slug}-copy`,
tags: data.tags,
},
validators: { onSubmit: formSchema },
@@ -298,13 +271,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
},
});
const name = useStore(form.store, (s) => s.values.name);
useEffect(() => {
if (!name) return;
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form);
return (
@@ -342,10 +308,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
const ResumeForm = withForm({
defaultValues,
render: function ResumeFormRenderer({ form }) {
const { data: session } = authClient.useSession();
const slugPrefix = `${window.location.origin}/${session?.user.username ?? ""}/`;
const onGenerateName = () => {
form.setFieldValue("name", generateRandomName());
};
@@ -384,38 +346,6 @@ const ResumeForm = withForm({
)}
</form.Field>
<form.Field name="slug">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Slug</Trans>
</FormLabel>
<FormControl
render={
<InputGroup>
<InputGroupAddon align="inline-start" className="hidden sm:flex">
<InputGroupText>{slugPrefix}</InputGroupText>
</InputGroupAddon>
<InputGroupInput
min={1}
max={64}
className="ps-0!"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
</InputGroup>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
<Trans>This is a URL-friendly name for your resume.</Trans>
</FormDescription>
</FormItem>
)}
</form.Field>
<form.Field name="tags">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
@@ -321,7 +321,7 @@ describe("builder resume autosave", () => {
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1);
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
{ id: initial.id, data: updated.data },
{ id: initial.id, data: updated.data, sessionId: expect.any(String) },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
@@ -342,7 +342,7 @@ describe("builder resume autosave", () => {
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
{ id: initial.id, data: updated.data },
{ id: initial.id, data: updated.data, sessionId: expect.any(String) },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
@@ -381,8 +381,16 @@ describe("builder resume autosave", () => {
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.updateResume.mock.calls[0]?.[0]).toEqual({ id: initial.id, data: first.data });
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data });
expect(orpcMocks.updateResume.mock.calls[0]?.[0]).toEqual({
id: initial.id,
data: first.data,
sessionId: expect.any(String),
});
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({
id: initial.id,
data: latest.data,
sessionId: expect.any(String),
});
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
});
@@ -419,7 +427,11 @@ describe("builder resume autosave", () => {
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data });
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({
id: initial.id,
data: latest.data,
sessionId: expect.any(String),
});
});
it("keeps the latest draft on this device and reports Not saved when saving fails", async () => {
+11 -4
View File
@@ -96,6 +96,8 @@ type Runtime = {
onlineHandler?: () => void;
deferredRemoteResume?: Resume;
deferredFocusHandler?: () => void;
/** This visit's saves share one autosave version in History. */
sessionId: string;
};
type ResumeUpdateSubscriptionOptions = {
@@ -263,7 +265,7 @@ async function flushResumeSave(id: string) {
try {
const updated = (await orpc.resume.update.call(
{ id: submitted.id, data: submittedData },
{ id: submitted.id, data: submittedData, sessionId: runtime.sessionId },
{ signal: runtime.abortController.signal },
)) as Resume;
@@ -338,6 +340,7 @@ function createRuntime(): Runtime {
isSaving: false,
saveFailed: false,
syncResume,
sessionId: crypto.randomUUID(),
};
if (typeof window !== "undefined") {
@@ -788,8 +791,12 @@ export function useBuilderResumeUpdateSubscription() {
useResumeUpdateSubscription({ resumeId, onUpdate, onError });
}
// Route transitions can await a save; unmount cleanup and browser unload cannot.
function saveResumeBeforeLeaving(id: string): boolean | Promise<boolean> {
/**
* Saves pending edits now and resolves once they're on the server (false if saving fails). Route transitions
* await it before leaving, and History before naming or restoring a version, so the server has what's on
* screen. Unmount cleanup and browser unload can't await it.
*/
export function savePendingChanges(id: string): boolean | Promise<boolean> {
const runtime = runtimes.get(id);
const current = useResumeStore.getState().resume;
if (!runtime?.hasPendingLocalChanges || current?.id !== id) return true;
@@ -833,7 +840,7 @@ export function useResumeCleanup() {
useBlocker({
shouldBlockFn: async ({ next }) => {
if (!resumeId || ("resumeId" in next.params && next.params.resumeId === resumeId)) return false;
return !(await saveResumeBeforeLeaving(resumeId));
return !(await savePendingChanges(resumeId));
},
enableBeforeUnload: () => !!resumeId && (runtimes.get(resumeId)?.hasPendingLocalChanges ?? false),
});
+14 -8
View File
@@ -5,6 +5,9 @@ import { create } from "zustand/react";
export const EDITOR_MODES = ["write", "design", "check"] as const;
export type EditorMode = (typeof EDITOR_MODES)[number];
/** The Share & export sheet's tabs; each entry point opens its own. */
export type ShareTab = "link" | "download" | "history";
/** What's selected in the editor: shared by the panel and the page, so each can outline the other. */
export type EditorSelection = PageMapTarget;
@@ -22,8 +25,10 @@ type EditorStore = {
drawerOpen: boolean;
/** Tablet in landscape: the panel sits beside the page instead of over it. */
drawerPinned: boolean;
shareOpen: boolean;
downloadOpen: boolean;
/** The open tab of the Share & export sheet, or null while it's closed. */
shareTab: ShareTab | null;
/** History: the version shown on the page, read-only, instead of the current resume. */
historyVersionId: string | null;
assistantOpen: boolean;
/** Write: sections open in the outline. */
openSections: readonly string[];
@@ -41,8 +46,9 @@ type EditorStore = {
setZoom: (zoom: number | "fit") => void;
setDrawerOpen: (open: boolean) => void;
setDrawerPinned: (pinned: boolean) => void;
setShareOpen: (open: boolean) => void;
setDownloadOpen: (open: boolean) => void;
/** Opens the sheet on a tab, or closes it (which also returns the page to now). */
setShareTab: (tab: ShareTab | null) => void;
setHistoryVersion: (versionId: string | null) => void;
setAssistantOpen: (open: boolean) => void;
setSectionOpen: (sectionId: string, open: boolean) => void;
markSectionAdded: (sectionId: string) => void;
@@ -70,8 +76,8 @@ const initialState = {
zoom: "fit",
drawerOpen: false,
drawerPinned: false,
shareOpen: false,
downloadOpen: false,
shareTab: null,
historyVersionId: null,
assistantOpen: false,
openSections: [],
addedSections: [],
@@ -87,8 +93,8 @@ export const useEditorStore = create<EditorStore>()((set) => ({
setZoom: (zoom) => set({ zoom: zoom === "fit" ? "fit" : clampZoom(zoom) }),
setDrawerOpen: (drawerOpen) => set({ drawerOpen }),
setDrawerPinned: (drawerPinned) => set({ drawerPinned }),
setShareOpen: (shareOpen) => set({ shareOpen }),
setDownloadOpen: (downloadOpen) => set({ downloadOpen }),
setShareTab: (shareTab) => set(shareTab ? { shareTab } : { shareTab, historyVersionId: null }),
setHistoryVersion: (historyVersionId) => set({ historyVersionId }),
setAssistantOpen: (assistantOpen) => set({ assistantOpen }),
setSectionOpen: (sectionId, open) =>
set((state) => {
@@ -1,214 +0,0 @@
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
import type { ReactElement, ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import {
CircleNotchIcon,
DownloadSimpleIcon,
EnvelopeSimpleIcon,
FileDocIcon,
FileJsIcon,
FilePdfIcon,
FileTextIcon,
MarkdownLogoIcon,
} from "@phosphor-icons/react";
import { useId, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@reactive-resume/ui/components/dialog";
import { Switch } from "@reactive-resume/ui/components/switch";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { useResumeExport } from "./use-resume-export";
type DownloadableResume = Parameters<typeof useResumeExport>[0];
type ResumeDownloadDialogProps = {
resume: DownloadableResume;
/** Omitted when something else opens the dialog through `open`. */
trigger?: (disabled: boolean) => ReactElement;
/** Controlled open state, e.g. for the ⌘⇧E shortcut. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
type FormatRowProps = {
action: ReactElement;
description: ReactNode;
disabled?: boolean;
icon: ReactElement;
title: ReactNode;
};
function FormatRow({ action, description, disabled, icon, title }: FormatRowProps) {
return (
<div
className={cn(
"flex items-center gap-3 rounded-lg border bg-background p-3 transition-opacity",
disabled && "opacity-45",
)}
>
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground">
{icon}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h3 className="font-medium text-sm">{title}</h3>
<p className="text-muted-foreground text-xs leading-normal">{description}</p>
</div>
{action}
</div>
);
}
export function ResumeDownloadDialog({ resume, trigger, open: openProp, onOpenChange }: ResumeDownloadDialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = openProp ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const [scope, setScope] = useState<ResumeExportTarget>("resume");
const [includeCoverLetterHeader, setIncludeCoverLetterHeader] = useState(false);
const includeHeaderSwitchId = useId();
const { hasCoverLetter, isExporting, onDownloadDOCX, onDownloadJSON, onDownloadMarkdown, onDownloadPDF } =
useResumeExport(resume);
const disabled = !resume || isExporting;
// Cover letter can't be the active scope when the resume has none (also guards a stale toggle).
const activeScope: ResumeExportTarget = scope === "cover-letter" && !hasCoverLetter ? "resume" : scope;
const jsonDisabled = activeScope === "cover-letter";
const run = (action: () => void | Promise<void>) => {
setOpen(false);
void action();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger && <DialogTrigger render={trigger(disabled)} />}
<DialogContent className="gap-5 sm:max-w-lg">
<DialogHeader className="pe-8">
<DialogTitle>
<Trans>Download</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Pick a format for your resume or cover letter.</Trans>
</DialogDescription>
</DialogHeader>
<Tabs value={activeScope} onValueChange={(value) => setScope(value as ResumeExportTarget)}>
<TabsList className="h-11! w-full">
<TabsTrigger value="resume">
<FileTextIcon />
<Trans>Resume</Trans>
</TabsTrigger>
<TabsTrigger value="cover-letter" disabled={!hasCoverLetter}>
<EnvelopeSimpleIcon />
<Trans>Cover letter</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
{activeScope === "cover-letter" && (
<label
htmlFor={includeHeaderSwitchId}
className="flex cursor-pointer items-center gap-3 rounded-lg border bg-background p-3"
>
<Switch
id={includeHeaderSwitchId}
checked={includeCoverLetterHeader}
onCheckedChange={setIncludeCoverLetterHeader}
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium text-sm">
<Trans>Include resume header</Trans>
</span>
<span className="text-muted-foreground text-xs leading-normal">
<Trans>Show the same first-page header on the cover letter.</Trans>
</span>
</span>
</label>
)}
<div className="grid gap-2">
<FormatRow
icon={
isExporting ? <CircleNotchIcon className="size-5 animate-spin" /> : <FilePdfIcon className="size-5" />
}
title="PDF"
description={<Trans>Best for applications, sharing, and printing.</Trans>}
action={
<Button
size="sm"
aria-label="Download PDF"
disabled={isExporting}
onClick={() => run(() => onDownloadPDF(activeScope, { includeCoverLetterHeader }))}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
<FormatRow
icon={<FileDocIcon className="size-5" />}
title="DOCX"
description={<Trans>Editable in Word, Google Docs, and Pages.</Trans>}
action={
<Button
size="sm"
variant="secondary"
aria-label="Download DOCX"
disabled={isExporting}
onClick={() => run(() => onDownloadDOCX(activeScope))}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
<FormatRow
icon={<MarkdownLogoIcon className="size-5" />}
title="Markdown"
description={<Trans>Plain text, for AI tools and quick edits.</Trans>}
action={
<Button
size="sm"
variant="secondary"
aria-label="Download Markdown"
disabled={isExporting}
onClick={() => run(() => onDownloadMarkdown(activeScope))}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
<FormatRow
disabled={jsonDisabled}
icon={<FileJsIcon className="size-5" />}
title="JSON"
description={<Trans>Full resume data for backup or import.</Trans>}
action={
<Button
size="sm"
variant="secondary"
aria-label="Download JSON"
disabled={jsonDisabled}
onClick={() => run(onDownloadJSON)}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
</Button>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -8,7 +8,7 @@ import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
import { buildMarkdown } from "@reactive-resume/resume/markdown";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { resolvePublicResumePdfBlob } from "@/features/resume/public/public-pdf";
import { client } from "@/libs/orpc/client";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
@@ -37,36 +37,71 @@ type UseResumeExportOptions = {
publicResumePdf?: PublicResumePdfOptions;
};
const getExportName = (resume: ExportableResume) => resume.name || resume.data.basics.name || resume.slug;
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume);
type DownloadPdfOptions = {
includeCoverLetterHeader?: boolean;
};
export type ExportFormat = "pdf" | "docx" | "md" | "json";
// Characters Windows and macOS won't take in a file name.
const UNSAFE_FILE_NAME_CHARACTERS = /[\\/:*?"<>|]/g;
/** A file name as the user typed it, minus the characters file systems reject. */
export const sanitizeFileName = (value: string) => value.replace(UNSAFE_FILE_NAME_CHARACTERS, "").trim();
/**
* Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim
* between the builder dock and the right-panel Export section (#17).
* "First-Last-Resume" (or "First-Last-Cover-Letter"): recruiters see this name. Without a name on the
* resume it falls back to the document's name.
*/
export function getDefaultFileName(resume: ExportableResume, target: ResumeExportTarget = "resume") {
const words = (text: string) => sanitizeFileName(text).split(/\s+/).filter(Boolean);
const person = words(resume.data.basics.name);
const suffix = target === "cover-letter" ? ["Cover", "Letter"] : ["Resume"];
if (person.length > 0) return [...person, ...suffix].join("-");
return [...words(resume.name || resume.slug), ...(target === "cover-letter" ? suffix : [])].join("-") || "Resume";
}
/** Builds one export file. It throws when the file can't be made, so each caller decides how to say so. */
export async function createExportFile(
resume: ExportableResume,
format: ExportFormat,
target: ResumeExportTarget = "resume",
options?: DownloadPdfOptions,
): Promise<Blob> {
if (format === "json") {
return new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" });
}
const data = getResumeExportData(resume.data, target);
if (format === "pdf") {
return createResumePdfBlob(
data,
undefined,
target === "cover-letter" ? { includeCoverLetterHeader: options?.includeCoverLetterHeader } : undefined,
);
}
const resolveTitle = await createSectionTitleResolver(data);
if (format === "md") return new Blob([buildMarkdown(data, resolveTitle)], { type: "text/markdown" });
return buildDocx(data, resolveTitle);
}
/** One-click exports that report their own progress and failures in toasts (the bar, ⌘P, public pages). */
export function useResumeExport(resume: ExportableResume | undefined, exportOptions: UseResumeExportOptions = {}) {
const [isExporting, setIsExporting] = useState(false);
const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false;
const onDownloadJSON = useCallback(() => {
const onDownloadJSON = useCallback(async () => {
if (!resume) return;
const blob = new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" });
downloadWithAnchor(blob, generateFilename(getExportName(resume), "json"));
downloadWithAnchor(await createExportFile(resume, "json"), `${getDefaultFileName(resume)}.json`);
}, [resume]);
const onDownloadMarkdown = useCallback(
async (target: ResumeExportTarget = "resume") => {
if (!resume) return;
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
const data = getResumeExportData(resume.data, target);
const resolveTitle = await createSectionTitleResolver(data);
const blob = new Blob([buildMarkdown(data, resolveTitle)], { type: "text/markdown" });
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "md"));
const blob = await createExportFile(resume, "md", target);
downloadWithAnchor(blob, `${getDefaultFileName(resume, target)}.md`);
},
[resume],
);
@@ -76,10 +111,8 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
if (!resume) return;
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
try {
const data = getResumeExportData(resume.data, target);
const resolveTitle = await createSectionTitleResolver(data);
const blob = await buildDocx(data, resolveTitle);
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "docx"));
const blob = await createExportFile(resume, "docx", target);
downloadWithAnchor(blob, `${getDefaultFileName(resume, target)}.docx`);
} catch {
toast.add({ type: "error", description: t`Could not generate the DOCX. Please try again.` });
}
@@ -97,17 +130,10 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
});
setIsExporting(true);
try {
const data = exportOptions.publicResumePdf ? resume.data : getResumeExportData(resume.data, target);
const blob = exportOptions.publicResumePdf
? await resolvePublicResumePdfBlob({ data, ...exportOptions.publicResumePdf })
: await createResumePdfBlob(
data,
undefined,
target === "cover-letter"
? { includeCoverLetterHeader: downloadOptions?.includeCoverLetterHeader }
: undefined,
);
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
? await resolvePublicResumePdfBlob({ data: resume.data, ...exportOptions.publicResumePdf })
: await createExportFile(resume, "pdf", target, downloadOptions);
downloadWithAnchor(blob, `${getDefaultFileName(resume, target)}.pdf`);
if (exportOptions.publicResumePdf) {
// Statistics are best effort and must not delay or fail a completed browser download.
void client.resume.statistics
@@ -0,0 +1,251 @@
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { ExportFormat } from "@/features/resume/export/use-resume-export";
import { Radio } from "@base-ui/react/radio";
import { RadioGroup } from "@base-ui/react/radio-group";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useId, useState } from "react";
import { resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
import { Button } from "@reactive-resume/ui/components/button";
import { Checkbox } from "@reactive-resume/ui/components/checkbox";
import { Icon } from "@reactive-resume/ui/components/icon";
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
type Format = { id: ExportFormat; label: string; extension: string; icon: IconName; description: string };
const getFormats = (): Format[] => [
{
id: "pdf",
label: "PDF",
extension: ".pdf",
icon: "picture_as_pdf",
description: t`Looks exactly like the page. Use it for applications and email.`,
},
{
id: "docx",
label: t`Word`,
extension: ".docx",
icon: "description",
description: t`For portals or recruiters who ask for Word. Layout is simplified.`,
},
{
id: "md",
label: "Markdown",
extension: ".md",
icon: "notes",
description: t`Plain text with headings. Paste into application forms and notes.`,
},
{
id: "json",
label: "JSON",
extension: ".json",
icon: "data_object",
description: t`Complete data backup. Imports back into Reactive Resume or JSON Resume tools.`,
},
];
type DownloadTabProps = {
/** Opens Check; the note about open issues links there. */
onReview: () => void;
};
/**
* Download: every format explained by when to use it, the file name recruiters see, and a button that shows
* its progress. Open Check issues are mentioned but never block. A failed file offers PDF instead.
*/
export function DownloadTab({ onReview }: DownloadTabProps) {
const resume = useCurrentResume();
const issues = useOpenIssueCount();
const fileNameId = useId();
const hasLetter = resumeHasCoverLetter(resume.data);
const [target, setTarget] = useState<ResumeExportTarget>("resume");
const [format, setFormat] = useState<ExportFormat>("pdf");
const [includeHeader, setIncludeHeader] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const [state, setState] = useState<"idle" | "busy" | "error">("idle");
const activeTarget = hasLetter ? target : "resume";
// JSON is the whole document's data, so it isn't offered for the letter on its own.
const activeFormat = activeTarget === "cover-letter" && format === "json" ? "pdf" : format;
const formats = getFormats();
const selected = formats.find((option) => option.id === activeFormat) ?? (formats[0] as Format);
const name = fileName ?? getDefaultFileName(resume, activeTarget);
const download = async (as: ExportFormat) => {
const extension = formats.find((option) => option.id === as)?.extension ?? ".pdf";
setState("busy");
try {
const blob = await createExportFile(resume, as, activeTarget, { includeCoverLetterHeader: includeHeader });
const file = `${sanitizeFileName(name) || getDefaultFileName(resume, activeTarget)}${extension}`;
downloadWithAnchor(blob, file);
setState("idle");
toast.add({ description: t`Downloaded ${file}` });
} catch {
setState("error");
}
};
return (
<div className="grid gap-4">
{hasLetter && (
<div className="grid gap-2">
<SegmentedControl
aria-label={t`What to download`}
value={activeTarget}
onValueChange={(value) => {
setTarget(value as ResumeExportTarget);
setFileName(null);
setState("idle");
}}
className="w-full"
>
<SegmentedControlItem value="resume">
<Trans>Resume</Trans>
</SegmentedControlItem>
<SegmentedControlItem value="cover-letter">
<Trans>Cover letter</Trans>
</SegmentedControlItem>
</SegmentedControl>
{activeTarget === "cover-letter" && (
<div className="flex items-center gap-2.5 text-sm">
<Checkbox
id={`${fileNameId}-header`}
checked={includeHeader}
onCheckedChange={(checked) => setIncludeHeader(checked === true)}
/>
<label htmlFor={`${fileNameId}-header`} className="cursor-pointer">
<Trans>Include the resume's header</Trans>
</label>
</div>
)}
</div>
)}
<RadioGroup
aria-label={t`Format`}
value={activeFormat}
onValueChange={(value) => {
setFormat(value as ExportFormat);
setState("idle");
}}
className="grid gap-1.5"
>
{formats.map((option) => {
const unavailable = option.id === "json" && activeTarget === "cover-letter";
return (
<Radio.Root
key={option.id}
value={option.id}
disabled={unavailable}
className="group/format flex cursor-pointer items-start gap-3 rounded-[10px] border border-line p-3 text-start transition-colors duration-quick hover:border-line-2 data-disabled:cursor-not-allowed data-checked:border-accent data-checked:bg-accent-soft data-disabled:opacity-45"
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-sunken text-ink-2">
<Icon name={option.icon} size={22} />
</span>
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{option.label}</span>
<span className="font-medium font-mono text-[11px] text-ink-3">{option.extension}</span>
{option.id === "pdf" && (
<span className="rounded bg-accent-soft px-1.5 font-semibold text-[11px] text-accent-text leading-[18px]">
<Trans>Best for applying</Trans>
</span>
)}
</span>
<span className="text-[13px] text-ink-2 leading-[18px]">{option.description}</span>
</span>
<span className="mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-full border-[1.5px] border-line-2 group-data-checked/format:border-accent">
<span className="size-2 rounded-full bg-accent opacity-0 group-data-checked/format:opacity-100" />
</span>
</Radio.Root>
);
})}
</RadioGroup>
<div className="grid gap-1.5">
<label htmlFor={fileNameId} className="font-medium text-ink-2 text-xs">
<Trans>File name</Trans>
</label>
<div className="flex h-[38px] items-center overflow-hidden rounded-lg border border-line-2 bg-raised focus-within:border-accent focus-within:ring-3 focus-within:ring-accent-soft">
<input
id={fileNameId}
value={name}
spellCheck={false}
aria-describedby={`${fileNameId}-hint`}
onChange={(event) => setFileName(sanitizeFileName(event.target.value))}
className="h-full min-w-0 flex-1 bg-transparent ps-2.5 font-medium font-mono text-[13px] text-ink outline-none"
/>
<span className="px-2.5 font-medium font-mono text-[13px] text-ink-3">{selected.extension}</span>
</div>
<span id={`${fileNameId}-hint`} className="text-ink-3 text-xs">
<Trans>Recruiters see this name. Your name plus “Resume” works well.</Trans>
</span>
</div>
{issues > 0 && (
<div className="flex gap-2.5 rounded-[10px] bg-warn-soft px-3 py-2.5 text-[13px] text-warn-text leading-[19px]">
<Icon name="fact_check" size={20} />
<span>
<Plural
value={issues}
one="Check has # thing to review. You can still download."
other="Check has # things to review. You can still download."
/>{" "}
<button type="button" className="font-medium underline underline-offset-2" onClick={onReview}>
<Trans>Review</Trans>
</button>
</span>
</div>
)}
{state === "error" && (
<div
role="alert"
className="flex flex-wrap items-start gap-2.5 rounded-[10px] bg-danger-soft px-3 py-2.5 text-[13px] text-danger-text leading-[19px]"
>
<Icon name="error" size={20} />
<span className="min-w-0 flex-1">
<Trans>The {selected.label} file couldn't be generated. Try again, or download PDF instead.</Trans>
</span>
{activeFormat !== "pdf" && (
<Button size="sm" variant="secondary" onClick={() => void download("pdf")}>
<Trans>Download PDF instead</Trans>
</Button>
)}
</div>
)}
<Button
className={cn("h-11 gap-2 text-[15px]")}
aria-busy={state === "busy"}
disabled={state === "busy"}
onClick={() => void download(activeFormat)}
>
{state === "busy" ? (
<>
<Spinner decorative className="size-4" />
<Trans>Preparing {selected.label} file…</Trans>
</>
) : state === "error" ? (
<>
<Icon name="refresh" />
<Trans>Try again</Trans>
</>
) : (
<>
<Icon name="download" />
<Trans>Download {selected.label}</Trans>
</>
)}
</Button>
</div>
);
}
@@ -0,0 +1,68 @@
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { formatTimeSince, formatVersionTime, getVersionTitle, summarizeViews } from "./format";
beforeAll(() => i18n.loadAndActivate({ locale: "en-US", messages: {} }));
const day = (date: string, views: number, downloads = 0) => ({ date, views, downloads });
describe("summarizeViews", () => {
it("totals the last 30 days and scales each day against the peak", () => {
const daily = [
day("2026-08-01", 50),
...Array.from({ length: 29 }, (_, index) => day(`d${index}`, 1, 1)),
day("today", 4, 2),
];
const summary = summarizeViews(daily);
expect(summary.bars).toHaveLength(30);
expect(summary.views).toBe(29 + 4);
expect(summary.downloads).toBe(29 + 2);
expect(summary.peak).toBe(4);
expect(summary.bars.at(-1)?.height).toBe(1);
expect(summary.bars[0]?.height).toBe(0.25);
});
it("draws flat bars when nobody has looked yet", () => {
expect(summarizeViews([day("a", 0), day("b", 0)])).toMatchObject({
views: 0,
peak: 0,
bars: [{ height: 0 }, { height: 0 }],
});
});
});
describe("formatTimeSince", () => {
const now = new Date("2026-09-28T12:00:00Z").getTime();
const ago = (ms: number) => new Date(now - ms);
it.each([
[5 * 60 * 1000, "5m"],
[2 * 60 * 60 * 1000, "2h"],
[3 * 24 * 60 * 60 * 1000, "3d"],
[20 * 24 * 60 * 60 * 1000, "2w"],
[400 * 24 * 60 * 60 * 1000, "1y"],
])("writes %i ms as %s", (elapsed, expected) => {
expect(formatTimeSince(ago(elapsed), "en-US", now)).toBe(expected);
});
});
describe("formatVersionTime", () => {
const now = new Date(2026, 8, 28, 15, 0);
it("names today and yesterday, then dates, with the year only when it differs", () => {
expect(formatVersionTime(new Date(2026, 8, 28, 14, 2), "en-US", now)).toMatch(/^Today 0?2:02 PM$|^Today 14:02$/);
expect(formatVersionTime(new Date(2026, 8, 27, 9, 15), "en-US", now)).toMatch(/^Yesterday/);
expect(formatVersionTime(new Date(2026, 8, 16), "en-US", now)).toBe("Sep 16");
expect(formatVersionTime(new Date(2025, 8, 16), "en-US", now)).toBe("Sep 16, 2025");
});
});
describe("getVersionTitle", () => {
it("uses the user's name for a named version and the kind otherwise", () => {
expect(getVersionTitle({ kind: "named", name: "Sent to Lumen" })).toBe("Sent to Lumen");
expect(getVersionTitle({ kind: "import", name: null })).toBe("Imported");
expect(getVersionTitle({ kind: "auto", name: null })).toBe("Editing session");
});
});
@@ -0,0 +1,90 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
type DailyStat = { date: string; views: number; downloads: number };
export type VersionSummary = RouterOutput["resume"]["listVersions"][number];
const DAY_MS = 24 * 60 * 60 * 1000;
/** The Link tab's numbers: views and downloads over the last 30 days, and each day's views against the peak. */
export function summarizeViews(daily: readonly DailyStat[]) {
const days = daily.slice(-30);
const peak = Math.max(0, ...days.map((day) => day.views));
return {
views: days.reduce((sum, day) => sum + day.views, 0),
downloads: days.reduce((sum, day) => sum + day.downloads, 0),
peak,
bars: days.map((day) => ({ date: day.date, views: day.views, height: peak > 0 ? day.views / peak : 0 })),
};
}
const UNITS = [
{ unit: "minute", ms: 60 * 1000, below: 60 },
{ unit: "hour", ms: 60 * 60 * 1000, below: 24 },
{ unit: "day", ms: DAY_MS, below: 7 },
{ unit: "week", ms: 7 * DAY_MS, below: 52 },
{ unit: "year", ms: 365 * DAY_MS, below: Number.POSITIVE_INFINITY },
] as const;
/** Time since a moment in the largest whole unit, written short: "5m", "2h", "3d", "6w", "1y". */
export function formatTimeSince(date: Date, locale: string, now = Date.now()) {
const elapsed = Math.max(0, now - date.getTime());
const { unit, ms } = UNITS.find(({ ms, below }) => elapsed / ms < below) ?? UNITS[UNITS.length - 1];
return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "narrow" }).format(Math.floor(elapsed / ms));
}
/** "Today 14:02", "Yesterday 09:15", "Sep 16", or "Sep 16, 2025" for another year. */
export function formatVersionTime(date: Date, locale: string, now = new Date()) {
const time = date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
if (date.getTime() >= startOfToday) return t`Today ${time}`;
if (date.getTime() >= startOfToday - DAY_MS) return t`Yesterday ${time}`;
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
});
}
/** A version's full date and time, for sentences: "Sep 28, 2026, 7:36 PM". */
export const formatVersionMoment = (date: Date, locale: string) =>
date.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
/** A version's title in History: its name when the user named it, otherwise what made it. */
export function getVersionTitle(version: Pick<VersionSummary, "kind" | "name">) {
switch (version.kind) {
case "named":
return version.name || t`Named version`;
case "created":
return t`Created`;
case "import":
return t`Imported`;
case "auto":
return t`Editing session`;
case "before-restore":
return t`Before restore`;
case "restored":
return t`Restored a version`;
case "ai":
return t`AI edit`;
case "sent":
return t`Sent`;
}
}
/** The line under a version's title: how it was saved. */
export function getVersionDetail(version: Pick<VersionSummary, "kind">) {
switch (version.kind) {
case "named":
return t`named`;
case "auto":
case "before-restore":
return t`autosaved`;
case "ai":
return t`from the assistant or API`;
default:
return t`saved`;
}
}
@@ -0,0 +1,281 @@
import type { Resume } from "@/features/resume/builder/draft";
import type { VersionSummary } from "./format";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { Input } from "@reactive-resume/ui/components/input";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { savePendingChanges, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { useConfirm } from "@/hooks/use-confirm";
import { usePrompt } from "@/hooks/use-prompt";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { formatVersionMoment, formatVersionTime, getVersionDetail, getVersionTitle } from "./format";
/**
* History: name the current state, or pick any version to see it on the page, read-only, then restore it or
* go back to now. Restoring saves the current state as "Before restore" first, so it can be undone.
*/
export function HistoryTab() {
const resume = useCurrentResume();
const queryClient = useQueryClient();
const { i18n } = useLingui();
const selectedId = useEditorStore((state) => state.historyVersionId);
const setVersion = useEditorStore((state) => state.setHistoryVersion);
const [name, setName] = useState("");
const listKey = orpc.resume.listVersions.queryKey({ input: { resumeId: resume.id } });
const { data: versions, isPending: loading } = useQuery(
orpc.resume.listVersions.queryOptions({ input: { resumeId: resume.id } }),
);
const createVersion = useMutation(orpc.resume.createVersion.mutationOptions());
const restoreVersion = useMutation(orpc.resume.restoreVersion.mutationOptions());
const selected = versions?.find((version) => version.id === selectedId) ?? null;
const when = (version: VersionSummary) => formatVersionTime(version.createdAt, i18n.locale);
const save = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
await savePendingChanges(resume.id);
await createVersion.mutateAsync({ resumeId: resume.id, name: trimmed });
setName("");
void queryClient.invalidateQueries({ queryKey: listKey });
toast.add({ description: t`Saved “${trimmed}”` });
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
};
const restore = async (version: VersionSummary) => {
try {
await savePendingChanges(resume.id);
const restored = await restoreVersion.mutateAsync({ resumeId: resume.id, versionId: version.id });
useResumeStore.getState().replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), restored);
setVersion(null);
void queryClient.invalidateQueries({ queryKey: listKey });
toast.add({
description: t`Restored the version from ${formatVersionMoment(version.createdAt, i18n.locale)}. Your previous state is saved as “Before restore”.`,
});
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
};
return (
<div className="grid gap-3.5">
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<Input
value={name}
maxLength={80}
aria-label={t`Name this version`}
placeholder={t`Name this version, e.g. Sent to Lumen`}
onChange={(event) => setName(event.target.value)}
className="h-9"
/>
<Button type="submit" variant="secondary" disabled={!name.trim() || createVersion.isPending}>
<Trans>Save</Trans>
</Button>
</form>
{selected && (
<div role="status" className="grid gap-2.5 rounded-[10px] bg-ink p-3 text-[13px] text-bg">
<span className="flex items-center gap-2 font-medium">
<Icon name="history" size={18} />
<Trans>
Viewing {when(selected)} · {getVersionTitle(selected)} · read-only
</Trans>
</span>
<span className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={resume.isLocked || restoreVersion.isPending}
onClick={() => void restore(selected)}
>
<Trans>Restore this version</Trans>
</Button>
<Button
size="sm"
variant="ghost"
className="text-bg underline underline-offset-[3px] hover:bg-transparent hover:text-bg"
onClick={() => setVersion(null)}
>
<Trans>Back to now</Trans>
</Button>
</span>
</div>
)}
<ol className="grid" aria-label={t`Versions`}>
<TimelineItem
title={t`Now`}
detail={t`The resume as it is`}
selected={!selected}
current
last={!versions?.length}
onSelect={() => setVersion(null)}
/>
{versions?.map((version, index) => (
<TimelineItem
key={version.id}
title={getVersionTitle(version)}
detail={`${when(version)} · ${getVersionDetail(version)}`}
named={version.kind === "named"}
selected={version.id === selectedId}
last={index === versions.length - 1}
onSelect={() => setVersion(version.id)}
menu={version.kind === "named" ? <NamedVersionMenu version={version} /> : null}
/>
))}
</ol>
{!loading && (versions?.length ?? 0) <= 1 && (
<p className="text-[13px] text-ink-2 leading-[19px]">
<Trans>Only one version so far. Every editing session adds one automatically.</Trans>
</p>
)}
<p className="text-ink-3 text-xs leading-[17px]">
<Trans>
Autosaves are grouped by session and kept for 90 days. Named versions are kept until you delete them.
Restoring saves the current state first.
</Trans>
</p>
</div>
);
}
type TimelineItemProps = {
title: string;
detail: string;
selected: boolean;
last: boolean;
onSelect: () => void;
current?: boolean;
named?: boolean;
menu?: React.ReactNode;
};
function TimelineItem({ title, detail, selected, last, onSelect, current, named, menu }: TimelineItemProps) {
return (
<li className="grid grid-cols-[20px_minmax(0,1fr)] gap-2.5">
<span aria-hidden="true" className="flex flex-col items-center">
<span
className={cn(
"mt-[15px] size-2.5 shrink-0 rounded-full shadow-[0_0_0_3px_var(--raised)]",
current ? "bg-accent" : selected ? "bg-ink" : "bg-line-2",
)}
/>
{!last && <span className="w-[1.5px] flex-1 bg-line" />}
</span>
<span
className={cn(
"group/version my-0.5 flex items-start rounded-[10px] border transition-colors duration-quick",
selected ? "border-line-2 bg-sunken" : "border-transparent hover:bg-hover",
)}
>
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 px-2.5 py-[9px] text-start"
>
<span className="flex max-w-full items-center gap-2">
<span className="truncate font-semibold text-[13px]">{title}</span>
{named && <Icon name="bookmark" size={14} className="text-accent-text" />}
</span>
<span className="text-ink-3 text-xs">{detail}</span>
</button>
{menu && <span className="p-1">{menu}</span>}
</span>
</li>
);
}
/** Named versions are the user's own: they can be renamed or deleted. The rest expire on their own. */
function NamedVersionMenu({ version }: { version: VersionSummary }) {
const resume = useCurrentResume();
const queryClient = useQueryClient();
const prompt = usePrompt();
const confirm = useConfirm();
const selectedId = useEditorStore((state) => state.historyVersionId);
const setVersion = useEditorStore((state) => state.setHistoryVersion);
const renameVersion = useMutation(orpc.resume.renameVersion.mutationOptions());
const deleteVersion = useMutation(orpc.resume.deleteVersion.mutationOptions());
const refresh = () =>
queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId: resume.id } }) });
const title = getVersionTitle(version);
const rename = async () => {
const name = (await prompt(t`Rename version`, { defaultValue: version.name ?? "" }))?.trim();
if (!name || name === version.name) return;
try {
await renameVersion.mutateAsync({ resumeId: resume.id, versionId: version.id, name });
void refresh();
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
};
const remove = async () => {
const confirmed = await confirm(t`Delete “${title}”?`, {
description: t`Named versions are kept until you delete them. This can't be undone.`,
confirmText: t`Delete`,
});
if (!confirmed) return;
try {
await deleteVersion.mutateAsync({ resumeId: resume.id, versionId: version.id });
if (selectedId === version.id) setVersion(null);
void refresh();
toast.add({ description: t`Deleted “${title}”` });
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<IconButton
icon="more_horiz"
label={t`Options for ${title}`}
size="icon-sm"
className="text-ink-2 opacity-0 focus-visible:opacity-100 group-hover/version:opacity-100 data-popup-open:opacity-100"
/>
}
/>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => void rename()}>
<Icon name="edit" />
<Trans>Rename…</Trans>
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => void remove()}>
<Icon name="delete" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -7,17 +7,18 @@ import { I18nProvider } from "@lingui/react";
import { ORPCError } from "@orpc/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { SharingSectionBuilder } from "@/routes/builder/$resumeId/-sidebar/right/sections/sharing";
type SectionBaseProps = { children: React.ReactNode };
import { LinkTab } from "./link-tab";
const mocks = vi.hoisted(() => ({
setPassword: vi.fn(),
update: vi.fn(),
checkSlug: vi.fn(),
patchResume: vi.fn(),
resume: { id: "resume-id", name: "Resume", slug: "resume", isPublic: true, hasPassword: false, isLocked: false },
}));
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => ({ id: "resume-id", slug: "resume", isPublic: true, hasPassword: false }),
useCurrentResume: () => mocks.resume,
usePatchResume: () => mocks.patchResume,
}));
vi.mock("@/libs/auth/client", () => ({
@@ -27,16 +28,28 @@ vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
setPassword: { mutationOptions: () => ({ mutationFn: mocks.setPassword }) },
update: { mutationOptions: () => ({ mutationFn: vi.fn() }) },
update: { mutationOptions: () => ({ mutationFn: mocks.update }) },
removePassword: { mutationOptions: () => ({ mutationFn: vi.fn() }) },
checkSlug: {
queryOptions: ({ input }: { input: { slug: string } }) => ({
queryKey: ["checkSlug", input.slug],
queryFn: () => mocks.checkSlug(input),
}),
},
statistics: {
getById: { queryOptions: () => ({ queryKey: ["stats"], queryFn: async () => ({ lastViewedAt: null }) }) },
getDailyById: { queryOptions: () => ({ queryKey: ["daily"], queryFn: async () => [] }) },
},
},
},
}));
vi.mock("@/hooks/use-confirm", () => ({ useConfirm: () => vi.fn() }));
vi.mock("@/routes/builder/$resumeId/-sidebar/right/shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn(), close: vi.fn() } }));
vi.mock("usehooks-ts", async (importOriginal) => ({
...(await importOriginal<typeof import("usehooks-ts")>()),
// The address check waits 300 ms after typing; tests skip the wait.
useDebounceValue: <T,>(value: T) => [value, vi.fn()],
}));
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -44,21 +57,26 @@ beforeAll(() => {
beforeEach(() => {
vi.clearAllMocks();
mocks.setPassword.mockResolvedValue(undefined);
mocks.update.mockImplementation(async (input: { slug?: string }) => ({ ...mocks.resume, ...input }));
});
afterEach(cleanup);
async function openDialog() {
function renderTab() {
render(
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<PromptDialogProvider>
<SharingSectionBuilder />
<LinkTab />
</PromptDialogProvider>
</QueryClientProvider>
</I18nProvider>,
);
}
async function openDialog() {
renderTab();
await act(() => {
fireEvent.click(screen.getByRole("button", { name: "Set Password" }));
fireEvent.click(screen.getByRole("switch", { name: "Require a password" }));
});
return within(screen.queryByRole("dialog") ?? screen.getByRole("alertdialog"));
}
@@ -156,10 +174,53 @@ describe("resume password sharing", () => {
await act(() => {
fireEvent.click(dialog.getByRole("button", { name: "Cancel" }));
});
// Cancelling leaves the switch off; turning it on again starts over.
expect(screen.getByRole("switch", { name: "Require a password" })).not.toBeChecked();
await act(() => {
fireEvent.click(screen.getByRole("button", { name: "Set Password" }));
fireEvent.click(screen.getByRole("switch", { name: "Require a password" }));
});
expect((screen.getByLabelText("Password", { exact: true }) as HTMLInputElement).value).toBe("");
expect(mocks.setPassword).not.toHaveBeenCalled();
});
});
describe("the address", () => {
const address = () => screen.getByRole("textbox", { name: "Address" });
const type = (value: string) =>
act(() => {
fireEvent.change(address(), { target: { value } });
});
it("explains the pattern without asking the server", async () => {
renderTab();
await type("My Resume!");
expect(address()).toHaveValue("my-resume!");
expect(screen.getByText("Use lowercase letters, numbers and single dashes.")).toBeInTheDocument();
expect(mocks.checkSlug).not.toHaveBeenCalled();
});
it("names the resume using a taken address and applies the suggestion", async () => {
mocks.checkSlug.mockResolvedValueOnce({ status: "taken", takenBy: "Resume 2024", suggestion: "resume-lumen" });
renderTab();
await type("taken");
expect(await screen.findByText("You already use this for “Resume 2024”.")).toBeInTheDocument();
mocks.checkSlug.mockResolvedValueOnce({ status: "available" });
await act(() => {
fireEvent.click(screen.getByRole("button", { name: "Try resume-lumen" }));
});
expect(address()).toHaveValue("resume-lumen");
});
it("saves a new address once it checks out, keeping the old one until then", async () => {
mocks.checkSlug.mockResolvedValueOnce({ status: "available" });
renderTab();
await type("product-designer");
await vi.waitFor(() =>
expect(mocks.update).toHaveBeenCalledWith({ id: "resume-id", slug: "product-designer" }, expect.anything()),
);
expect(mocks.patchResume).toHaveBeenCalled();
});
});
@@ -0,0 +1,406 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useId, useRef, useState } from "react";
import { useCopyToClipboard, useDebounceValue } from "usehooks-ts";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Separator } from "@reactive-resume/ui/components/separator";
import { SwitchRow } from "@reactive-resume/ui/components/switch";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, usePatchResume } from "@/features/resume/builder/draft";
import { ResumePasswordDialog } from "@/features/resume/builder/password-dialog";
import { useConfirm } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { orpc } from "@/libs/orpc/client";
import { formatTimeSince, summarizeViews } from "./format";
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const COPIED_MS = 2000;
const errorMessage = (error: unknown) =>
error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
/**
* Link: the public switch, the address with its live check, copy, downloads for visitors, open, QR code and
* the password, then views and downloads. Private is the default; turning the link off keeps the address.
*/
export function LinkTab() {
const resume = useCurrentResume();
const patchResume = usePatchResume();
const { data: session } = authClient.useSession();
const { mutateAsync: updateResume, isPending } = useMutation(orpc.resume.update.mutationOptions());
const isPublic = resume.isPublic ?? false;
const url = `${window.location.origin}/${session?.user.username ?? ""}/${resume.slug}`;
const setPublic = async (checked: boolean) => {
try {
const updated = await updateResume({ id: resume.id, isPublic: checked });
patchResume((draft) => {
draft.isPublic = updated.isPublic;
});
toast.add({ description: checked ? t`Link is live` : t`Link turned off. The address is kept` });
} catch (error) {
toast.add({ type: "error", description: errorMessage(error) });
}
};
const setVisitorDownloads = async (checked: boolean) => {
try {
const updated = await updateResume({ id: resume.id, showDownloadButtons: checked });
patchResume((draft) => {
draft.showDownloadButtons = updated.showDownloadButtons;
});
} catch (error) {
toast.add({ type: "error", description: errorMessage(error) });
}
};
return (
<div className="grid gap-[18px]">
<SwitchRow
checked={isPublic}
disabled={isPending || resume.isLocked}
onCheckedChange={(checked) => void setPublic(checked)}
label={<span className="font-semibold">{t`Public link`}</span>}
description={
isPublic
? t`On. Anyone with the link can view. It isn't listed or indexed by search engines.`
: t`Off. Only you can see this resume.`
}
className="items-start rounded-xl border border-line p-3.5 transition-colors duration-standard data-checked:border-accent data-checked:bg-accent-soft"
/>
<AddressField url={url} username={session?.user.username ?? ""} />
{isPublic && (
<div className="grid gap-3">
<SwitchRow
checked={resume.showDownloadButtons !== false}
disabled={isPending || resume.isLocked}
onCheckedChange={(checked) => void setVisitorDownloads(checked)}
label={t`Visitors can download the PDF`}
className="py-0"
/>
<PasswordRow />
<div className="flex flex-wrap gap-2">
<a
href={url}
target="_blank"
rel="noopener"
className={buttonVariants({ variant: "ghost", size: "sm", className: "gap-1.5" })}
>
<Icon name="open_in_new" size={18} />
<Trans>Open public page</Trans>
</a>
<QrCodeButton url={url} />
</div>
</div>
)}
<Separator />
<ViewsAndDownloads isPublic={isPublic} />
</div>
);
}
type AddressFieldProps = { url: string; username: string };
/** The address, checked as you type (300 ms). The old one stays live until the new one is valid and saved. */
function AddressField({ url, username }: AddressFieldProps) {
const resume = useCurrentResume();
const patchResume = usePatchResume();
const id = useId();
const isPublic = resume.isPublic ?? false;
const [slug, setSlug] = useState(resume.slug);
const [debouncedSlug] = useDebounceValue(slug, 300);
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const copiedTimer = useRef<number>(undefined);
const { mutateAsync: updateResume } = useMutation(orpc.resume.update.mutationOptions());
const changed = slug !== resume.slug;
const wellFormed = SLUG_PATTERN.test(slug);
const check = useQuery({
...orpc.resume.checkSlug.queryOptions({ input: { resumeId: resume.id, slug: debouncedSlug } }),
enabled: isPublic && changed && wellFormed && debouncedSlug === slug,
});
const result = debouncedSlug === slug ? check.data : undefined;
// Save as soon as a new address checks out; until then the current one keeps working.
const saving = useRef<string | null>(null);
useEffect(() => {
if (result?.status !== "available" || saving.current === slug) return;
saving.current = slug;
updateResume({ id: resume.id, slug })
.then((updated) =>
patchResume((draft) => {
draft.slug = updated.slug;
}),
)
.catch((error: unknown) => toast.add({ type: "error", description: errorMessage(error) }))
.finally(() => {
saving.current = null;
});
}, [result?.status, slug, resume.id, updateResume, patchResume]);
// A save here, another tab or a restore can change the address; the field follows it.
useEffect(() => setSlug(resume.slug), [resume.slug]);
const invalid = isPublic && (!slug || !wellFormed || result?.status === "taken");
const message = !isPublic
? t`Turn on the link to choose an address.`
: !slug
? t`Add an address.`
: !wellFormed
? t`Use lowercase letters, numbers and single dashes.`
: !changed
? t`Live at ${url.replace(/^https?:\/\//, "")}`
: result?.status === "taken"
? null
: result?.status === "available"
? t`Available · ${window.location.host}/${username}/${slug}`
: t`Checking…`;
const copy = async () => {
await copyToClipboard(url);
setCopied(true);
window.clearTimeout(copiedTimer.current);
copiedTimer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
toast.add({ description: t`Link copied` });
};
const canShare = typeof navigator.share === "function";
return (
<div className={cn("grid gap-1.5 transition-opacity duration-standard", !isPublic && "opacity-45")}>
<label htmlFor={id} className="font-medium text-ink-2 text-xs">
<Trans>Address</Trans>
</label>
<div className="flex gap-1.5">
<div
className={cn(
"flex h-[38px] min-w-0 flex-1 items-center overflow-hidden rounded-lg border bg-raised transition-colors duration-quick focus-within:border-accent focus-within:ring-3 focus-within:ring-accent-soft",
invalid ? "border-danger" : "border-line-2",
)}
>
{/* A long host or username truncates, so the part being edited stays visible. */}
<span className="max-w-[55%] truncate ps-2.5 font-medium font-mono text-[13px] text-ink-3" dir="ltr">
{window.location.host}/{username}/
</span>
<input
id={id}
value={slug}
disabled={!isPublic || resume.isLocked}
spellCheck={false}
aria-invalid={invalid}
aria-describedby={`${id}-message`}
onChange={(event) => setSlug(event.target.value.toLowerCase().replace(/\s+/g, "-"))}
className="h-full min-w-0 flex-1 bg-transparent pe-1.5 font-medium font-mono text-[13px] text-ink outline-none"
/>
{isPublic && slug && (
<Icon
name={invalid ? "error" : "check_circle"}
size={18}
className={cn("me-2.5", invalid ? "text-danger-text" : "text-accent-text")}
/>
)}
</div>
<Button variant="secondary" className="h-[38px] gap-1.5" disabled={!isPublic || changed} onClick={copy}>
<Icon name={copied ? "check" : "content_copy"} size={18} />
{copied ? <Trans>Copied</Trans> : <Trans>Copy</Trans>}
</Button>
</div>
<p
id={`${id}-message`}
role="status"
className={cn("break-all text-xs leading-4", invalid ? "text-danger-text" : "text-ink-3")}
>
{result?.status === "taken" ? (
<>
{result.takenBy ? (
<Trans>You already use this for “{result.takenBy}”.</Trans>
) : (
<Trans>Another of your resumes uses this address.</Trans>
)}{" "}
{result.suggestion && (
<button
type="button"
className="font-medium text-ink underline underline-offset-2"
onClick={() => setSlug(result.suggestion ?? slug)}
>
<Trans>Try {result.suggestion}</Trans>
</button>
)}
</>
) : (
message
)}
</p>
{isPublic && canShare && (
<Button
variant="secondary"
className="pointer-fine:hidden w-fit gap-1.5"
onClick={() => void navigator.share({ title: resume.name, url }).catch(() => undefined)}
>
<Icon name="ios_share" size={18} />
<Trans>Share via…</Trans>
</Button>
)}
</div>
);
}
/** Q3a: visitors enter a password before they see the resume. */
function PasswordRow() {
const resume = useCurrentResume();
const patchResume = usePatchResume();
const confirm = useConfirm();
const [dialogOpen, setDialogOpen] = useState(false);
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
const turnOff = async () => {
const confirmed = await confirm(t`Remove the password?`, {
description: t`Anyone with the link will be able to view your resume.`,
confirmText: t`Remove`,
});
if (!confirmed) return;
try {
await removePassword({ id: resume.id });
patchResume((draft) => {
draft.hasPassword = false;
});
toast.add({ description: t`Password removed` });
} catch (error) {
toast.add({ type: "error", description: errorMessage(error) });
}
};
return (
<>
<SwitchRow
checked={resume.hasPassword ?? false}
disabled={resume.isLocked}
onCheckedChange={(checked) => (checked ? setDialogOpen(true) : void turnOff())}
label={t`Require a password`}
description={
resume.hasPassword
? t`Visitors enter it before they see the resume. Share it only with people you trust.`
: undefined
}
className="py-0"
/>
{dialogOpen && (
<ResumePasswordDialog
onClose={() => setDialogOpen(false)}
onSubmit={async (password) => {
await setPassword({ id: resume.id, password });
patchResume((draft) => {
draft.hasPassword = true;
});
toast.add({ description: t`Password set` });
}}
/>
)}
</>
);
}
function QrCodeButton({ url }: { url: string }) {
return (
<Popover>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" className="gap-1.5">
<Icon name="qr_code_2" size={18} />
<Trans>QR code</Trans>
</Button>
}
/>
<PopoverContent className="w-auto p-4">
<QRCodeSVG value={url} size={176} marginSize={2} title={t`QR code for ${url}`} />
</PopoverContent>
</Popover>
);
}
/** Only the owner sees these; counts are anonymous and kept for 90 days. */
function ViewsAndDownloads({ isPublic }: { isPublic: boolean }) {
const resume = useCurrentResume();
const { i18n } = useLingui();
const { data: statistics } = useQuery(orpc.resume.statistics.getById.queryOptions({ input: { id: resume.id } }));
const { data: daily } = useQuery({
...orpc.resume.statistics.getDailyById.queryOptions({ input: { id: resume.id, days: 30 } }),
enabled: isPublic,
});
const summary = summarizeViews(daily ?? []);
const lastView = statistics?.lastViewedAt ? formatTimeSince(statistics.lastViewedAt, i18n.locale) : "—";
const firstDay = daily?.[0]?.date;
return (
<section aria-labelledby="share-stats-title" className="grid gap-3">
<div className="flex items-baseline justify-between">
<h3 id="share-stats-title" className="font-semibold text-sm">
<Trans>Views and downloads</Trans>
</h3>
<span className="text-ink-3 text-xs">
<Trans>Only you see these</Trans>
</span>
</div>
{isPublic ? (
<>
<dl className="grid grid-cols-3 gap-2.5">
<Stat value={summary.views} label={t`views · 30 days`} />
<Stat value={summary.downloads} label={t`downloads`} />
<Stat value={lastView} label={t`since last view`} />
</dl>
<div
role="img"
aria-label={t`Daily views over 30 days, peak ${summary.peak}`}
className="flex h-14 items-end gap-[3px] pt-1"
>
{summary.bars.map((bar) => (
<span
key={bar.date}
className="flex-1 rounded-t-[2px] bg-line-2"
style={{ height: `${Math.max(6, Math.round(bar.height * 100))}%` }}
/>
))}
</div>
<div className="flex justify-between font-medium font-mono text-[11px] text-ink-3">
<span>
{firstDay
? new Date(`${firstDay}T00:00:00`).toLocaleDateString(i18n.locale, { month: "short", day: "numeric" })
: ""}
</span>
<span>
<Trans>Today</Trans>
</span>
</div>
</>
) : (
<p className="text-[13px] text-ink-2 leading-[19px]">
<Trans>
Turn on the public link to count views and downloads. Counts are anonymous and kept for 90 days.
</Trans>
</p>
)}
</section>
);
}
function Stat({ value, label }: { value: number | string; label: string }) {
return (
<div className="flex flex-col-reverse gap-0.5">
<dt className="text-ink-3 text-xs">{label}</dt>
<dd className="font-display font-medium text-[26px] leading-[30px]">{value}</dd>
</div>
);
}
+5
View File
@@ -17,6 +17,11 @@ export const Route = createFileRoute("/$username/$slug")({
orpc.resume.getBySlug.queryOptions({ input: { username, slug } }),
);
// A renamed resume's old address still finds it for 30 days; send visitors to the current one.
if (resume.slug !== slug) {
throw redirect({ to: "/$username/$slug", params: { username, slug: resume.slug }, replace: true });
}
return { resume: resume as LoaderData };
},
head: ({ loaderData, params }) => {
@@ -11,12 +11,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/com
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { BuilderAiAssistant } from "./ai-assistant";
import { DocumentMenu } from "./document-menu";
import { useOpenIssueCount } from "./use-open-issue-count";
import { BuilderVersionHistory } from "./version-history";
type EditorBarProps = {
layout: "desktop" | "tablet" | "mobile";
@@ -49,7 +47,7 @@ export function EditorBar({ layout, pinnable }: EditorBarProps) {
{layout === "desktop" && (
<>
<UndoButton />
<BuilderVersionHistory resumeId={resumeId} />
<HistoryButton />
<span className="me-1.5">
<BuilderAiAssistant resumeId={resumeId} />
</span>
@@ -170,6 +168,13 @@ function DrawerControls({ pinnable }: { pinnable: boolean }) {
);
}
/** History lives in the Share & export sheet; the clock opens it there. */
function HistoryButton() {
const setShareTab = useEditorStore((state) => state.setShareTab);
return <IconButton icon="history" label={t`History`} className="text-ink-2" onClick={() => setShareTab("history")} />;
}
type ToolbarActionProps = {
compact: boolean;
disabled: boolean;
@@ -177,7 +182,7 @@ type ToolbarActionProps = {
function ShareButton({ compact, disabled }: ToolbarActionProps) {
const isPublic = useCurrentBuilderResumeSelector((resume) => resume.isPublic ?? false);
const setShareOpen = useEditorStore((state) => state.setShareOpen);
const setShareTab = useEditorStore((state) => state.setShareTab);
const liveDot = isPublic ? (
<span aria-hidden="true" className="absolute end-1.5 top-1.5 size-[7px] rounded-full bg-accent" />
) : null;
@@ -190,7 +195,7 @@ function ShareButton({ compact, disabled }: ToolbarActionProps) {
label={t`Share`}
shortcut="⌘⇧S"
disabled={disabled}
onClick={() => setShareOpen(true)}
onClick={() => setShareTab("link")}
/>
{liveDot}
</span>
@@ -198,7 +203,7 @@ function ShareButton({ compact, disabled }: ToolbarActionProps) {
}
return (
<Button variant="secondary" className="relative gap-1.5" disabled={disabled} onClick={() => setShareOpen(true)}>
<Button variant="secondary" className="relative gap-1.5" disabled={disabled} onClick={() => setShareTab("link")}>
<Icon name="ios_share" />
<Trans>Share</Trans>
{isPublic && (
@@ -211,26 +216,21 @@ function ShareButton({ compact, disabled }: ToolbarActionProps) {
);
}
/** Download PDF is the only filled button; the ▾ segment opens every format. */
/** Download PDF is the only filled button; the ▾ segment opens the Download tab with every format. */
function DownloadButtons({ compact, disabled }: ToolbarActionProps) {
const resume = useCurrentResume();
const { onDownloadPDF, isExporting } = useResumeExport(resume);
const downloadOpen = useEditorStore((state) => state.downloadOpen);
const setDownloadOpen = useEditorStore((state) => state.setDownloadOpen);
const setShareTab = useEditorStore((state) => state.setShareTab);
if (compact) {
return (
<>
<IconButton
icon="download"
label={t`Download PDF`}
shortcut="⌘P"
disabled={disabled || isExporting}
onClick={() => void onDownloadPDF()}
/>
{/* Opened from the Share sheet. */}
<ResumeDownloadDialog resume={resume} open={downloadOpen} onOpenChange={setDownloadOpen} />
</>
<IconButton
icon="download"
label={t`Download PDF`}
shortcut="⌘P"
disabled={disabled || isExporting}
onClick={() => void onDownloadPDF()}
/>
);
}
@@ -240,21 +240,15 @@ function DownloadButtons({ compact, disabled }: ToolbarActionProps) {
{!isExporting && <Icon name="download" />}
{isExporting ? <Trans>Preparing…</Trans> : <Trans>Download PDF</Trans>}
</Button>
<ResumeDownloadDialog
resume={resume}
open={downloadOpen}
onOpenChange={setDownloadOpen}
trigger={(exporting) => (
<Button
size="icon"
disabled={disabled || exporting}
aria-label={t`More download formats`}
className="w-8 border-s border-s-[oklch(1_0_0/0.25)]"
>
<Icon name="expand_more" />
</Button>
)}
/>
<Button
size="icon"
disabled={disabled || isExporting}
aria-label={t`More download formats`}
className="w-8 border-s border-s-[oklch(1_0_0/0.25)]"
onClick={() => setShareTab("download")}
>
<Icon name="expand_more" />
</Button>
</ButtonGroup>
);
}
@@ -1,7 +1,9 @@
import type { EditorSelection } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Plural, Trans } from "@lingui/react/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
@@ -13,6 +15,8 @@ import { PageOverlay } from "@/features/resume/editor/page-overlay";
import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { ResumePreview } from "@/features/resume/preview/preview";
import { formatVersionTime, getVersionTitle } from "@/features/resume/share/format";
import { orpc } from "@/libs/orpc/client";
import { useEditorMode } from "./use-editor-mode";
// Page widths in PDF points; 1pt renders as 1 CSS px at 100%.
@@ -46,6 +50,10 @@ export function PageCanvas() {
const select = useEditorStore((state) => state.select);
const setDrawerOpen = useEditorStore((state) => state.setDrawerOpen);
const previewTemplate = useEditorStore((state) => state.previewTemplate);
const historyVersionId = useEditorStore((state) => state.historyVersionId);
const sheetOpen = useEditorStore((state) => state.shareTab !== null);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const { i18n } = useLingui();
const rendered = useEditorStore((state) => state.rendered);
const setRendered = useEditorStore((state) => state.setRendered);
const breakpoint = useBreakpoint();
@@ -58,13 +66,26 @@ export function PageCanvas() {
const pageScale = zoom === "fit" ? Math.max(0.25, fitScale) : zoom;
const formatLabel = { a4: "A4", letter: t`Letter`, "free-form": t`Free-form` }[format];
// History: the picked version is drawn on the page, read-only, until the user restores it or goes back to now.
const { data: version } = useQuery({
...orpc.resume.getVersion.queryOptions({ input: { resumeId, versionId: historyVersionId ?? "" } }),
enabled: historyVersionId !== null,
});
const viewing = historyVersionId !== null && version?.id === historyVersionId ? version : null;
// Design: a hovered or focused template is drawn on the page until it's applied or the pointer leaves.
const previewData = useMemo(
() =>
data && previewTemplate ? { ...data, metadata: { ...data.metadata, template: previewTemplate } } : undefined,
[data, previewTemplate],
viewing
? viewing.data
: data && previewTemplate
? { ...data, metadata: { ...data.metadata, template: previewTemplate } }
: undefined,
[data, previewTemplate, viewing],
);
const overflow = data && !previewTemplate ? measureOverflow(data, rendered) : null;
const overflow = data && !previewTemplate && !viewing ? measureOverflow(data, rendered) : null;
// Desktop: the page moves 120px aside so it stays visible beside the Share & export sheet.
const shifted = sheetOpen && (breakpoint === "desktop" || breakpoint === "wide");
const onSelect = (selection: EditorSelection) => {
select(selection);
@@ -92,13 +113,27 @@ export function PageCanvas() {
pageLayout="vertical"
pageGap={24}
pageScale={pageScale}
className="mx-auto w-fit"
pageClassName="rounded-none shadow-page"
className={cn(
"mx-auto w-fit transition-transform duration-emphasized ease-enter",
shifted && "-translate-x-[120px] rtl:translate-x-[120px]",
)}
pageClassName={cn("rounded-none shadow-page", viewing && "outline-2 outline-ink outline-offset-4")}
onRender={setRendered}
renderPageCaption={({ pageNumber }) =>
pageNumber === 1 ? (
<figcaption className="mb-2.5 flex min-h-8 flex-wrap items-center justify-center gap-2.5 text-center font-medium text-ink-3 text-xs">
{previewTemplate ? (
{viewing ? (
<span
role="status"
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
>
<Icon name="history" size={18} />
<Trans>
Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} ·
read-only
</Trans>
</span>
) : previewTemplate ? (
<span
role="status"
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
@@ -126,9 +161,10 @@ export function PageCanvas() {
</figcaption>
)
}
renderPageOverlay={({ pageIndex, pageMap }) => (
<PageOverlay pageIndex={pageIndex} pageMap={pageMap} onSelect={onSelect} />
)}
renderPageOverlay={({ pageIndex, pageMap }) =>
// A version from History is read-only: its lines don't open entries.
viewing ? null : <PageOverlay pageIndex={pageIndex} pageMap={pageMap} onSelect={onSelect} />
}
/>
</div>
@@ -1,41 +1,75 @@
import type { ShareTab } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useEditorStore } from "@/features/resume/editor/store";
import { SharingSectionBuilder } from "../-sidebar/right/sections/sharing";
import { StatisticsSectionBuilder } from "../-sidebar/right/sections/statistics";
import { DownloadTab } from "@/features/resume/share/download-tab";
import { HistoryTab } from "@/features/resume/share/history-tab";
import { LinkTab } from "@/features/resume/share/link-tab";
import { useEditorMode } from "./use-editor-mode";
/** The public link, its statistics and every download format. History joins this sheet in M5. */
/**
* Share & export: one sheet with Link, Download and History. Share opens Link, the ▾ beside Download PDF
* opens Download and the clock opens History. 440px from the right; a full-height bottom sheet on phones.
*/
export function ShareSheet() {
const open = useEditorStore((state) => state.shareOpen);
const setOpen = useEditorStore((state) => state.setShareOpen);
const setDownloadOpen = useEditorStore((state) => state.setDownloadOpen);
const tab = useEditorStore((state) => state.shareTab);
const setTab = useEditorStore((state) => state.setShareTab);
const setHistoryVersion = useEditorStore((state) => state.setHistoryVersion);
const [, setMode] = useEditorMode();
const isPhone = useBreakpoint() === "mobile";
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-[440px]">
<SheetHeader>
<Sheet open={tab !== null} onOpenChange={(open) => !open && setTab(null)}>
<SheetContent
side={isPhone ? "bottom" : "right"}
closeLabel={t`Close`}
className={cn("gap-0", isPhone && "h-[calc(100svh-1.5rem)]")}
>
<SheetHeader className="px-5 pt-3.5 pb-2.5">
<SheetTitle>
<Trans>Share & export</Trans>
</SheetTitle>
</SheetHeader>
<div className="space-y-8 px-6 pb-8">
<SharingSectionBuilder />
<StatisticsSectionBuilder />
<Button
variant="secondary"
className="w-full gap-1.5"
onClick={() => {
setOpen(false);
setDownloadOpen(true);
}}
>
<Icon name="download" />
<Trans>More download formats…</Trans>
</Button>
</div>
<Tabs
value={tab ?? "link"}
onValueChange={(value) => {
// The page shows a version only while History is open.
if (value !== "history") setHistoryVersion(null);
setTab(value as ShareTab);
}}
className="min-h-0 flex-1 gap-0"
>
<TabsList variant="line" aria-label={t`Share sections`} className="w-full justify-start gap-5 px-5">
<TabsTrigger value="link">
<Trans>Link</Trans>
</TabsTrigger>
<TabsTrigger value="download">
<Trans>Download</Trans>
</TabsTrigger>
<TabsTrigger value="history">
<Trans>History</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="link" className="overflow-y-auto p-5">
<LinkTab />
</TabsContent>
<TabsContent value="download" className="overflow-y-auto p-5">
<DownloadTab
onReview={() => {
setTab(null);
setMode("check");
}}
/>
</TabsContent>
<TabsContent value="history" className="overflow-y-auto p-5">
<HistoryTab />
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
@@ -17,7 +17,7 @@ export function useEditorHotkeys(setMode: (mode: EditorMode) => void) {
const redo = useResumeStore((state) => state.redo);
const resume = useCurrentResume();
const { onDownloadPDF } = useResumeExport(resume);
const { select, setShareOpen, setDownloadOpen, setAssistantOpen } = useEditorStore.getState();
const { select, setShareTab, setAssistantOpen } = useEditorStore.getState();
const isOnline = () => useResumeStore.getState().saveStatus !== "offline";
useHotkey("1", () => setMode("write"));
@@ -38,10 +38,10 @@ export function useEditorHotkeys(setMode: (mode: EditorMode) => void) {
if (isOnline()) void onDownloadPDF();
});
useHotkey("Mod+Shift+S", () => {
if (isOnline()) setShareOpen(true);
if (isOnline()) setShareTab("link");
});
useHotkey("Mod+Shift+E", () => {
if (isOnline()) setDownloadOpen(true);
if (isOnline()) setShareTab("download");
});
useHotkey("Mod+J", () => setAssistantOpen(!useEditorStore.getState().assistantOpen));
useHotkey("Escape", () => select(null), { preventDefault: false, stopPropagation: false });
@@ -1,101 +0,0 @@
import type { Resume } from "@/features/resume/builder/draft";
import { i18n } from "@lingui/core";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { toast } from "@reactive-resume/ui/components/toast";
import { useResumeStore } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
type BuilderVersionHistoryProps = {
resumeId: string;
};
export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps) {
const [open, setOpen] = useState(false);
const confirm = useConfirm();
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
const relativeTimeFormatter = useMemo(() => new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" }), []);
const { data: versions, isLoading } = useQuery({
...orpc.resume.listVersions.queryOptions({ input: { resumeId } }),
enabled: open,
});
const { mutateAsync: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
const handleRestore = async (versionId: string) => {
const confirmed = await confirm(t`Restore this version?`, {
description: t`Earlier versions are kept; the builder's undo history is reset.`,
});
if (!confirmed) return;
try {
const restored = await restoreVersion({ resumeId, versionId });
replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored);
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
toast.add({ type: "success", description: t`Your resume has been restored to the selected version.` });
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<IconButton icon="history" label={t`Version history`} className="text-ink-2" />} />
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans>Version history</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isLoading && (
<div className="px-2 py-3 text-muted-foreground text-xs">
<Trans>Loading…</Trans>
</div>
)}
{!isLoading && (!versions || versions.length === 0) && (
<div className="px-2 py-3 text-muted-foreground text-xs">
<Trans>No saved versions yet.</Trans>
</div>
)}
{versions?.map((version) => (
<DropdownMenuItem
key={version.id}
disabled={isPending}
className="flex-col items-start gap-0.5"
onClick={() => handleRestore(version.id)}
>
<span className="font-medium">{version.label}</span>
<span className="text-muted-foreground text-xs">
{formatRelativeTime(version.createdAt, relativeTimeFormatter)}
</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,158 +0,0 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, 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 { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const downloadWithAnchor = vi.hoisted(() => vi.fn());
const buildDocx = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/x-docx" })));
const createResumePdfBlob = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/pdf" })));
const resumeMock = vi.hoisted(() => ({
resume: undefined as
| undefined
| {
id: string;
name: string;
slug: string;
data: ResumeData;
},
}));
type SectionBaseProps = {
children: React.ReactNode;
};
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
}));
vi.mock("@reactive-resume/utils/file", () => ({
downloadWithAnchor,
generateFilename: (name: string, ext: string) => `${name}.${ext}`,
}));
vi.mock("@reactive-resume/docx", () => ({ buildDocx }));
vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob }));
// DOCX/Markdown resolve locale-aware section titles; stub the async locale resolver so exports
// fall back to the built-in English titles without loading real locale catalogs.
vi.mock("@/libs/resume/section-title-locale", () => ({
createSectionTitleResolverForLocale: vi.fn().mockResolvedValue(() => undefined),
}));
vi.mock("@/features/resume/builder/draft", () => ({
useResume: () => resumeMock.resume,
}));
const { ExportSectionBuilder } = await import("./export");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
const data = structuredClone(defaultResumeData);
data.metadata.stylesheet = {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname {" },
};
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data };
});
afterEach(() => {
downloadWithAnchor.mockReset();
buildDocx.mockClear();
createResumePdfBlob.mockClear();
});
const renderExport = () =>
render(
<I18nProvider i18n={i18n}>
<ExportSectionBuilder />
</I18nProvider>,
);
const openDialog = () => {
const trigger = screen.getByText(
"Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available.",
);
fireEvent.click(trigger.closest("button") as HTMLButtonElement);
};
describe("ExportSectionBuilder", () => {
it("renders the PDF, DOCX, Markdown, and JSON format rows", () => {
renderExport();
openDialog();
expect(screen.getByRole("button", { name: "Download PDF" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download DOCX" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download Markdown" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download JSON" })).toBeInTheDocument();
});
it("downloads a Markdown blob when the Markdown button is clicked", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download Markdown" }));
await waitFor(() => expect(downloadWithAnchor).toHaveBeenCalledTimes(1));
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it.
const [blob, filename] = downloadWithAnchor.mock.calls[0]!;
expect((blob as Blob).type).toBe("text/markdown");
expect(filename).toBe("My Resume.md");
});
it("downloads the current stylesheet source in JSON", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it.
const [blob, filename] = downloadWithAnchor.mock.calls[0]!;
expect(blob).toBeInstanceOf(Blob);
expect((blob as Blob).type).toBe("application/json");
expect(filename).toBe("My Resume.json");
const exported = JSON.parse(await (blob as Blob).text());
expect(exported.metadata.stylesheet).toEqual(resumeMock.resume?.data.metadata.stylesheet);
});
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download DOCX" }));
await waitFor(() => expect(buildDocx).toHaveBeenCalledTimes(1));
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.docx");
});
it("calls createResumePdfBlob and downloads when PDF is clicked", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download PDF" }));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
expect(createResumePdfBlob).toHaveBeenCalledWith(resumeMock.resume?.data, undefined, undefined);
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
});
it("exports the cover letter when the scope is switched and a cover letter exists", async () => {
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: sampleResumeData };
renderExport();
openDialog();
fireEvent.click(screen.getByRole("tab", { name: "Cover letter" }));
fireEvent.click(screen.getByRole("button", { name: "Download PDF" }));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume Cover Letter.pdf");
});
});
@@ -1,39 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { useResume } from "@/features/resume/builder/draft";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
import { SectionBase } from "../shared/section-base";
export function ExportSectionBuilder() {
const resume = useResume();
if (!resume) return null;
return (
<SectionBase type="export" className="space-y-4">
<ResumeDownloadDialog
resume={resume}
trigger={(disabled) => (
<Button
variant="secondary"
disabled={disabled}
className="h-auto w-full gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
<DownloadSimpleIcon className="size-6 shrink-0" />
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">
<Trans>Download</Trans>
</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available.
</Trans>
</p>
</div>
</Button>
)}
/>
</SectionBase>
);
}
@@ -1,97 +0,0 @@
// @vitest-environment happy-dom
import type { ReactNode } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const mocks = vi.hoisted(() => ({
resume: {
id: "resume-id",
slug: "resume",
isPublic: true,
isLocked: false,
hasPassword: false,
showDownloadButtons: true,
},
update: vi.fn(),
patch: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@tanstack/react-query", () => ({ useMutation: () => ({ mutateAsync: mocks.update, isPending: false }) }));
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => mocks.resume,
usePatchResume: () => mocks.patch,
}));
vi.mock("@/libs/auth/client", () => ({
authClient: { useSession: () => ({ data: { user: { username: "owner" } } }) },
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
update: { mutationOptions: () => ({}) },
setPassword: { mutationOptions: () => ({}) },
removePassword: { mutationOptions: () => ({}) },
},
},
}));
vi.mock("@/hooks/use-confirm", () => ({ useConfirm: () => vi.fn() }));
vi.mock("@/hooks/use-prompt", () => ({ usePrompt: () => vi.fn() }));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: mocks.toast } }));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: { children: ReactNode }) => <section>{children}</section>,
}));
const { SharingSectionBuilder } = await import("./sharing");
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
beforeEach(() => {
vi.clearAllMocks();
mocks.resume = {
id: "resume-id",
slug: "resume",
isPublic: true,
isLocked: false,
hasPassword: false,
showDownloadButtons: true,
};
mocks.update.mockResolvedValue({ ...mocks.resume, showDownloadButtons: false });
mocks.patch.mockImplementation((update: (draft: typeof mocks.resume) => void) => update(mocks.resume));
});
const renderSharing = () =>
render(
<I18nProvider i18n={i18n}>
<SharingSectionBuilder />
</I18nProvider>,
);
describe("sharing download preference", () => {
it("saves the per-resume preference when its switch is turned off", async () => {
renderSharing();
const toggle = screen.getByRole("switch", { name: "Show Download Buttons" });
expect(toggle).toBeChecked();
fireEvent.click(toggle);
await waitFor(() => expect(mocks.update).toHaveBeenCalledWith({ id: "resume-id", showDownloadButtons: false }));
await waitFor(() => expect(mocks.resume.showDownloadButtons).toBe(false));
});
it("keeps the saved preference when persistence fails", async () => {
mocks.update.mockRejectedValueOnce(new Error("Unavailable"));
renderSharing();
fireEvent.click(screen.getByRole("switch", { name: "Show Download Buttons" }));
await waitFor(() => expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })));
expect(mocks.patch).not.toHaveBeenCalled();
expect(mocks.resume.showDownloadButtons).toBe(true);
});
it("disables changes while the resume is locked", () => {
mocks.resume.isLocked = true;
renderSharing();
const toggle = screen.getByRole("switch", { name: "Show Download Buttons" });
expect(toggle).toHaveAttribute("aria-disabled", "true");
fireEvent.click(toggle);
expect(mocks.update).not.toHaveBeenCalled();
});
});
@@ -1,182 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Switch } from "@reactive-resume/ui/components/switch";
import { toast } from "@reactive-resume/ui/components/toast";
import { useCurrentResume, usePatchResume } from "@/features/resume/builder/draft";
import { ResumePasswordDialog } from "@/features/resume/builder/password-dialog";
import { useConfirm } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
export function SharingSectionBuilder() {
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const confirm = useConfirm();
const [_, copyToClipboard] = useCopyToClipboard();
const { data: session } = authClient.useSession();
const resume = useCurrentResume();
const patchResume = usePatchResume();
const { mutateAsync: updateResume, isPending: isUpdating } = useMutation(orpc.resume.update.mutationOptions());
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
const publicUrl = session ? `${window.location.origin}/${session.user.username}/${resume.slug}` : "";
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
toast.add({ type: "success", description: t`Resume link copied to clipboard.` });
}, [publicUrl, copyToClipboard]);
const onTogglePublic = useCallback(
async (checked: boolean) => {
try {
const updated = await updateResume({ id: resume.id, isPublic: checked });
patchResume((draft) => {
draft.isPublic = updated.isPublic;
});
} catch (error) {
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
toast.add({ type: "error", description: message });
}
},
[patchResume, resume.id, updateResume],
);
const onToggleDownloadButtons = useCallback(
async (checked: boolean) => {
try {
const updated = await updateResume({ id: resume.id, showDownloadButtons: checked });
patchResume((draft) => {
draft.showDownloadButtons = updated.showDownloadButtons;
});
} catch (error) {
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
toast.add({ type: "error", description: message });
}
},
[patchResume, resume.id, updateResume],
);
const onSetPassword = useCallback(
async (password: string) => {
await setPassword({ id: resume.id, password });
patchResume((draft) => {
draft.hasPassword = true;
});
toast.add({ type: "success", description: t`Password protection has been enabled.` });
},
[patchResume, resume.id, setPassword],
);
const onRemovePassword = useCallback(async () => {
if (!resume.hasPassword) return;
const confirmation = await confirm(t`Are you sure you want to remove password protection?`, {
description: t`Anyone with the public URL will be able to view your resume without a password.`,
confirmText: t`Confirm`,
cancelText: t`Cancel`,
});
if (!confirmation) return;
const toastId = toast.add({ type: "loading", description: t`Removing password protection...` });
try {
await removePassword({ id: resume.id });
patchResume((draft) => {
draft.hasPassword = false;
});
toast.add({ type: "success", description: t`Password protection has been disabled.`, id: toastId });
} catch (error) {
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
toast.add({ type: "error", description: message, id: toastId });
}
}, [confirm, patchResume, removePassword, resume.hasPassword, resume.id]);
const isPasswordProtected = resume.hasPassword;
return (
<SectionBase type="sharing" className="space-y-4">
{isPasswordDialogOpen && (
<ResumePasswordDialog onSubmit={onSetPassword} onClose={() => setIsPasswordDialogOpen(false)} />
)}
<div className="flex items-center gap-x-4">
<Switch
id="sharing-switch"
checked={resume.isPublic}
onCheckedChange={(checked) => void onTogglePublic(checked)}
/>
<Label htmlFor="sharing-switch" className="my-2 flex flex-col items-start gap-y-1 font-normal">
<span className="font-medium">
<Trans>Allow Public Access</Trans>
</span>
<span className="text-muted-foreground text-xs">
<Trans>Anyone with the link can view the resume.</Trans>
</span>
</Label>
</div>
{resume.isPublic && (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center gap-x-4">
<Switch
id="sharing-downloads-switch"
checked={resume.showDownloadButtons !== false}
disabled={isUpdating || resume.isLocked}
onCheckedChange={(checked) => void onToggleDownloadButtons(checked)}
/>
<Label htmlFor="sharing-downloads-switch">
<Trans>Show Download Buttons</Trans>
</Label>
</div>
<div className="grid gap-2">
<Label htmlFor="sharing-url">
<Trans comment="Form field label for the generated public resume link in sharing settings">URL</Trans>
</Label>
<div className="flex items-center gap-x-2">
<Input readOnly id="sharing-url" value={publicUrl} />
<Button size="icon" variant="ghost" aria-label={t`Copy URL`} onClick={onCopyUrl}>
<ClipboardIcon />
</Button>
</div>
</div>
<p className="text-muted-foreground">
{isPasswordProtected ? (
<Trans>
Your resume's public URL is protected by a password. Share the password only with people you trust.
</Trans>
) : (
<Trans>Set a password if you want only people who know it to open the public URL.</Trans>
)}
</p>
{isPasswordProtected ? (
<Button variant="secondary" onClick={onRemovePassword}>
<LockSimpleOpenIcon />
<Trans>Remove Password</Trans>
</Button>
) : (
<Button variant="secondary" onClick={() => setIsPasswordDialogOpen(true)}>
<LockSimpleIcon />
<Trans>Set Password</Trans>
</Button>
)}
</div>
)}
</SectionBase>
);
}
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { computeDelta, getSparklinePoints } from "./statistics.utils";
describe("computeDelta", () => {
it("returns null when the prior period had no activity", () => {
// Prior 2 days are all zero -> no baseline to compare against.
expect(computeDelta([0, 0, 5, 5], 2)).toBeNull();
});
it("returns a positive percentage when the recent period grew", () => {
// previous sum = 2, recent sum = 4 -> +100%
expect(computeDelta([1, 1, 2, 2], 2)).toBe(100);
});
it("returns a negative percentage when the recent period shrank", () => {
// previous sum = 4, recent sum = 2 -> -50%
expect(computeDelta([2, 2, 1, 1], 2)).toBe(-50);
});
});
describe("getSparklinePoints", () => {
it("returns null for a single point", () => {
expect(getSparklinePoints([5], 80, 24)).toBeNull();
});
it("returns null for an all-zero series", () => {
expect(getSparklinePoints([0, 0, 0], 80, 24)).toBeNull();
});
it("returns points for all-equal non-zero values (flat line at the top)", () => {
// max === value, so every y is 0; x spans 0..width.
expect(getSparklinePoints([3, 3, 3], 80, 24)).toBe("0.0,0.0 40.0,0.0 80.0,0.0");
});
});
@@ -1,129 +0,0 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const queryResult = vi.hoisted(() => ({
data: undefined as
| undefined
| {
isPublic: boolean;
views: number;
downloads: number;
lastViewedAt: Date | null;
lastDownloadedAt: Date | null;
},
}));
const dailyResult = vi.hoisted(() => ({
data: undefined as undefined | { date: string; views: number; downloads: number }[],
}));
type SectionBaseProps = {
children: React.ReactNode;
};
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { __key?: string }) => (options.__key === "daily" ? dailyResult : queryResult),
}));
vi.mock("@tanstack/react-router", () => ({
useParams: () => ({ resumeId: "r1" }),
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
statistics: {
getById: { queryOptions: () => ({ __key: "getById" }) },
getDailyById: { queryOptions: () => ({ __key: "daily" }) },
},
},
},
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
}));
const { StatisticsSectionBuilder } = await import("./statistics");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
queryResult.data = undefined;
dailyResult.data = undefined;
});
const renderStats = () =>
render(
<I18nProvider i18n={i18n}>
<StatisticsSectionBuilder />
</I18nProvider>,
);
describe("StatisticsSectionBuilder", () => {
it("renders nothing while the query result is undefined", () => {
const { container } = renderStats();
expect(container.textContent).toBe("");
});
it("renders the private hint when isPublic=false", () => {
queryResult.data = {
isPublic: false,
views: 0,
downloads: 0,
lastViewedAt: null,
lastDownloadedAt: null,
};
renderStats();
expect(screen.getByText("Track your resume's views and downloads")).toBeInTheDocument();
});
it("renders the views/downloads counters when isPublic=true", () => {
queryResult.data = {
isPublic: true,
views: 42,
downloads: 7,
lastViewedAt: null,
lastDownloadedAt: null,
};
renderStats();
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("7")).toBeInTheDocument();
expect(screen.getByText("Views")).toBeInTheDocument();
expect(screen.getByText("Downloads")).toBeInTheDocument();
});
it("renders a prior-period delta from the daily series", () => {
queryResult.data = {
isPublic: true,
views: 30,
downloads: 0,
lastViewedAt: null,
lastDownloadedAt: null,
};
// 60 days: prior 30 sum to 10, recent 30 sum to 20 -> +100%.
dailyResult.data = Array.from({ length: 60 }, (_, i) => ({
date: `2024-01-${String(i + 1).padStart(2, "0")}`,
views: i < 30 ? (i < 10 ? 1 : 0) : i < 50 ? 1 : 0,
downloads: 0,
}));
renderStats();
expect(screen.getByText(/\+100%/)).toBeInTheDocument();
});
it("renders 'last viewed/downloaded' timestamps when present", () => {
queryResult.data = {
isPublic: true,
views: 1,
downloads: 0,
lastViewedAt: new Date("2024-01-15T00:00:00Z"),
lastDownloadedAt: null,
};
renderStats();
// Just verify some 'Last viewed' copy appears — the date formatting depends on the runner's locale.
expect(screen.getByText(/Last viewed/i)).toBeInTheDocument();
});
});
@@ -1,136 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { InfoIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { Accordion, AccordionContent, AccordionItem } from "@reactive-resume/ui/components/accordion";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
import { computeDelta, getSparklinePoints } from "./statistics.utils";
// Fetch 60 days so we can render a 30-day sparkline and compare it against the prior 30 days.
const TREND_DAYS = 60;
const WINDOW = 30;
export function StatisticsSectionBuilder() {
const params = useParams({ from: "/builder/$resumeId" });
const { data: statistics } = useQuery(
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
);
const { data: daily } = useQuery(
orpc.resume.statistics.getDailyById.queryOptions({
input: { id: params.resumeId, days: TREND_DAYS },
enabled: Boolean(statistics?.isPublic),
}),
);
if (!statistics) return null;
const viewsSeries = daily?.map((day) => day.views) ?? [];
const downloadsSeries = daily?.map((day) => day.downloads) ?? [];
return (
<SectionBase type="statistics">
<Accordion value={statistics.isPublic ? ["isPublic"] : ["isPrivate"]}>
<AccordionItem value="isPrivate">
<AccordionContent className="pb-0">
<Alert>
<InfoIcon />
<AlertTitle>
<Trans>Track your resume's views and downloads</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Turn on public sharing to track how many times your resume has been viewed or downloaded. Only you can
see these numbers.
</Trans>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
<AccordionItem value="isPublic">
<AccordionContent className="grid @md:grid-cols-2 grid-cols-1 gap-4 pb-0">
<StatisticsItem
label={t`Views`}
value={statistics.views}
series={viewsSeries}
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
/>
<StatisticsItem
label={t`Downloads`}
value={statistics.downloads}
series={downloadsSeries}
timestamp={
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</SectionBase>
);
}
type StatisticsItemProps = {
label: string;
value: number;
series: number[];
timestamp: string | null;
};
function StatisticsItem({ label, value, series, timestamp }: StatisticsItemProps) {
const recent = series.slice(-WINDOW);
const delta = computeDelta(series, WINDOW);
return (
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<h4 className="font-mono font-semibold text-4xl">{value}</h4>
<Sparkline title={t`${label} over the last 30 days`} values={recent} />
</div>
<p className="font-medium text-muted-foreground leading-none">{label}</p>
{delta === null ? (
<span className="text-muted-foreground text-xs">
<Trans>No prior data</Trans>
</span>
) : (
<span className={cn("text-xs", delta >= 0 ? "text-accent-text" : "text-danger-text")}>
{`${delta >= 0 ? "+" : ""}${delta}% `}
<Trans>vs previous 30 days</Trans>
</span>
)}
{timestamp && <span className="block text-muted-foreground text-xs">{timestamp}</span>}
</div>
);
}
type SparklineProps = {
title: string;
values: number[];
};
function Sparkline({ title, values }: SparklineProps) {
const width = 80;
const height = 24;
const points = getSparklinePoints(values, width, height);
if (!points) return null;
return (
<svg className="text-primary" height={height} role="img" viewBox={`0 0 ${width} ${height}`} width={width}>
<title>{title}</title>
<polyline
fill="none"
points={points}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
);
}
@@ -1,21 +0,0 @@
// Percent change of the most recent `window` days vs the `window` days before it.
// Returns null when the prior period had no activity (division by zero / no baseline).
export function computeDelta(series: number[], window: number): number | null {
const recent = series.slice(-window);
const previous = series.slice(-window * 2, -window);
const recentSum = recent.reduce((sum, n) => sum + n, 0);
const previousSum = previous.reduce((sum, n) => sum + n, 0);
if (previousSum === 0) return null;
return Math.round(((recentSum - previousSum) / previousSum) * 100);
}
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
// points, or an all-zero series) where there is nothing meaningful to draw.
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
if (values.length < 2 || values.every((n) => n === 0)) return null;
const max = Math.max(...values, 1);
const step = width / (values.length - 1);
return values
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
.join(" ");
}
@@ -24,6 +24,7 @@
"content_cut",
"contrast",
"dark_mode",
"data_object",
"delete",
"delete_forever",
"description",
@@ -67,6 +68,7 @@
"more_vert",
"motion_mode",
"note_add",
"notes",
"open_in_new",
"palette",
"passkey",
@@ -74,6 +76,7 @@
"print",
"qr_code_2",
"redo",
"refresh",
"remove",
"restart_alt",
"restore_from_trash",
Binary file not shown.
+3
View File
@@ -29,6 +29,7 @@ export const iconNames = [
"content_cut",
"contrast",
"dark_mode",
"data_object",
"delete",
"delete_forever",
"description",
@@ -72,6 +73,7 @@ export const iconNames = [
"more_vert",
"motion_mode",
"note_add",
"notes",
"open_in_new",
"palette",
"passkey",
@@ -79,6 +81,7 @@ export const iconNames = [
"print",
"qr_code_2",
"redo",
"refresh",
"remove",
"restart_alt",
"restore_from_trash",