This commit is contained in:
Amruth Pillai
2026-01-24 23:00:54 +01:00
parent 1419c16d04
commit be84f0cca6
74 changed files with 2329 additions and 1035 deletions
+91 -32
View File
@@ -1,10 +1,15 @@
import { Trans } from "@lingui/react/macro";
import { IconContext, type IconProps } from "@phosphor-icons/react";
import { useMemo } from "react";
import { ArrowRightIcon, IconContext, type IconProps, WarningIcon } from "@phosphor-icons/react";
import { type RefObject, useMemo, useRef, useState } from "react";
import { match } from "ts-pattern";
import { useResizeObserver } from "usehooks-ts";
import type z from "zod";
import { pageDimensionsAsPixels } from "@/schema/page";
import type { pageLayoutSchema } from "@/schema/resume/data";
import type { Template } from "@/schema/templates";
import { sanitizeCss } from "@/utils/sanitize";
import { cn } from "@/utils/style";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { useCSSVariables } from "./hooks/use-css-variables";
import { useWebfonts } from "./hooks/use-webfonts";
import styles from "./preview.module.css";
@@ -27,11 +32,6 @@ export type ExtendedIconProps = IconProps & {
hidden?: boolean;
};
type Props = React.ComponentProps<"div"> & {
pageClassName?: string;
showPageNumbers?: boolean;
};
const CSS_RULE_SPLIT_PATTERN = /\n(?=\s*[.#a-zA-Z])/;
const CSS_SELECTOR_PATTERN = /^([^{]+)(\{)/;
@@ -53,13 +53,17 @@ function getTemplateComponent(template: Template) {
.exhaustive();
}
export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...props }: Props) => {
type Props = React.ComponentProps<"div"> & {
pageClassName?: string;
showPageNumbers?: boolean;
};
export const ResumePreview = ({ showPageNumbers = false, pageClassName, className, ...props }: Props) => {
const picture = useResumeStore((state) => state.resume.data.picture);
const metadata = useResumeStore((state) => state.resume.data.metadata);
useWebfonts(metadata.typography);
const style = useCSSVariables({ picture, metadata });
const totalNumberOfPages = metadata.layout.pages.length;
const iconProps = useMemo<ExtendedIconProps>(() => {
return {
@@ -94,36 +98,91 @@ export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...pr
return scoped;
}, [metadata.css.enabled, metadata.css.value]);
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
return (
<IconContext.Provider value={iconProps}>
{/** biome-ignore lint/security/noDangerouslySetInnerHtml: CSS is sanitized with sanitizeCss */}
{scopedCSS && <style dangerouslySetInnerHTML={{ __html: scopedCSS }} />}
<div style={style} className={cn("resume-preview-container", className)} {...props}>
{metadata.layout.pages.map((pageLayout, pageIndex) => {
const pageNumber = pageIndex + 1;
return (
<div key={pageIndex} data-page-index={pageIndex} className="relative">
{showPageNumbers && totalNumberOfPages > 1 && (
<div className="absolute -top-6 left-0">
<span className="font-medium text-foreground text-xs">
<Trans>
Page {pageNumber} of {totalNumberOfPages}
</Trans>
</span>
</div>
)}
<div className={cn(`page page-${pageIndex}`, styles.page, pageClassName)}>
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
</div>
</div>
);
})}
{metadata.layout.pages.map((pageLayout, pageIndex) => (
<PageContainer
key={pageIndex}
pageIndex={pageIndex}
pageLayout={pageLayout}
pageClassName={pageClassName}
showPageNumbers={showPageNumbers}
/>
))}
</div>
</IconContext.Provider>
);
};
type PageContainerProps = {
pageIndex: number;
pageLayout: z.infer<typeof pageLayoutSchema>;
pageClassName?: string;
showPageNumbers?: boolean;
};
function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers = false }: PageContainerProps) {
const pageRef = useRef<HTMLDivElement>(null);
const [pageHeight, setPageHeight] = useState<number>(0);
const metadata = useResumeStore((state) => state.resume.data.metadata);
const pageNumber = useMemo(() => pageIndex + 1, [pageIndex]);
const maxPageHeight = useMemo(() => pageDimensionsAsPixels[metadata.page.format].height, [metadata.page.format]);
const totalNumberOfPages = useMemo(() => metadata.layout.pages.length, [metadata.layout.pages]);
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
useResizeObserver({
ref: pageRef as RefObject<HTMLDivElement>,
onResize: (size) => {
if (!size.height) return;
setPageHeight(size.height);
},
});
return (
<div data-page-index={pageIndex} className="relative">
{showPageNumbers && totalNumberOfPages > 1 && (
<div className="absolute -top-6 left-0">
<span className="font-medium text-foreground text-xs">
<Trans>
Page {pageNumber} of {totalNumberOfPages}
</Trans>
</span>
</div>
)}
<div ref={pageRef} className={cn(`page page-${pageIndex}`, styles.page, pageClassName)}>
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
</div>
{pageHeight > maxPageHeight && (
<div className="absolute top-full left-0 mt-4">
<a
rel="noopener"
target="_blank"
className="group/link"
href="https://docs.rxresu.me/guides/fitting-content-on-a-page"
>
<Alert className="max-w-sm text-yellow-600">
<WarningIcon color="currentColor" />
<AlertTitle>
<Trans>
The content is too tall for this page, this may cause undesirable results when exporting to PDF.
</Trans>
</AlertTitle>
<AlertDescription className="text-xs underline-offset-2 group-hover/link:underline">
<Trans>Learn more about how to fit content on a page</Trans>
<ArrowRightIcon color="currentColor" className="ml-1 inline size-3" />
</AlertDescription>
</Alert>
</a>
</div>
)}
</div>
);
}
+12 -10
View File
@@ -36,21 +36,23 @@ export function DitgarTemplate({ pageIndex, pageLayout }: TemplateProps) {
return (
<div className="template-ditgar page-content">
{/* Sidebar Background */}
{!fullWidth && (
{(!fullWidth || isFirstPage) && (
<div className="page-sidebar-background absolute inset-y-0 left-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20" />
)}
<div className="flex">
<aside data-layout="sidebar" className="sidebar group z-10 flex w-(--page-sidebar-width) shrink-0 flex-col">
{isFirstPage && <Header />}
{(!fullWidth || isFirstPage) && (
<aside data-layout="sidebar" className="sidebar group z-10 flex w-(--page-sidebar-width) shrink-0 flex-col">
{isFirstPage && <Header />}
<div className="flex-1 space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</aside>
<div className="flex-1 space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</aside>
)}
<main data-layout="main" className={cn("main group z-10", !fullWidth ? "col-span-2" : "col-span-3")}>
{isFirstPage && <SummaryComponent id="summary" />}
+1 -1
View File
@@ -3,7 +3,7 @@ import type * as React from "react";
import { cn } from "@/utils/style";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 has-data-[slot=alert-action]:pr-18 *:[svg:not([class*='size-'])]:size-4 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current",
"group/alert relative grid w-full gap-1 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 has-data-[slot=alert-action]:pr-18 *:[svg:not([class*='size-'])]:size-4 *:[svg]:row-span-2 *:[svg]:translate-y-1 *:[svg]:text-current",
{
variants: {
variant: {
+6 -16
View File
@@ -2,22 +2,12 @@ import { ORPCError } from "@orpc/server";
import type { InferSelectModel } from "drizzle-orm";
import puppeteer, { type Browser, type ConnectOptions } from "puppeteer-core";
import type { schema } from "@/integrations/drizzle";
import { pageDimensionsAsPixels } from "@/schema/page";
import { printMarginTemplates } from "@/schema/templates";
import { env } from "@/utils/env";
import { generatePrinterToken } from "@/utils/printer-token";
import { getStorageService, uploadFile } from "./storage";
const pageDimensions = {
a4: {
width: 794,
height: 1123,
},
letter: {
width: 816,
height: 1056,
},
} as const;
const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour
async function getBrowser(): Promise<Browser> {
@@ -116,7 +106,7 @@ export const printerService = {
const page = await browser.newPage();
// Wait for the page to fully load (network idle + custom loaded attribute)
await page.setViewport(pageDimensions[format]);
await page.setViewport(pageDimensionsAsPixels[format]);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
@@ -161,13 +151,13 @@ export const printerService = {
}
},
marginY,
pageDimensions[format].height,
pageDimensionsAsPixels[format].height,
);
// Step 6: Generate the PDF with the specified dimensions and margins
const pdfBuffer = await page.pdf({
width: `${pageDimensions[format].width}px`,
height: `${pageDimensions[format].height}px`,
width: `${pageDimensionsAsPixels[format].width}px`,
height: `${pageDimensionsAsPixels[format].height}px`,
tagged: true, // Adds accessibility tags to the PDF
waitForFonts: true, // Ensures all fonts are loaded before rendering
printBackground: true, // Includes background colors and images
@@ -244,7 +234,7 @@ export const printerService = {
const page = await browser.newPage();
await page.setViewport(pageDimensions.a4);
await page.setViewport(pageDimensionsAsPixels.a4);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
+5 -5
View File
@@ -26,16 +26,16 @@ type SocialLink = {
const getResourceLinks = (): FooterLinkItem[] => [
{ url: "https://docs.rxresu.me", label: t`Documentation` },
{ url: "https://opencollective.com/reactive-resume", label: t`Sponsorships` },
{ url: "https://github.com/amruthpillai/reactive-resume", label: t`Source Code` },
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
{ url: "https://opencollective.com/reactive-resume", label: t`Donate` },
{ url: "https://docs.rxresu.me/changelog", label: t`Changelog` },
];
const getCommunityLinks = (): FooterLinkItem[] => [
{ url: "https://github.com/amruthpillai/reactive-resume/discussions", label: t`Discussions` },
{ url: "https://github.com/amruthpillai/reactive-resume/issues", label: t`Report an Issue` },
{ url: "https://github.com/amruthpillai/reactive-resume/issues", label: t`Report an issue` },
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
{ url: "https://github.com/amruthpillai/reactive-resume/blob/main/CONTRIBUTING.md", label: t`Contributing` },
{ url: "https://reddit.com/r/reactiveresume", label: t`Subreddit` },
{ url: "https://discord.gg/EE8yFqW4", label: t`Discord` },
];
const socialLinks: SocialLink[] = [
+3 -13
View File
@@ -1,21 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { json } from "@tanstack/react-start";
import z from "zod";
import { resumeDataSchema } from "@/schema/resume/data";
function handler({ request }: { request: Request }) {
const url = new URL(request.url);
function handler() {
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
const resumeDataJSONSchema = z.toJSONSchema(
resumeDataSchema.extend({
version: z.literal("5.0.0").describe("The version of the Reactive Resume JSON Schema"),
$schema: z
.literal(`${url.origin}/schema.json`)
.describe("The URL of the Reactive Resume JSON Schema, used for validation and documentation purposes."),
}),
);
return json(resumeDataJSONSchema, {
return Response.json(resumeDataJSONSchema, {
status: 200,
headers: {
"Content-Type": "application/schema+json; charset=utf-8",
+21
View File
@@ -0,0 +1,21 @@
export const pageDimensionsAsPixels = {
a4: {
width: 794,
height: 1123,
},
letter: {
width: 816,
height: 1056,
},
} as const;
export const pageDimensionsAsMillimeters = {
a4: {
width: "210mm",
height: "297mm",
},
letter: {
width: "216mm",
height: "279mm",
},
} as const;
File diff suppressed because one or more lines are too long