mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+74
-13
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user