mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 23:07:01 +10:00
📦 v5.0.2 · Changelog: https://docs.rxresu.me/changelog
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user