diff --git a/apps/web/src/dialogs/resume/index.tsx b/apps/web/src/dialogs/resume/index.tsx index e5229d112..283d412c8 100644 --- a/apps/web/src/dialogs/resume/index.tsx +++ b/apps/web/src/dialogs/resume/index.tsx @@ -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; 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({ )} - - {(field) => ( - 0}> - - Slug - - - - {slugPrefix} - - field.handleChange(event.target.value)} - /> - - } - /> - - - This is a URL-friendly name for your resume. - - - )} - - {(field) => ( 0}> diff --git a/apps/web/src/features/resume/builder/draft.test.ts b/apps/web/src/features/resume/builder/draft.test.ts index 34ed2b4b8..b9c8900a3 100644 --- a/apps/web/src/features/resume/builder/draft.test.ts +++ b/apps/web/src/features/resume/builder/draft.test.ts @@ -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 () => { diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index ff1634901..caf135812 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -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 { +/** + * 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 { 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), }); diff --git a/apps/web/src/features/resume/editor/store.ts b/apps/web/src/features/resume/editor/store.ts index 30dee2dc4..27e4348a2 100644 --- a/apps/web/src/features/resume/editor/store.ts +++ b/apps/web/src/features/resume/editor/store.ts @@ -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()((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) => { diff --git a/apps/web/src/routes/builder/$resumeId/-components/use-open-issue-count.ts b/apps/web/src/features/resume/editor/use-open-issue-count.ts similarity index 100% rename from apps/web/src/routes/builder/$resumeId/-components/use-open-issue-count.ts rename to apps/web/src/features/resume/editor/use-open-issue-count.ts diff --git a/apps/web/src/features/resume/export/download-dialog.tsx b/apps/web/src/features/resume/export/download-dialog.tsx deleted file mode 100644 index 8e0f13637..000000000 --- a/apps/web/src/features/resume/export/download-dialog.tsx +++ /dev/null @@ -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[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 ( -
-
- {icon} -
-
-

{title}

-

{description}

-
- {action} -
- ); -} - -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("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) => { - setOpen(false); - void action(); - }; - - return ( - - {trigger && } - - - - Download - - - Pick a format for your resume or cover letter. - - - - setScope(value as ResumeExportTarget)}> - - - - Resume - - - - Cover letter - - - - - {activeScope === "cover-letter" && ( - - )} - -
- : - } - title="PDF" - description={Best for applications, sharing, and printing.} - action={ - - } - /> - - } - title="DOCX" - description={Editable in Word, Google Docs, and Pages.} - action={ - - } - /> - - } - title="Markdown" - description={Plain text, for AI tools and quick edits.} - action={ - - } - /> - - } - title="JSON" - description={Full resume data for backup or import.} - action={ - - } - /> -
-
-
- ); -} diff --git a/apps/web/src/features/resume/export/use-resume-export.ts b/apps/web/src/features/resume/export/use-resume-export.ts index 25fd1989a..c9154cb63 100644 --- a/apps/web/src/features/resume/export/use-resume-export.ts +++ b/apps/web/src/features/resume/export/use-resume-export.ts @@ -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 { + 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 diff --git a/apps/web/src/features/resume/share/download-tab.tsx b/apps/web/src/features/resume/share/download-tab.tsx new file mode 100644 index 000000000..4badd4f25 --- /dev/null +++ b/apps/web/src/features/resume/share/download-tab.tsx @@ -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("resume"); + const [format, setFormat] = useState("pdf"); + const [includeHeader, setIncludeHeader] = useState(false); + const [fileName, setFileName] = useState(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 ( +
+ {hasLetter && ( +
+ { + setTarget(value as ResumeExportTarget); + setFileName(null); + setState("idle"); + }} + className="w-full" + > + + Resume + + + Cover letter + + + {activeTarget === "cover-letter" && ( +
+ setIncludeHeader(checked === true)} + /> + +
+ )} +
+ )} + + { + setFormat(value as ExportFormat); + setState("idle"); + }} + className="grid gap-1.5" + > + {formats.map((option) => { + const unavailable = option.id === "json" && activeTarget === "cover-letter"; + return ( + + + + + + + {option.label} + {option.extension} + {option.id === "pdf" && ( + + Best for applying + + )} + + {option.description} + + + + + + ); + })} + + +
+ +
+ 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" + /> + {selected.extension} +
+ + Recruiters see this name. Your name plus “Resume” works well. + +
+ + {issues > 0 && ( +
+ + + {" "} + + +
+ )} + + {state === "error" && ( +
+ + + The {selected.label} file couldn't be generated. Try again, or download PDF instead. + + {activeFormat !== "pdf" && ( + + )} +
+ )} + + +
+ ); +} diff --git a/apps/web/src/features/resume/share/format.test.ts b/apps/web/src/features/resume/share/format.test.ts new file mode 100644 index 000000000..8d9087392 --- /dev/null +++ b/apps/web/src/features/resume/share/format.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/features/resume/share/format.ts b/apps/web/src/features/resume/share/format.ts new file mode 100644 index 000000000..f771cd6ea --- /dev/null +++ b/apps/web/src/features/resume/share/format.ts @@ -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) { + 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) { + 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`; + } +} diff --git a/apps/web/src/features/resume/share/history-tab.tsx b/apps/web/src/features/resume/share/history-tab.tsx new file mode 100644 index 000000000..bf469cc45 --- /dev/null +++ b/apps/web/src/features/resume/share/history-tab.tsx @@ -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 ( +
+
{ + event.preventDefault(); + void save(); + }} + > + setName(event.target.value)} + className="h-9" + /> + +
+ + {selected && ( +
+ + + + Viewing {when(selected)} · {getVersionTitle(selected)} · read-only + + + + + + +
+ )} + +
    + setVersion(null)} + /> + {versions?.map((version, index) => ( + setVersion(version.id)} + menu={version.kind === "named" ? : null} + /> + ))} +
+ + {!loading && (versions?.length ?? 0) <= 1 && ( +

+ Only one version so far. Every editing session adds one automatically. +

+ )} + +

+ + Autosaves are grouped by session and kept for 90 days. Named versions are kept until you delete them. + Restoring saves the current state first. + +

+
+ ); +} + +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 ( +
  • +
  • + ); +} + +/** 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 ( + + + } + /> + + void rename()}> + + Rename… + + void remove()}> + + Delete + + + + ); +} diff --git a/apps/web/src/features/resume/builder/sharing.test.tsx b/apps/web/src/features/resume/share/link-tab.test.tsx similarity index 65% rename from apps/web/src/features/resume/builder/sharing.test.tsx rename to apps/web/src/features/resume/share/link-tab.test.tsx index d30c49278..8213964a1 100644 --- a/apps/web/src/features/resume/builder/sharing.test.tsx +++ b/apps/web/src/features/resume/share/link-tab.test.tsx @@ -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) =>
    {children}
    , -})); vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn(), close: vi.fn() } })); +vi.mock("usehooks-ts", async (importOriginal) => ({ + ...(await importOriginal()), + // The address check waits 300 ms after typing; tests skip the wait. + useDebounceValue: (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( - + - + , ); +} + +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(); + }); +}); diff --git a/apps/web/src/features/resume/share/link-tab.tsx b/apps/web/src/features/resume/share/link-tab.tsx new file mode 100644 index 000000000..69177302a --- /dev/null +++ b/apps/web/src/features/resume/share/link-tab.tsx @@ -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 ( +
    + void setPublic(checked)} + label={{t`Public link`}} + 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" + /> + + + + {isPublic && ( +
    + void setVisitorDownloads(checked)} + label={t`Visitors can download the PDF`} + className="py-0" + /> + + +
    + )} + + + +
    + ); +} + +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(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(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 ( +
    + +
    +
    + {/* A long host or username truncates, so the part being edited stays visible. */} + + {window.location.host}/{username}/ + + 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 && ( + + )} +
    + +
    +

    + {result?.status === "taken" ? ( + <> + {result.takenBy ? ( + You already use this for “{result.takenBy}”. + ) : ( + Another of your resumes uses this address. + )}{" "} + {result.suggestion && ( + + )} + + ) : ( + message + )} +

    + {isPublic && canShare && ( + + )} +
    + ); +} + +/** 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 ( + <> + (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 && ( + 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 ( + + + + QR code + + } + /> + + + + + ); +} + +/** 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 ( +
    +
    +

    + Views and downloads +

    + + Only you see these + +
    + + {isPublic ? ( + <> +
    + + + +
    +
    + {summary.bars.map((bar) => ( + + ))} +
    +
    + + {firstDay + ? new Date(`${firstDay}T00:00:00`).toLocaleDateString(i18n.locale, { month: "short", day: "numeric" }) + : ""} + + + Today + +
    + + ) : ( +

    + + Turn on the public link to count views and downloads. Counts are anonymous and kept for 90 days. + +

    + )} +
    + ); +} + +function Stat({ value, label }: { value: number | string; label: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} diff --git a/apps/web/src/routes/$username/$slug.tsx b/apps/web/src/routes/$username/$slug.tsx index 23dc18346..7005f17b3 100644 --- a/apps/web/src/routes/$username/$slug.tsx +++ b/apps/web/src/routes/$username/$slug.tsx @@ -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 }) => { diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx index 0da097210..79850da49 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx @@ -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" && ( <> - + @@ -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 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 ? ( @@ -198,7 +203,7 @@ function ShareButton({ compact, disabled }: ToolbarActionProps) { } return ( - - ( - - )} - /> + ); } diff --git a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx index 28b95013b..56427d011 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx @@ -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 ? (
    - {previewTemplate ? ( + {viewing ? ( + + + + Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} · + read-only + + + ) : previewTemplate ? ( ) } - renderPageOverlay={({ pageIndex, pageMap }) => ( - - )} + renderPageOverlay={({ pageIndex, pageMap }) => + // A version from History is read-only: its lines don't open entries. + viewing ? null : + } /> diff --git a/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx b/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx index 88bfc58ba..8146e2c5c 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx @@ -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 ( - - - + !open && setTab(null)}> + + Share & export -
    - - - -
    + { + // 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" + > + + + Link + + + Download + + + History + + + + + + + { + setTab(null); + setMode("check"); + }} + /> + + + + +
    ); diff --git a/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts b/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts index 4013f1c6a..af7fee96c 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts +++ b/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts @@ -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 }); diff --git a/apps/web/src/routes/builder/$resumeId/-components/version-history.tsx b/apps/web/src/routes/builder/$resumeId/-components/version-history.tsx deleted file mode 100644 index c88c14b41..000000000 --- a/apps/web/src/routes/builder/$resumeId/-components/version-history.tsx +++ /dev/null @@ -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 ( - - } /> - - - - - Version history - - - - {isLoading && ( -
    - Loading… -
    - )} - - {!isLoading && (!versions || versions.length === 0) && ( -
    - No saved versions yet. -
    - )} - - {versions?.map((version) => ( - handleRestore(version.id)} - > - {version.label} - - {formatRelativeTime(version.createdAt, relativeTimeFormatter)} - - - ))} -
    -
    -
    - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.test.tsx deleted file mode 100644 index 7d192a770..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.test.tsx +++ /dev/null @@ -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) =>
    {children}
    , -})); -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( - - - , - ); - -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"); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx deleted file mode 100644 index 53afb5525..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx +++ /dev/null @@ -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 ( - - ( - - )} - /> - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.test.tsx deleted file mode 100644 index 6e2077525..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.test.tsx +++ /dev/null @@ -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 }) =>
    {children}
    , -})); - -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( - - - , - ); - -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(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx deleted file mode 100644 index bec9db752..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx +++ /dev/null @@ -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 ( - - {isPasswordDialogOpen && ( - setIsPasswordDialogOpen(false)} /> - )} -
    - void onTogglePublic(checked)} - /> - - -
    - - {resume.isPublic && ( -
    -
    - void onToggleDownloadButtons(checked)} - /> - -
    - -
    - - -
    - - - -
    -
    - -

    - {isPasswordProtected ? ( - - Your resume's public URL is protected by a password. Share the password only with people you trust. - - ) : ( - Set a password if you want only people who know it to open the public URL. - )} -

    - - {isPasswordProtected ? ( - - ) : ( - - )} -
    - )} -
    - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts deleted file mode 100644 index c899d8afa..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx deleted file mode 100644 index bcf660b38..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx +++ /dev/null @@ -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) =>
    {children}
    , -})); - -const { StatisticsSectionBuilder } = await import("./statistics"); - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -beforeEach(() => { - queryResult.data = undefined; - dailyResult.data = undefined; -}); - -const renderStats = () => - render( - - - , - ); - -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(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx deleted file mode 100644 index d0703928a..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx +++ /dev/null @@ -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 ( - - - - - - - - Track your resume's views and downloads - - - - Turn on public sharing to track how many times your resume has been viewed or downloaded. Only you can - see these numbers. - - - - - - - - - - - - - - - - ); -} - -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 ( -
    -
    -

    {value}

    - -
    -

    {label}

    - {delta === null ? ( - - No prior data - - ) : ( - = 0 ? "text-accent-text" : "text-danger-text")}> - {`${delta >= 0 ? "+" : ""}${delta}% `} - vs previous 30 days - - )} - {timestamp && {timestamp}} -
    - ); -} - -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 ( - - {title} - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts deleted file mode 100644 index e0fe37a40..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts +++ /dev/null @@ -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(" "); -} diff --git a/packages/ui/src/icons/material-symbols-rounded.json b/packages/ui/src/icons/material-symbols-rounded.json index a889face9..4ee249cc4 100644 --- a/packages/ui/src/icons/material-symbols-rounded.json +++ b/packages/ui/src/icons/material-symbols-rounded.json @@ -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", diff --git a/packages/ui/src/icons/material-symbols-rounded.woff2 b/packages/ui/src/icons/material-symbols-rounded.woff2 index e8520faba..07edaf7ef 100644 Binary files a/packages/ui/src/icons/material-symbols-rounded.woff2 and b/packages/ui/src/icons/material-symbols-rounded.woff2 differ diff --git a/packages/ui/src/icons/names.ts b/packages/ui/src/icons/names.ts index 686bedc4e..3fa7b88b5 100644 --- a/packages/ui/src/icons/names.ts +++ b/packages/ui/src/icons/names.ts @@ -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",