mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 12:22:00 +10:00
updates to printer, added changelog entry, restored deploy script in CI
This commit is contained in:
@@ -3,6 +3,7 @@ name: Build Docker Image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ "v*.*.*" ]
|
||||
|
||||
concurrency:
|
||||
@@ -204,4 +205,14 @@ jobs:
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Redeploy Stack
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
script: |
|
||||
cd docker
|
||||
./manage_stack.sh up reactive_resume
|
||||
@@ -4,7 +4,16 @@ description: "List of all notable changes and updates to Reactive Resume"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.0.0" description="26th January 2026">
|
||||
<Update label="v5.0.1" description="24th January 2026">
|
||||
- Updated translations from Crowdin.
|
||||
- Added a Community Spotlight section to the documentation.
|
||||
- Remove `-r require-metadata` from the Dockerfile as it was not needed.
|
||||
- Fixed inconsistencies in the docker compose examples in the documentation.
|
||||
- Fixed an issue with usernames not allowing hyphens in them.
|
||||
- Fixed issues with the printer service, when using the `getResumeScreenshot` or `printResumeAsPDF` endpoints.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.0" description="22th January 2026">
|
||||
This has been a major overhaul from the previous version of Reactive Resume. The app has been completely redesigned and rebuilt from scratch, to be more intuitive and user-friendly.
|
||||
|
||||
**Here are some of the key changes from the previous version:**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -19,23 +20,16 @@ const pageDimensions = {
|
||||
|
||||
const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
let browser: Browser | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
const endpoint = new URL(env.PRINTER_ENDPOINT);
|
||||
const isWebSocket = endpoint.protocol.startsWith("ws");
|
||||
|
||||
const connectOptions: ConnectOptions = {
|
||||
acceptInsecureCerts: true,
|
||||
defaultViewport: pageDimensions.a4,
|
||||
};
|
||||
const connectOptions: ConnectOptions = { acceptInsecureCerts: true };
|
||||
|
||||
if (isWebSocket) connectOptions.browserWSEndpoint = env.PRINTER_ENDPOINT;
|
||||
else connectOptions.browserURL = env.PRINTER_ENDPOINT;
|
||||
|
||||
if (browser?.connected) return browser;
|
||||
browser = await puppeteer.connect(connectOptions);
|
||||
return browser;
|
||||
return puppeteer.connect(connectOptions);
|
||||
}
|
||||
|
||||
export const printerService = {
|
||||
@@ -110,83 +104,90 @@ export const printerService = {
|
||||
marginY = Math.round(data.metadata.page.marginY / 0.75);
|
||||
}
|
||||
|
||||
// Step 4: Connect to the browser and navigate to the printer route
|
||||
const browser = await getBrowser();
|
||||
let browser: Browser | null = null;
|
||||
|
||||
// Set locale cookie so the resume renders in the correct language
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
try {
|
||||
// Step 4: Connect to the browser and navigate to the printer route
|
||||
browser = await getBrowser();
|
||||
|
||||
const page = await browser.newPage();
|
||||
// Set locale cookie so the resume renders in the correct language
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
// Wait for the page to fully load (network idle + custom loaded attribute)
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Step 5: Adjust the DOM for proper PDF pagination
|
||||
// This runs in the browser context to modify CSS before PDF generation
|
||||
await page.evaluate((marginY: number) => {
|
||||
const root = document.documentElement;
|
||||
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
||||
// Wait for the page to fully load (network idle + custom loaded attribute)
|
||||
await page.setViewport(pageDimensions[format]);
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
|
||||
// The --page-height CSS variable controls the height of each resume page.
|
||||
// We need to reduce it by the PDF margins so content fits within the printable area.
|
||||
// Without this, content would overflow and create empty pages.
|
||||
const containerHeight = container ? getComputedStyle(container).getPropertyValue("--page-height").trim() : null;
|
||||
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
|
||||
const currentHeight = containerHeight || rootHeight;
|
||||
const heightValue = Number.parseFloat(currentHeight);
|
||||
// Step 5: Adjust the DOM for proper PDF pagination
|
||||
// This runs in the browser context to modify CSS before PDF generation
|
||||
await page.evaluate((marginY: number) => {
|
||||
const root = document.documentElement;
|
||||
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
||||
|
||||
if (!Number.isNaN(heightValue)) {
|
||||
// Subtract top + bottom margins from page height
|
||||
const newHeight = `${heightValue - marginY}px`;
|
||||
if (container) container.style.setProperty("--page-height", newHeight);
|
||||
root.style.setProperty("--page-height", newHeight);
|
||||
}
|
||||
// The --page-height CSS variable controls the height of each resume page.
|
||||
// We need to reduce it by the PDF margins so content fits within the printable area.
|
||||
// Without this, content would overflow and create empty pages.
|
||||
const containerHeight = container ? getComputedStyle(container).getPropertyValue("--page-height").trim() : null;
|
||||
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
|
||||
const currentHeight = containerHeight || rootHeight;
|
||||
const heightValue = Number.parseFloat(currentHeight);
|
||||
|
||||
// Add page break CSS to each resume page element (identified by data-page-index attribute)
|
||||
// This ensures each visual resume page starts a new PDF page
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
if (!Number.isNaN(heightValue)) {
|
||||
// Subtract top + bottom margins from page height
|
||||
const newHeight = `${heightValue - marginY}px`;
|
||||
if (container) container.style.setProperty("--page-height", newHeight);
|
||||
root.style.setProperty("--page-height", newHeight);
|
||||
}
|
||||
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
||||
// Add page break CSS to each resume page element (identified by data-page-index attribute)
|
||||
// This ensures each visual resume page starts a new PDF page
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
|
||||
// Force a page break before each page except the first
|
||||
if (index > 0) element.style.breakBefore = "page";
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
||||
|
||||
// Allow content within a page to break naturally if it overflows
|
||||
// (e.g., if a single page has more content than fits on one PDF page)
|
||||
element.style.breakInside = "auto";
|
||||
}
|
||||
}, marginY);
|
||||
// Force a page break before each page except the first
|
||||
if (index > 0) element.style.breakBefore = "page";
|
||||
|
||||
// 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`,
|
||||
tagged: true, // Adds accessibility tags to the PDF
|
||||
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
||||
printBackground: true, // Includes background colors and images
|
||||
margin: {
|
||||
top: marginY,
|
||||
right: marginX,
|
||||
// bottom: marginY,
|
||||
left: marginX,
|
||||
},
|
||||
});
|
||||
// Allow content within a page to break naturally if it overflows
|
||||
// (e.g., if a single page has more content than fits on one PDF page)
|
||||
element.style.breakInside = "auto";
|
||||
}
|
||||
}, marginY);
|
||||
|
||||
await page.close();
|
||||
// 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`,
|
||||
tagged: true, // Adds accessibility tags to the PDF
|
||||
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
||||
printBackground: true, // Includes background colors and images
|
||||
margin: {
|
||||
top: marginY,
|
||||
right: marginX,
|
||||
// bottom: marginY,
|
||||
left: marginX,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 7: Upload the generated PDF to storage
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(pdfBuffer),
|
||||
contentType: "application/pdf",
|
||||
type: "pdf",
|
||||
});
|
||||
// Step 7: Upload the generated PDF to storage
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(pdfBuffer),
|
||||
contentType: "application/pdf",
|
||||
type: "pdf",
|
||||
});
|
||||
|
||||
return result.url;
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
},
|
||||
|
||||
getResumeScreenshot: async (
|
||||
@@ -228,27 +229,34 @@ export const printerService = {
|
||||
const token = generatePrinterToken(id);
|
||||
const url = `${baseUrl}/printer/${id}?token=${token}`;
|
||||
|
||||
const browser = await getBrowser();
|
||||
let browser: Browser | null = null;
|
||||
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
try {
|
||||
browser = await getBrowser();
|
||||
|
||||
const page = await browser.newPage();
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
const page = await browser.newPage();
|
||||
|
||||
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
|
||||
await page.setViewport(pageDimensions.a4);
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
|
||||
await page.close();
|
||||
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
|
||||
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(screenshotBuffer),
|
||||
contentType: "image/webp",
|
||||
type: "screenshot",
|
||||
});
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(screenshotBuffer),
|
||||
contentType: "image/webp",
|
||||
type: "screenshot",
|
||||
});
|
||||
|
||||
return result.url;
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,8 +27,8 @@ export function Hero() {
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
// @ts-expect-error - typescript doesn't know about fetchpriority for video elements
|
||||
fetchpriority="high"
|
||||
// @ts-expect-error - typescript doesn't know about fetchPriority for video elements
|
||||
fetchPriority="high"
|
||||
src="/videos/timelapse.webm"
|
||||
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
|
||||
className="pointer-events-none aspect-video size-full rounded-lg border object-cover shadow-2xl"
|
||||
|
||||
Reference in New Issue
Block a user