Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src/app/_components/public-resume-footer-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"use client";

import { DownloadIcon } from "@/app/_components/cv-studio-ui";
import { publicResumeColumnClass } from "@/app/_components/public-resume-stage";
import { downloadPdfFromUrl } from "@/app/_lib/pdf-download-client";
import { buildResumePdfDownloadUrl } from "@/app/_lib/resume-print-url";
import Link from "next/link";

export function PublicResumeFooterActions({
pageWidth,
showBranding,
}: {
pageWidth: number;
showBranding: boolean;
}) {
const handleDownloadPdf = async () => {
Expand All @@ -28,8 +27,7 @@ export function PublicResumeFooterActions({
if (!showBranding) {
return (
<footer
className="app-chrome mx-auto mt-4 flex w-full items-center justify-end pb-2 lg:mt-5 lg:pb-0 print:hidden"
style={{ maxWidth: `${pageWidth}px` }}
className={`app-chrome ${publicResumeColumnClass} mt-4 flex items-center justify-end pb-2 lg:mt-5 lg:pb-0 print:hidden`}
>
<button
className="inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center gap-1.5 text-[0.82rem] font-medium text-slate-600 underline-offset-4 transition hover:text-slate-950 hover:underline sm:min-h-0 sm:min-w-0"
Expand All @@ -45,8 +43,7 @@ export function PublicResumeFooterActions({

return (
<footer
className="app-chrome mx-auto mt-4 flex w-full flex-col gap-2 pb-2 text-left sm:flex-row sm:items-center sm:justify-between lg:mt-5 lg:pb-0 print:hidden"
style={{ maxWidth: `${pageWidth}px` }}
className={`app-chrome ${publicResumeColumnClass} mt-4 flex flex-col gap-2 pb-2 text-left sm:flex-row sm:items-center sm:justify-between lg:mt-5 lg:pb-0 print:hidden`}
>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-slate-500">
Published with Tiny CV
Expand Down
10 changes: 7 additions & 3 deletions src/app/_components/public-resume-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PublicResumeFooterActions } from "@/app/_components/public-resume-footer-actions";
import { PublicResumeDesktopStage } from "@/app/_components/public-resume-stage";
import { ResumeDesktopSheet, ResumeMobileSheet } from "@/app/_components/resume-live-document";
import { ResumePrintView } from "@/app/_components/resume-print-view";
import { getPageMetrics } from "@/app/_lib/cv-fit";
Expand Down Expand Up @@ -31,11 +32,14 @@ export async function PublicResumeRenderer({
<ResumeMobileSheet document={document} />
</div>

<div className="public-resume-desktop hidden flex-1 items-start justify-center lg:flex">
<PublicResumeDesktopStage
pageHeight={pageMetrics.pageHeight}
pageWidth={pageMetrics.pageWidth}
>
<ResumeDesktopSheet document={document} fitScale={resume.fitScale} />
</div>
</PublicResumeDesktopStage>

<PublicResumeFooterActions pageWidth={pageMetrics.pageWidth} showBranding={!hideBranding} />
<PublicResumeFooterActions showBranding={!hideBranding} />
</div>

<style media="print">{`@page { size: ${document.style.pageSize}; margin: 0; }`}</style>
Expand Down
80 changes: 80 additions & 0 deletions src/app/_components/public-resume-stage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import {
type CSSProperties,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";

/**
* Shared column for the public resume sheet and the actions underneath it so the
* two stay aligned at every width. The 102rem cap is twice the 8.5in page width,
* which is how far the sheet is allowed to scale up on very wide displays.
*/
export const publicResumeColumnClass = "mx-auto w-full max-w-[102rem]";

export function PublicResumeDesktopStage({
children,
pageHeight,
pageWidth,
}: {
children: ReactNode;
pageHeight: number;
pageWidth: number;
}) {
const stageRef = useRef<HTMLDivElement>(null);
const [stageScale, setStageScale] = useState<number | null>(null);

useLayoutEffect(() => {
const stage = stageRef.current;

if (!stage) {
return;
}

const updateStageScale = () => {
const availableWidth = stage.clientWidth;

if (availableWidth <= 0) {
return;
}

// Round down so the scaled sheet never overflows the stage by a subpixel.
const nextScale = Math.floor((availableWidth / pageWidth) * 1000) / 1000;

setStageScale((current) => (current === nextScale ? current : nextScale));
};

updateStageScale();

const observer = new ResizeObserver(() => {
window.requestAnimationFrame(updateStageScale);
});

observer.observe(stage);

return () => observer.disconnect();
}, [pageWidth]);

return (
<div
className={`public-resume-desktop ${publicResumeColumnClass} hidden flex-1 items-start justify-center lg:flex`}
ref={stageRef}
>
<div
className="cv-paper-frame cv-scaled-frame"
style={{
height: `calc(${pageHeight}px * var(--cv-stage-scale))`,
width: `calc(${pageWidth}px * var(--cv-stage-scale))`,
// Before hydration the scale comes from the CSS breakpoint ladder in
// globals.css; the measured value takes over once it is known.
...(stageScale === null ? null : { "--cv-stage-scale": stageScale }),
} as CSSProperties}
>
{children}
</div>
</div>
);
}
63 changes: 63 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,68 @@ textarea {
overflow: hidden;
}

/*
* A frame that renders a full-size page scaled to the width it is given, so the
* published resume grows with the viewport instead of sitting at 8.5in.
*/
.cv-scaled-frame {
--cv-stage-scale: 1;
}

.cv-scaled-frame > .cv-sheet {
transform: scale(var(--cv-stage-scale));
transform-origin: top left;
}

/*
* Pre-hydration approximation of the width PublicResumeDesktopStage measures,
* so the sheet lands close to its final size on first paint (and still scales
* without JavaScript). Every step stays a little under the exact
* viewport-derived scale, leaving room for a scrollbar, so the sheet can never
* overflow its stage before the measured value takes over.
*/
@media (min-width: 1024px) {
.cv-scaled-frame {
--cv-stage-scale: 1.15;
}
}

@media (min-width: 1152px) {
.cv-scaled-frame {
--cv-stage-scale: 1.31;
}
}

@media (min-width: 1280px) {
.cv-scaled-frame {
--cv-stage-scale: 1.46;
}
}

@media (min-width: 1408px) {
.cv-scaled-frame {
--cv-stage-scale: 1.62;
}
}

@media (min-width: 1536px) {
.cv-scaled-frame {
--cv-stage-scale: 1.78;
}
}

@media (min-width: 1664px) {
.cv-scaled-frame {
--cv-stage-scale: 1.93;
}
}

@media (min-width: 1792px) {
.cv-scaled-frame {
--cv-stage-scale: 2;
}
}

.cv-document {
height: 100%;
position: relative;
Expand Down Expand Up @@ -201,6 +263,7 @@ textarea {
align-items: flex-start !important;
justify-content: flex-start !important;
width: auto !important;
max-width: none !important;
min-height: 0 !important;
}

Expand Down
Loading