diff --git a/apps/web/src/features/resume/public/public-resume.test.tsx b/apps/web/src/features/resume/public/public-resume.test.tsx index b6c48e53b..e984710ad 100644 --- a/apps/web/src/features/resume/public/public-resume.test.tsx +++ b/apps/web/src/features/resume/public/public-resume.test.tsx @@ -37,6 +37,9 @@ vi.mock("@tanstack/react-router", () => ({ }), })); vi.mock("./pdf-viewer", () => ({ PdfViewer: publicResumeMock.PdfViewer })); +vi.mock("./resume-reflow", () => ({ ResumeReflow: () =>
})); +const breakpoint = vi.hoisted(() => ({ value: "desktop" })); +vi.mock("@reactive-resume/ui/hooks/use-breakpoint", () => ({ useBreakpoint: () => breakpoint.value })); vi.mock("@/libs/orpc/client", () => ({ orpc: { resume: { getBySlug: { queryOptions: () => ({ query: "resume" }) } } }, })); @@ -49,6 +52,7 @@ const { PublicResumeRoute } = await import("./public-resume"); beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); beforeEach(() => { + breakpoint.value = "desktop"; publicResumeMock.flags.disableSignups = false; publicResumeMock.resume = { data: sampleResumeData, name: "Sample Resume", slug: "sample" }; publicResumeMock.PdfViewer.mockClear(); @@ -71,39 +75,44 @@ const renderPublicResumeRoute = () => ); describe("PublicResumeRoute", () => { - it("shows the create-resume link when registration is enabled", () => { + it("links the footer credit home when registration is enabled", () => { renderPublicResumeRoute(); - expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /Made with Reactive Resume/ })).toHaveAttribute("href", "/"); }); - it("hides the create-resume link when registration is disabled", () => { + it("keeps the credit as plain text when registration is disabled", () => { publicResumeMock.flags.disableSignups = true; renderPublicResumeRoute(); - expect(screen.queryByRole("link", { name: /Build your own resume/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /Made with Reactive Resume/ })).not.toBeInTheDocument(); + expect(screen.getByText(/Made with Reactive Resume/)).toBeInTheDocument(); expect(screen.getByTestId("pdf-viewer")).toBeInTheDocument(); }); - it("shows both working download controls by default", () => { + it("leads with the owner's name and downloads from the bar", () => { renderPublicResumeRoute(); - const buttons = screen.getAllByRole("button", { name: "Download PDF" }); - expect(buttons).toHaveLength(2); - for (const button of buttons) fireEvent.click(button); - expect(publicResumeMock.onDownloadPDF).toHaveBeenCalledTimes(2); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name); + expect(screen.getByRole("button", { name: /Copy link/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Download PDF" })); + expect(publicResumeMock.onDownloadPDF).toHaveBeenCalledTimes(1); }); - it("hides both download controls while keeping the public PDF visible", () => { + it("hides Download when downloads are off, keeps the page, and blocks printing with a note", () => { publicResumeMock.resume = { data: sampleResumeData, name: "Sample", slug: "sample", showDownloadButtons: false }; renderPublicResumeRoute(); expect(screen.queryByRole("button", { name: "Download PDF" })).not.toBeInTheDocument(); expect(screen.getByTestId("pdf-viewer")).toBeVisible(); + expect(screen.getByText("Printing is turned off for this resume.")).toHaveClass("print:block"); }); - it("shows both controls when the owner enables downloads again", () => { - publicResumeMock.resume = { data: sampleResumeData, name: "Sample", slug: "sample", showDownloadButtons: true }; + it("reflows on phones, with Download and Share pinned", () => { + breakpoint.value = "mobile"; renderPublicResumeRoute(); - expect(screen.getAllByRole("button", { name: "Download PDF" })).toHaveLength(2); + expect(screen.getByTestId("reflow")).toBeInTheDocument(); + expect(screen.queryByTestId("pdf-viewer")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Download PDF" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument(); }); it("passes exposed source data directly to the browser viewer and export fallback", () => { @@ -125,9 +134,7 @@ describe("PublicResumeRoute", () => { renderPublicResumeRoute(); const viewerFrame = screen.getByTestId("pdf-viewer").parentElement; - const page = viewerFrame?.parentElement; - expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden"); - expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden"); + expect(viewerFrame).not.toHaveClass("min-h-0", "overflow-hidden", "h-svh", "max-h-svh"); }); }); @@ -145,7 +152,7 @@ describe("PublicResumePage at root", () => { /> , ); - expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/dashboard"); + expect(screen.getByRole("link", { name: /Made with Reactive Resume/ })).toHaveAttribute("href", "/dashboard"); expect(screen.getByRole("main")).toHaveAttribute("id", "main-content"); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name); expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, { diff --git a/apps/web/src/features/resume/public/public-resume.tsx b/apps/web/src/features/resume/public/public-resume.tsx index 1461b6c4a..7fb410124 100644 --- a/apps/web/src/features/resume/public/public-resume.tsx +++ b/apps/web/src/features/resume/public/public-resume.tsx @@ -1,16 +1,19 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { BrandIcon } from "@reactive-resume/ui/components/brand-icon"; +import { useMemo, useState } from "react"; +import { useCopyToClipboard } from "usehooks-ts"; import { Button } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; import { LoadingScreen } from "@/components/layout/loading-screen"; import { useResumeExport } from "@/features/resume/export/use-resume-export"; import { orpc } from "@/libs/orpc/client"; import { PdfViewer } from "./pdf-viewer"; +import { ResumeReflow } from "./resume-reflow"; const publicResumeRoute = getRouteApi("/$username/$slug"); @@ -30,74 +33,137 @@ type PublicResumePageProps = { isRoot?: boolean; }; +/** + * The shared resume, for recruiters: no app chrome, the owner's name leads, and Download PDF is the one action. On + * phones it reflows into readable text with Download pinned; with downloads off, Download is gone and printing shows + * a note instead of the page. + */ export function PublicResumePage({ resume, username, slug, flags, isRoot = false }: PublicResumePageProps) { const publicResume = useMemo(() => ({ username, slug }), [slug, username]); const { onDownloadPDF, isExporting } = useResumeExport(resume, { ...(resume ? { publicResumePdf: { publicResume } } : {}), }); + const phone = useBreakpoint() === "mobile"; + const [copied, setCopied] = useState(false); + const [, copy] = useCopyToClipboard(); if (!resume) return ; - const { basics, picture } = resume.data; - const showDownloadButtons = resume.showDownloadButtons !== false; + const { basics } = resume.data; + const downloads = resume.showDownloadButtons !== false; + const subtitle = [basics.headline, basics.location].filter(Boolean).join(" · "); + + const copyLink = async () => { + await copy(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const share = async () => { + if (typeof navigator.share === "function") { + try { + await navigator.share({ title: basics.name || resume.name, url: window.location.href }); + } catch { + // Closing the share sheet isn't an error. + } + return; + } + await copy(window.location.href); + toast.add({ description: t`Link copied` }); + }; + + const download = ( + + ); + + const credit = ( + + ); return ( - <> -
-
- {picture.url && !picture.hidden && ( - {basics.name} - )} -
- {basics.name &&

{basics.name}

} - {basics.headline &&

{basics.headline}

} -
- {showDownloadButtons && ( - - )} -
+
+ {/* Printing is part of downloading: when the owner turns downloads off, print shows this note. */} + {!downloads && ( +

+ Printing is turned off for this resume. +

+ )} -
- -
- - {!flags.disableSignups && ( - +
+ {phone ? ( + <> +
+

{basics.name || resume.name}

+ + {credit} +
+
+ {downloads && download} + +
+ + ) : ( + <> +
+
+

{basics.name || resume.name}

+ {subtitle &&

{subtitle}

} +
+ + {downloads && download} +
+
+
+ +
+
+ {credit} + )}
+
+ ); +} - {showDownloadButtons && ( - - )} - +/** Off, unknown or trashed links read the same, with nothing about the owner. */ +export function SharedResumeUnavailable() { + return ( +
+
+

+ This resume isn't shared right now. +

+

+ If someone sent you this link, ask them for a new one. +

+
+
); } diff --git a/apps/web/src/features/resume/public/reflow.test.ts b/apps/web/src/features/resume/public/reflow.test.ts new file mode 100644 index 000000000..1c6fe34df --- /dev/null +++ b/apps/web/src/features/resume/public/reflow.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { reflowOrder } from "./reflow"; + +const clone = () => structuredClone(sampleResumeData); + +describe("reflowOrder", () => { + it("follows the print order: each page's main column, then its sidebar", () => { + const data = clone(); + const order = reflowOrder(data).map((section) => section.sectionId); + const pages = data.metadata.layout.pages; + const printed = pages.flatMap((page) => [...page.main, ...(page.fullWidth ? [] : page.sidebar)]); + + // Every reflowed section is printed, in the same relative order. + expect(order.length).toBeGreaterThan(0); + expect(order).toEqual(printed.filter((id) => order.includes(id))); + }); + + it("leaves out hidden sections and hidden entries", () => { + const data = clone(); + data.sections.skills.hidden = true; + const [first, second] = data.sections.projects.items; + if (first) first.hidden = true; + + const order = reflowOrder(data); + expect(order.map((section) => section.sectionId)).not.toContain("skills"); + const projects = order.find((section) => section.sectionId === "projects")?.itemIds ?? []; + expect(projects).not.toContain(first?.id); + expect(projects).toContain(second?.id); + }); +}); diff --git a/apps/web/src/features/resume/public/reflow.ts b/apps/web/src/features/resume/public/reflow.ts new file mode 100644 index 000000000..847ac6854 --- /dev/null +++ b/apps/web/src/features/resume/public/reflow.ts @@ -0,0 +1,40 @@ +import type { SemanticNode } from "@reactive-resume/resume/stylesheet/types"; +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { buildSemanticTree, shouldShowResumeHeader } from "@reactive-resume/pdf/semantic-tree"; + +/** One section as the phone reflow shows it: its id and the ids of the entries the page prints. */ +export type ReflowSection = { sectionId: string; itemIds: string[] }; + +function collect(node: SemanticNode, into: ReflowSection[]) { + if (node.kind === "section" && node.id) { + const itemIds: string[] = []; + const findItems = (child: SemanticNode) => { + if (child.kind === "item" && child.id) itemIds.push(child.id); + else for (const grandchild of child.children) findItems(grandchild); + }; + for (const child of node.children) findItems(child); + into.push({ sectionId: node.id, itemIds }); + return; + } + for (const child of node.children) collect(child, into); +} + +/** + * The sections and entries in the order the PDF prints them, page by page, with hidden and empty ones left out. The + * semantic tree already applies the template's region order and the shared filtering, so the reflow reads it rather + * than repeating those rules. + */ +export function reflowOrder(data: ResumeData): ReflowSection[] { + const sections: ReflowSection[] = []; + data.metadata.layout.pages.forEach((page, index) => { + const tree = buildSemanticTree({ + data, + template: data.metadata.template, + page, + pageNumber: index + 1, + showHeader: shouldShowResumeHeader(data, index), + }); + collect(tree, sections); + }); + return sections; +} diff --git a/apps/web/src/features/resume/public/resume-reflow.tsx b/apps/web/src/features/resume/public/resume-reflow.tsx new file mode 100644 index 000000000..38af62d16 --- /dev/null +++ b/apps/web/src/features/resume/public/resume-reflow.tsx @@ -0,0 +1,162 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { IconName } from "@reactive-resume/ui/components/icon"; +import { useMemo } from "react"; +import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { cn } from "@reactive-resume/utils/style"; +import { reflowOrder } from "./reflow"; +import { RichText } from "./rich-text"; + +type Entry = Record & { id: string }; + +// Lists keep their markers and paragraphs their spacing, which the app's reset removes. +const RICH = "grid gap-1 [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:list-disc [&_ul]:ps-5 [&_a]:underline"; + +// What each kind of entry leads with, what follows it, and the date or place set beside it. +const SHAPES: Record = { + experience: { title: ["position", "company"], subtitle: ["company"], meta: ["period", "location"] }, + education: { title: ["school"], subtitle: ["degree", "area", "grade"], meta: ["period", "location"] }, + projects: { title: ["name"], subtitle: [], meta: ["period"] }, + skills: { title: ["name"], subtitle: ["proficiency"], meta: [] }, + languages: { title: ["language"], subtitle: ["fluency"], meta: [] }, + interests: { title: ["name"], subtitle: [], meta: [] }, + awards: { title: ["title"], subtitle: ["awarder"], meta: ["date"] }, + certifications: { title: ["title"], subtitle: ["issuer"], meta: ["date"] }, + publications: { title: ["title"], subtitle: ["publisher"], meta: ["date"] }, + volunteer: { title: ["organization"], subtitle: [], meta: ["period", "location"] }, + references: { title: ["name"], subtitle: ["position"], meta: [] }, + profiles: { title: ["network"], subtitle: ["username"], meta: [] }, + summary: { title: [], subtitle: [], meta: [] }, + "cover-letter": { title: [], subtitle: [], meta: [] }, +}; + +const text = (entry: Entry, field: string) => (typeof entry[field] === "string" ? (entry[field] as string).trim() : ""); + +function sectionOf(data: ResumeData, sectionId: string): { type: string; items: Entry[] } | null { + if (sectionId === "summary") return { type: "summary", items: [{ id: "summary", content: data.summary.content }] }; + if (sectionId in data.sections) { + const section = data.sections[sectionId as keyof ResumeData["sections"]]; + return { type: sectionId, items: section.items as unknown as Entry[] }; + } + const custom = data.customSections.find((section) => section.id === sectionId); + return custom ? { type: custom.type, items: custom.items as unknown as Entry[] } : null; +} + +function EntryView({ type, entry }: { type: string; entry: Entry }) { + const shape = SHAPES[type] ?? SHAPES.projects; + const title = shape?.title.map((field) => text(entry, field)).find(Boolean) ?? ""; + const subtitle = [ + ...(type === "experience" && text(entry, "position") ? ["company"] : []), + ...(shape?.subtitle.filter((field) => field !== "company") ?? []), + ] + .map((field) => text(entry, field)) + .filter((value) => value && value !== title); + const meta = shape?.meta.map((field) => text(entry, field)).filter(Boolean) ?? []; + const website = entry.website as { url?: string; label?: string } | undefined; + const url = typeof entry.url === "string" ? entry.url : website?.url; + const keywords = Array.isArray(entry.keywords) ? (entry.keywords as string[]).filter(Boolean) : []; + const roles = Array.isArray(entry.roles) ? (entry.roles as Entry[]) : []; + const html = text(entry, "content") || text(entry, "description"); + + return ( +
+ {title &&

{title}

} + {(subtitle.length > 0 || meta.length > 0) && ( +

{[...subtitle, ...meta].join(" · ")}

+ )} + {url && /^https?:/i.test(url) && ( + + {website?.label || url.replace(/^https?:\/\//, "")} + + )} + {keywords.length > 0 &&

{keywords.join(", ")}

} + {html && } + {roles.map((role) => ( +
+

{text(role, "position")}

+ {text(role, "period") &&

{text(role, "period")}

} + {text(role, "description") && } +
+ ))} +
+ ); +} + +type ContactPill = { icon: IconName; label: string; href: string }; + +function contactPills(basics: ResumeData["basics"]): ContactPill[] { + const pills: ContactPill[] = []; + if (basics.email) pills.push({ icon: "mail", label: basics.email, href: `mailto:${basics.email}` }); + if (basics.phone) pills.push({ icon: "call", label: basics.phone, href: `tel:${basics.phone.replace(/\s+/g, "")}` }); + if (basics.website.url) + pills.push({ + icon: "language", + label: basics.website.label || basics.website.url.replace(/^https?:\/\//, ""), + href: basics.website.url, + }); + for (const field of basics.customFields) + if (field.text && /^(https?:|mailto:|tel:)/i.test(field.link)) + pills.push({ icon: "link", label: field.text, href: field.link }); + return pills; +} + +type ResumeReflowProps = { data: ResumeData }; + +/** + * Phones: the resume as readable text at 15px, in the order the PDF prints it and in the template's colour and body + * font. Contact details are tap targets. The PDF stays the exact page. + */ +export function ResumeReflow({ data }: ResumeReflowProps) { + const order = useMemo(() => reflowOrder(data), [data]); + const { basics } = data; + const accent = data.metadata.design.colors.primary; + const font = data.metadata.typography.body.fontFamily; + + return ( +
+
+

{basics.name}

+ {(basics.headline || basics.location) && ( +

{[basics.headline, basics.location].filter(Boolean).join(" · ")}

+ )} + +
+ + {order.map(({ sectionId, itemIds }) => { + const section = sectionOf(data, sectionId); + if (!section) return null; + const entries = itemIds.length + ? itemIds.map((id) => section.items.find((item) => item.id === id)).filter((item): item is Entry => !!item) + : section.items.filter((item) => !item.hidden); + if (entries.length === 0) return null; + return ( +
+

+ {getResumeSectionTitle(data, sectionId)} +

+ {entries.map((entry) => ( + + ))} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/features/resume/public/rich-text.test.tsx b/apps/web/src/features/resume/public/rich-text.test.tsx new file mode 100644 index 000000000..46ba87607 --- /dev/null +++ b/apps/web/src/features/resume/public/rich-text.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom + +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RichText } from "./rich-text"; + +describe("RichText", () => { + it("keeps formatting and plain links", () => { + const { container } = render( +
  • Led design at Lumen

  • '} + />, + ); + expect(container.querySelector("li strong")?.textContent).toBe("design"); + expect(container.querySelector("a")?.getAttribute("href")).toBe("https://lumen.health"); + expect(container.querySelector("a")?.getAttribute("rel")).toContain("noopener"); + }); + + it("never runs the owner's markup", () => { + const { container } = render( + Hix

    ' + } + />, + ); + expect(container.querySelector("img, script, [onclick]")).toBeNull(); + expect(container.querySelector("a")).toBeNull(); + expect(container.textContent).toBe("Hix"); + }); +}); diff --git a/apps/web/src/features/resume/public/rich-text.tsx b/apps/web/src/features/resume/public/rich-text.tsx new file mode 100644 index 000000000..1dcc557a5 --- /dev/null +++ b/apps/web/src/features/resume/public/rich-text.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { createElement } from "react"; + +const TAGS: Record = { + p: "p", + br: "br", + ul: "ul", + ol: "ol", + li: "li", + strong: "strong", + b: "strong", + em: "em", + i: "em", + u: "u", + s: "s", + strike: "s", + del: "s", + code: "code", + mark: "mark", + blockquote: "blockquote", + h1: "p", + h2: "p", + h3: "p", + h4: "p", + h5: "p", + h6: "p", +}; + +// Their contents are code or markup, not text anyone wrote for the page. +const DROPPED = new Set(["script", "style", "template", "noscript", "iframe", "object"]); + +const SAFE_HREF = /^(https?:|mailto:|tel:)/i; + +function toReact(node: Node, key: number): ReactNode { + if (node.nodeType === Node.TEXT_NODE) return node.textContent; + if (node.nodeType !== Node.ELEMENT_NODE) return null; + + const element = node as Element; + const tag = element.tagName.toLowerCase(); + if (DROPPED.has(tag)) return null; + const children = Array.from(element.childNodes).map(toReact); + + if (tag === "a") { + const href = element.getAttribute("href")?.trim() ?? ""; + if (!SAFE_HREF.test(href)) return createElement("span", { key }, ...children); + return createElement("a", { key, href, target: "_blank", rel: "noopener noreferrer nofollow" }, ...children); + } + + const allowed = TAGS[tag]; + // Anything else (spans, divs, tables, scripts…) keeps only its text. + if (!allowed) return children.length ? createElement("span", { key }, ...children) : null; + if (allowed === "br") return createElement("br", { key }); + if (tag.startsWith("h")) return createElement("p", { key }, createElement("strong", null, ...children)); + return createElement(allowed, { key }, ...children); +} + +type RichTextProps = { html: string; className?: string }; + +/** + * A resume's rich text on the public page. The owner's HTML is never injected: it's parsed, and only formatting tags + * and plain links become elements, so whatever else it holds stays inert text. + */ +export function RichText({ html, className }: RichTextProps) { + if (!html.trim()) return null; + const body = new DOMParser().parseFromString(html, "text/html").body; + return
    {Array.from(body.childNodes).map(toReact)}
    ; +} diff --git a/apps/web/src/routes/$username/$slug.tsx b/apps/web/src/routes/$username/$slug.tsx index 7005f17b3..c6af9ab68 100644 --- a/apps/web/src/routes/$username/$slug.tsx +++ b/apps/web/src/routes/$username/$slug.tsx @@ -11,6 +11,10 @@ type LoaderData = Omit & { data: Re export const Route = createFileRoute("/$username/$slug")({ ssr: "data-only", component: lazyRouteComponent(() => import("@/features/resume/public/public-resume"), "PublicResumeRoute"), + notFoundComponent: lazyRouteComponent( + () => import("@/features/resume/public/public-resume"), + "SharedResumeUnavailable", + ), loader: async ({ context, params }) => { const { username, slug } = params; const resume = await context.queryClient.ensureQueryData(