feat: integrate PDF generation using Puppeteer
- Removed dependencies on html2canvas-pro and jsPDF. - Added puppeteer-core for PDF generation. - Created a new API endpoint for generating PDFs. - Updated SchedaAnnuncioStampabile component to use the new PDF generation API. - Enhanced error handling and user feedback during PDF generation. - Added browserless service to docker-compose for Puppeteer. - Updated environment variables to include BROWSER_WS_URL. - Refactored code to improve readability and maintainability.
This commit is contained in:
parent
217d6c7982
commit
42c15dfe96
12 changed files with 1226 additions and 651 deletions
|
|
@ -85,6 +85,8 @@ ARG STORAGE_URL
|
|||
ENV STORAGE_URL=$STORAGE_URL
|
||||
ARG STORAGE_TOKEN
|
||||
ENV STORAGE_TOKEN=$STORAGE_TOKEN
|
||||
ARG BROWSER_WS_URL
|
||||
ENV BROWSER_WS_URL=$BROWSER_WS_URL
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
browserless:
|
||||
image: browserless/chrome:latest
|
||||
ports:
|
||||
- "8081:3000" # Optional: Access the debug dashboard at localhost:8080
|
||||
environment:
|
||||
- MAX_CONCURRENT_SESSIONS=5
|
||||
- CONNECTION_TIMEOUT=30000
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
tiles-data:
|
||||
|
|
|
|||
1005
apps/infoalloggi/package-lock.json
generated
1005
apps/infoalloggi/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -61,10 +61,8 @@
|
|||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.27.5",
|
||||
"frimousse": "^0.3.0",
|
||||
"html2canvas-pro": "^1.6.5",
|
||||
"ioredis": "^5.9.2",
|
||||
"jose": "^6.0.12",
|
||||
"jspdf": "^4.0.0",
|
||||
"kysely": "^0.28.10",
|
||||
"kysely-plugin-serialize": "^0.8.2",
|
||||
"leaflet": "^1.9.4",
|
||||
|
|
@ -78,6 +76,7 @@
|
|||
"nuqs": "^2.8.6",
|
||||
"pg": "^8.17.1",
|
||||
"postcss": "^8.5.6",
|
||||
"puppeteer-core": "^24.36.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import html2canvas from "html2canvas-pro";
|
||||
import jsPDF from "jspdf";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useReactToPrint } from "react-to-print";
|
||||
import z from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
|
|
@ -12,7 +9,7 @@ import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
|
|||
import { it } from "~/i18n/it";
|
||||
import { handleConsegna } from "~/lib/annuncio_details";
|
||||
import { replaceWithBr } from "~/lib/newlineToBr";
|
||||
import { formatCurrency } from "~/lib/utils";
|
||||
import { cn, formatCurrency } from "~/lib/utils";
|
||||
import type { Annunci } from "~/schemas/public/Annunci";
|
||||
import { api } from "~/utils/api";
|
||||
import { IconMatrix } from "./IconComponents";
|
||||
|
|
@ -21,14 +18,25 @@ import { Input } from "./ui/input";
|
|||
import { Label } from "./ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
const blobToBase64 = (blob: Blob): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = reject;
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Remove the Data-URL prefix (e.g. "data:application/pdf;base64,") to get just the base64
|
||||
resolve(reader.result.split(",")[1] || "");
|
||||
} else {
|
||||
reject(new Error("Failed to convert blob to text"));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
|
||||
const [inputEmail, setInputEmail] = useState<string>("");
|
||||
const [openPopover, setOpenPopover] = useState<boolean>(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const reactToPrintFn = useReactToPrint({
|
||||
contentRef,
|
||||
//print: async (target) => {console.log("Printing...", target.contentDocument);},
|
||||
});
|
||||
|
||||
const { mutate } = api.comunicazioni.sendSchedaAnnuncioEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
|
|
@ -43,119 +51,25 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
|
|||
},
|
||||
});
|
||||
|
||||
const sendEmail = useReactToPrint({
|
||||
contentRef,
|
||||
print: async (target) => {
|
||||
try {
|
||||
const htmlContent = target.contentDocument?.documentElement;
|
||||
if (!htmlContent) {
|
||||
toast.error("Impossibile generare il contenuto per la stampa.");
|
||||
return;
|
||||
}
|
||||
const sendPdf = async () => {
|
||||
if (!data || !inputEmail || !data.locatore || !data.numero) {
|
||||
toast.error("Dati annuncio o indirizzo email mancanti.");
|
||||
toast.error("Dati annuncio mancanti.");
|
||||
return;
|
||||
}
|
||||
toast.loading("Invio email in corso...", {
|
||||
id: "send-scheda-annuncio-email",
|
||||
});
|
||||
setOpenPopover(false);
|
||||
htmlContent.querySelectorAll(".print\\:border-none").forEach((el) => {
|
||||
el.classList.remove("border");
|
||||
});
|
||||
htmlContent.querySelectorAll(".print\\:shadow-none").forEach((el) => {
|
||||
el.classList.remove("shadow-sm");
|
||||
});
|
||||
htmlContent.querySelectorAll("#navigationLink").forEach((el) => {
|
||||
el.classList.remove("flex");
|
||||
el.classList.add("hidden");
|
||||
});
|
||||
htmlContent.querySelectorAll("#mainLogoSvg text").forEach((el) => {
|
||||
el.classList.remove("font-sans");
|
||||
el.classList.add("font-[system-ui]");
|
||||
});
|
||||
const canvas = await html2canvas(htmlContent, {
|
||||
//scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
try {
|
||||
toast.loading("Generazione PDF in corso...", {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
const response = await fetch(`/api/generate-pdf?id=${data.id}`);
|
||||
|
||||
const imgData = canvas.toDataURL("image/png");
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
|
||||
// A4 dimensions in mm
|
||||
const pdfWidth = pdf.internal.pageSize.getWidth();
|
||||
const pdfHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
// Define margins (in mm)
|
||||
const marginTop = 5;
|
||||
const marginRight = 5;
|
||||
const marginBottom = 5;
|
||||
const marginLeft = 5;
|
||||
|
||||
// Calculate available space
|
||||
const availableWidth = pdfWidth - marginLeft - marginRight;
|
||||
const availableHeight = pdfHeight - marginTop - marginBottom;
|
||||
|
||||
// Calculate image dimensions
|
||||
const imgProps = pdf.getImageProperties(imgData);
|
||||
const imgWidth = imgProps.width;
|
||||
const imgHeight = imgProps.height;
|
||||
|
||||
// Calculate how the image should fit with margins
|
||||
const ratio = availableWidth / imgWidth;
|
||||
const scaledHeight = imgHeight * ratio;
|
||||
|
||||
// Check if content needs multiple pages
|
||||
if (scaledHeight <= availableHeight) {
|
||||
// Single page - content fits
|
||||
pdf.addImage(
|
||||
imgData,
|
||||
"PNG",
|
||||
marginLeft,
|
||||
marginTop,
|
||||
availableWidth,
|
||||
scaledHeight,
|
||||
undefined,
|
||||
"FAST",
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Errore nella generazione del PDF: ${response.statusText}`,
|
||||
);
|
||||
} else {
|
||||
// Multiple pages needed
|
||||
let heightLeft = scaledHeight;
|
||||
let position = marginTop;
|
||||
|
||||
// Add first page
|
||||
pdf.addImage(
|
||||
imgData,
|
||||
"PNG",
|
||||
marginLeft,
|
||||
position,
|
||||
availableWidth,
|
||||
scaledHeight,
|
||||
undefined,
|
||||
"FAST",
|
||||
);
|
||||
heightLeft -= availableHeight;
|
||||
|
||||
// Add remaining pages
|
||||
while (heightLeft > 0) {
|
||||
position = marginTop - (scaledHeight - heightLeft);
|
||||
pdf.addPage();
|
||||
pdf.addImage(
|
||||
imgData,
|
||||
"PNG",
|
||||
marginLeft,
|
||||
position,
|
||||
availableWidth,
|
||||
scaledHeight,
|
||||
undefined,
|
||||
"FAST",
|
||||
);
|
||||
heightLeft -= availableHeight;
|
||||
}
|
||||
}
|
||||
const pdfBase64 = pdf.output("dataurlstring");
|
||||
const blob = await response.blob();
|
||||
|
||||
const pdfBase64 = await blobToBase64(blob);
|
||||
|
||||
mutate({
|
||||
pdf: pdfBase64,
|
||||
|
|
@ -166,18 +80,52 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
|
|||
indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`,
|
||||
});
|
||||
setInputEmail("");
|
||||
} catch (e) {
|
||||
|
||||
toast.success("PDF generato con successo!", {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Errore durante l'invio dell'email: ${(e as Error).message}`,
|
||||
`Errore durante la generazione del PDF: ${(error as Error).message}`,
|
||||
{
|
||||
id: "send-scheda-annuncio-email",
|
||||
id: "generate-pdf",
|
||||
},
|
||||
);
|
||||
console.error("Error during print:", e);
|
||||
setInputEmail("");
|
||||
console.error("Error generating PDF:", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const generatePdf = async () => {
|
||||
if (!data) {
|
||||
toast.error("Dati annuncio mancanti.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
toast.loading("Generazione PDF in corso...", {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
const response = await fetch(`/api/generate-pdf?id=${data.id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Errore nella generazione del PDF: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
toast.success("PDF generato con successo!", {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
window.open(url, "_blank");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Errore durante la generazione del PDF: ${(error as Error).message}`,
|
||||
{
|
||||
id: "generate-pdf",
|
||||
},
|
||||
);
|
||||
console.error("Error generating PDF:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
|
|
@ -185,9 +133,8 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
|
|||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button className="print:hidden" onClick={() => reactToPrintFn()}>
|
||||
Stampa Scheda
|
||||
</Button>
|
||||
<Button onClick={() => generatePdf()}>Genera Scheda</Button>
|
||||
|
||||
<Popover onOpenChange={setOpenPopover} open={openPopover}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="print:hidden" variant="outline">
|
||||
|
|
@ -204,7 +151,7 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
|
|||
e.key === "Enter" &&
|
||||
z.safeParse(z.email(), inputEmail).success
|
||||
) {
|
||||
sendEmail();
|
||||
sendPdf();
|
||||
}
|
||||
}}
|
||||
type="email"
|
||||
|
|
@ -213,7 +160,7 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
|
|||
disabled={
|
||||
z.safeParse(z.email(), inputEmail).success === false
|
||||
}
|
||||
onClick={sendEmail}
|
||||
onClick={sendPdf}
|
||||
>
|
||||
Invia
|
||||
</Button>
|
||||
|
|
@ -221,15 +168,19 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
|
|||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={contentRef}>
|
||||
<SchedaAnnuncio data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedaAnnuncio({ data }: { data: Annunci }) {
|
||||
export function SchedaAnnuncio({
|
||||
data,
|
||||
raw,
|
||||
}: {
|
||||
data: Annunci;
|
||||
raw?: boolean;
|
||||
}) {
|
||||
const companyAddress =
|
||||
"Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it";
|
||||
|
||||
|
|
@ -239,7 +190,26 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
|
|||
const [navigationUrl, setNavigationUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [printDate, setPrintDate] = useState<string>("");
|
||||
const [disponibileDate, setDisponibileDate] = useState<string>("");
|
||||
useEffect(() => {
|
||||
setPrintDate(new Date().toLocaleDateString("it-IT"));
|
||||
if (data.disponibile_da) {
|
||||
setDisponibileDate(new Date(data.disponibile_da).toLocaleDateString());
|
||||
} else if (data.consegna) {
|
||||
setDisponibileDate(
|
||||
handleConsegna({
|
||||
aggiornamento: it.card.in_aggiornamento,
|
||||
consegna: data.consegna,
|
||||
consegna_da: it.card.consegna_da,
|
||||
mesi: it.parametri.mesi,
|
||||
subito: it.card.consegna_subito,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setDisponibileDate("Non specificata");
|
||||
}
|
||||
if (!data) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (
|
||||
|
|
@ -260,7 +230,12 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
|
|||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card className="p-4 print:border-none print:shadow-none">
|
||||
<Card
|
||||
className={cn(
|
||||
"p-4 print:border-none print:shadow-none",
|
||||
raw && "border-none shadow-none",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
|
|
@ -376,18 +351,7 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
|
|||
{(data.disponibile_da || data.consegna) && (
|
||||
<div className="rounded bg-gray-50 p-3">
|
||||
<p className="font-medium text-primary">Disponibile da</p>
|
||||
<p className="font-bold text-xl">
|
||||
{data.disponibile_da
|
||||
? new Date(data.disponibile_da).toLocaleDateString()
|
||||
: data.consegna &&
|
||||
handleConsegna({
|
||||
aggiornamento: it.card.in_aggiornamento,
|
||||
consegna: data.consegna,
|
||||
consegna_da: it.card.consegna_da,
|
||||
mesi: it.parametri.mesi,
|
||||
subito: it.card.consegna_subito,
|
||||
})}
|
||||
</p>
|
||||
<p className="font-bold text-xl">{disponibileDate}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -447,9 +411,7 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
|
|||
</div>
|
||||
<div className="pt-2 text-center text-muted-foreground text-xs">
|
||||
<p>{footer}</p>
|
||||
<p className="mt-1">
|
||||
Documento generato in data: {new Date().toLocaleDateString("it-IT")}
|
||||
</p>
|
||||
<p className="mt-1">Documento generato in data: {printDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const env = createEnv({
|
|||
REVALIDATION_SECRET: z.string(),
|
||||
STORAGE_URL: z.string(),
|
||||
STORAGE_TOKEN: z.string(),
|
||||
BROWSER_WS_URL: z.string(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_BASE_URL: z.string(),
|
||||
|
|
|
|||
|
|
@ -511,6 +511,16 @@ const BudgetFilters = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
<div className="flex w-full max-w-md flex-col gap-2">
|
||||
<Label htmlFor="slider">Price Range</Label>
|
||||
<Slider id="slider" max={1000} min={0} onValueChange={setValue} value={value} />
|
||||
<div className="flex items-center justify-between text-muted-foreground text-sm">
|
||||
<span>${value[0]}</span>
|
||||
<span>${value[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex h-4 flex-row gap-4 px-1">
|
||||
<Label
|
||||
|
|
|
|||
54
apps/infoalloggi/src/pages/api/generate-pdf.ts
Normal file
54
apps/infoalloggi/src/pages/api/generate-pdf.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
let browser = null;
|
||||
|
||||
try {
|
||||
browser = await puppeteer.connect({
|
||||
browserWSEndpoint: env.BROWSER_WS_URL,
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
if (req.cookies && Object.keys(req.cookies).length > 0) {
|
||||
const productionDomain = new URL(env.INTERNAL_BASE_URL).hostname;
|
||||
|
||||
const cookies = Object.entries(req.cookies).map(([name, value]) => ({
|
||||
name,
|
||||
value: value || "",
|
||||
domain:
|
||||
env.NODE_ENV === "production"
|
||||
? productionDomain
|
||||
: "host.docker.internal",
|
||||
path: "/",
|
||||
}));
|
||||
await browser.setCookie(...cookies);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
env.NODE_ENV === "production"
|
||||
? env.INTERNAL_BASE_URL
|
||||
: `http://host.docker.internal:3000`;
|
||||
const targetUrl = `${baseUrl}/area-riservata/admin/scheda-annuncio-stampa/${req.query.id}?raw=true`;
|
||||
await page.goto(targetUrl, { waitUntil: "networkidle0" });
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
});
|
||||
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.send(Buffer.from(pdf));
|
||||
} catch (error) {
|
||||
console.error("PDF Generation Error:", error);
|
||||
res.status(500).json({ error: "Failed to generate PDF" });
|
||||
} finally {
|
||||
// 'disconnect' tells browserless you're done with this tab
|
||||
if (browser) await browser.disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +1,44 @@
|
|||
import type { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import { AreaRiservataLayout } from "~/components/Layout";
|
||||
import { LoadingPage } from "~/components/loading";
|
||||
import { SchedaAnnuncioStampabile } from "~/components/schedaAnnuncioStampabile";
|
||||
import { Status500 } from "~/components/status-page";
|
||||
import {
|
||||
SchedaAnnuncio,
|
||||
SchedaAnnuncioStampabile,
|
||||
} from "~/components/schedaAnnuncioStampabile";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import type { AnnunciId } from "~/schemas/public/Annunci";
|
||||
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
|
||||
import type { AnnunciWithMedia } from "~/server/controllers/annunci.controller";
|
||||
import { generateSSGHelper } from "~/server/utils/ssgHelper";
|
||||
import { zAnnuncioId } from "~/server/utils/zod_types";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type PageProps = {
|
||||
id: AnnunciId;
|
||||
raw: boolean;
|
||||
data: StringifiedAnnunciWithMedia;
|
||||
};
|
||||
|
||||
const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({
|
||||
id,
|
||||
raw,
|
||||
data,
|
||||
}: PageProps) => {
|
||||
const { data, isLoading } = api.annunci.getAnnuncioById_rawImgUrls.useQuery({
|
||||
id,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (!data) {
|
||||
return <Status500 />;
|
||||
const annuncioData = StringifiedToAnnuncio(data);
|
||||
if (raw) {
|
||||
return <SchedaAnnuncio data={annuncioData} raw />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Scheda Annuncio {data.codice}</title>
|
||||
<title>Scheda Annuncio {annuncioData.codice}</title>
|
||||
</Head>
|
||||
<div className="mx-auto w-full p-4">
|
||||
<SchedaAnnuncioStampabile data={data} />
|
||||
<SchedaAnnuncioStampabile data={annuncioData} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SchedaAnnuncioPage.getLayout = function getLayout(page) {
|
||||
if (page.props.raw) {
|
||||
return <>{page}</>;
|
||||
}
|
||||
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
|
||||
};
|
||||
|
||||
|
|
@ -47,6 +46,7 @@ export default SchedaAnnuncioPage;
|
|||
|
||||
export const getServerSideProps = (async (context) => {
|
||||
const id = context.params?.id;
|
||||
const raw = context.query?.raw;
|
||||
|
||||
if (typeof id !== "string") {
|
||||
console.error("Error: id is not a string");
|
||||
|
|
@ -69,25 +69,58 @@ export const getServerSideProps = (async (context) => {
|
|||
};
|
||||
}
|
||||
|
||||
const access_token = context.req.cookies.access_token;
|
||||
const helper = generateSSGHelper();
|
||||
|
||||
const helper = await TrpcAuthedFetchingIstance({ access_token });
|
||||
if (helper) {
|
||||
await helper.trpc.annunci.getAnnuncioById_rawImgUrls.prefetch({
|
||||
const data = await helper.annunci.getAnnuncioById_rawImgUrls.fetch({
|
||||
id: parsed.data,
|
||||
});
|
||||
if (!data) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
id: parsed.data,
|
||||
trpcState: helper.trpc.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
raw: raw === "true",
|
||||
trpcState: helper.dehydrate(),
|
||||
data : AnnuncioToStringified(data),
|
||||
},
|
||||
};
|
||||
}) satisfies GetServerSideProps<PageProps>;
|
||||
|
||||
type StringifiedAnnunciWithMedia = Omit<
|
||||
AnnunciWithMedia,
|
||||
"creato_il" | "disponibile_da" | "media_updated_at" | "modificato_il"
|
||||
> & {
|
||||
creato_il: string | undefined;
|
||||
disponibile_da: string | undefined;
|
||||
media_updated_at: string | undefined;
|
||||
modificato_il: string | undefined;
|
||||
};
|
||||
|
||||
function AnnuncioToStringified(
|
||||
data: AnnunciWithMedia,
|
||||
): StringifiedAnnunciWithMedia {
|
||||
return {
|
||||
...data,
|
||||
creato_il: data?.creato_il?.toISOString(),
|
||||
disponibile_da: data?.disponibile_da?.toISOString(),
|
||||
media_updated_at: data?.media_updated_at?.toISOString(),
|
||||
modificato_il: data?.modificato_il?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function StringifiedToAnnuncio(
|
||||
data: StringifiedAnnunciWithMedia,
|
||||
): AnnunciWithMedia {
|
||||
return {
|
||||
...data,
|
||||
creato_il: data?.creato_il ? new Date(data.creato_il) : null,
|
||||
disponibile_da: data?.disponibile_da ? new Date(data.disponibile_da) : null,
|
||||
media_updated_at: data?.media_updated_at
|
||||
? new Date(data.media_updated_at)
|
||||
: null,
|
||||
modificato_il: data?.modificato_il ? new Date(data.modificato_il) : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,20 +8,16 @@ const STALE_KEY = "stale-resource";
|
|||
export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
||||
const { pathname, searchParams } = req.nextUrl;
|
||||
|
||||
if (!pathname.startsWith("/storage-api/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Storage API requests
|
||||
if (pathname.startsWith("/storage-api/")) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const isImageRequest = params.get("media") === "image";
|
||||
const isVideoRequest = params.get("media") === "video";
|
||||
|
||||
// Check if this is a Next.js Image Optimization request
|
||||
const purpose = req.headers.get("purpose");
|
||||
const isNextImageRequest =
|
||||
purpose === "prefetch" || req.headers.get("x-vercel-id");
|
||||
|
||||
// For regular requests (not from Next Image Optimization), check auth
|
||||
if (!isNextImageRequest && !isImageRequest && !isVideoRequest) {
|
||||
const accessToken = req.cookies.get(
|
||||
TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
|
||||
|
|
@ -44,18 +40,14 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Build the storage API URL correctly
|
||||
const slug = pathname.replace("/storage-api/", "");
|
||||
params.set("token", env.STORAGE_TOKEN);
|
||||
const storageUrl = `${env.STORAGE_URL}/${slug}?${params.toString()}`;
|
||||
|
||||
try {
|
||||
// Forward the request to storage API
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
|
||||
// **CRITICAL: Preserve Range header for video streaming**
|
||||
const rangeHeader = req.headers.get("range");
|
||||
|
||||
const response = await fetch(storageUrl, {
|
||||
|
|
@ -67,17 +59,10 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
duplex: "half",
|
||||
});
|
||||
|
||||
// Handle failed responses
|
||||
if (!response.ok) {
|
||||
// console.error(
|
||||
// `Storage API error: ${response.status} for ${storageUrl}, referrer: ${req.referrer}, url: ${req.url}`,
|
||||
// );
|
||||
// 404 = File not found (likely stale URL after media update)
|
||||
// 410 = Gone (explicitly removed)
|
||||
const isStaleResource =
|
||||
response.status === 404 || response.status === 410;
|
||||
|
||||
// Return fallback for image requests
|
||||
if (isImageRequest || isNextImageRequest) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/fallback-image.png";
|
||||
|
|
@ -93,7 +78,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
return response;
|
||||
}
|
||||
|
||||
// Return fallback for video requests
|
||||
if (isVideoRequest) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/fallback-video.png";
|
||||
|
|
@ -109,7 +93,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
return response;
|
||||
}
|
||||
|
||||
// For non-media requests (e.g., documents), return JSON with reload hint
|
||||
if (isStaleResource) {
|
||||
return NextResponse.json(
|
||||
{ message: "Stale resource, please reload." },
|
||||
|
|
@ -121,15 +104,12 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
});
|
||||
}
|
||||
|
||||
//ignore if 204 no content, is the favicon request
|
||||
if (response.status === 204) {
|
||||
return new NextResponse(null, { status: 200 });
|
||||
}
|
||||
|
||||
// Get response data
|
||||
const data = await response.arrayBuffer();
|
||||
|
||||
// Check if we got valid data
|
||||
if (!data || data.byteLength === 0) {
|
||||
console.error(
|
||||
"Received empty response from storage API, referrer:",
|
||||
|
|
@ -155,17 +135,14 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
return new NextResponse("Empty response", { status: 500 });
|
||||
}
|
||||
|
||||
// **IMPORTANT: Use correct status code for partial content**
|
||||
const status =
|
||||
rangeHeader && response.status === 206 ? 206 : response.status;
|
||||
|
||||
// Create response with proper headers
|
||||
const proxyResponse = new NextResponse(data, {
|
||||
status: status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
|
||||
// Copy relevant headers
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
|
|
@ -184,7 +161,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
proxyResponse.headers.set("Content-Length", contentLength);
|
||||
}
|
||||
|
||||
// **CRITICAL: Copy Range-related headers for video streaming**
|
||||
if (acceptRanges) {
|
||||
proxyResponse.headers.set("Accept-Ranges", acceptRanges);
|
||||
} else if (isVideoRequest) {
|
||||
|
|
@ -195,10 +171,8 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
proxyResponse.headers.set("Content-Range", contentRange);
|
||||
}
|
||||
|
||||
// Set CORS and caching headers
|
||||
proxyResponse.headers.set("Access-Control-Allow-Origin", "*");
|
||||
|
||||
// Different cache strategy for videos
|
||||
if (isVideoRequest) {
|
||||
proxyResponse.headers.set(
|
||||
"Cache-Control",
|
||||
|
|
@ -221,7 +195,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
} catch (error) {
|
||||
console.error("Storage proxy error:", error);
|
||||
|
||||
// Return fallback on error
|
||||
if (isImageRequest || isNextImageRequest) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/fallback-image.png";
|
||||
|
|
@ -238,4 +211,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
|
|||
|
||||
return new NextResponse("Failed to proxy request", { status: 500 });
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
|
|||
)?.value;
|
||||
|
||||
const isProtectedRoute =
|
||||
(path.startsWith("/area-riservata") || path.startsWith("/servizio")) &&
|
||||
(path.startsWith("/area-riservata") ||
|
||||
path.startsWith("/servizio") ||
|
||||
path.startsWith("/api/generate-pdf")) &&
|
||||
!path.startsWith("/servizio/pre-onboard");
|
||||
|
||||
if (isProtectedRoute) {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,17 @@ services:
|
|||
limits:
|
||||
cpus: "0.5"
|
||||
|
||||
browserless:
|
||||
image: browserless/chrome:latest
|
||||
networks:
|
||||
- dokploy-network
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- MAX_CONCURRENT_SESSIONS=5
|
||||
- CONNECTION_TIMEOUT=30000
|
||||
restart: always
|
||||
|
||||
backend:
|
||||
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/${PREFIX:-infoalloggi}-backend:${TAG:-latest}"
|
||||
networks:
|
||||
|
|
@ -161,6 +172,7 @@ services:
|
|||
SKEBBY_PASS: ${SKEBBY_PASS}
|
||||
STORAGE_URL: http://storage:8080
|
||||
STORAGE_TOKEN: ${STORAGE_TOKEN}
|
||||
BROWSER_WS_URL: "ws://browserless:3000"
|
||||
networks:
|
||||
- dokploy-network
|
||||
ports:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue