feat(social): add post image thumbnail to OG social card
Ports the same loadPostImage + thumbBlock pattern from the calendar social route. Reads postImage.src (fallback: image.src), loads via CMS transform API as base64 JPEG, renders next to QR code. Posts without an image are unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -98,7 +98,7 @@ function formatDateLong(iso: string | undefined | null): string {
|
|||||||
|
|
||||||
const cache = new Map<string, { png: Buffer; ts: number }>();
|
const cache = new Map<string, { png: Buffer; ts: number }>();
|
||||||
const CACHE_TTL = 10 * 60_000;
|
const CACHE_TTL = 10 * 60_000;
|
||||||
const CACHE_VERSION = "v1";
|
const CACHE_VERSION = "v2";
|
||||||
|
|
||||||
function parseFormat(v: string | null): Format {
|
function parseFormat(v: string | null): Format {
|
||||||
if (v === "square") return "square";
|
if (v === "square") return "square";
|
||||||
@@ -111,6 +111,24 @@ function parseTheme(v: string | null): Theme {
|
|||||||
return "wald";
|
return "wald";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPostImage(
|
||||||
|
imageSrc: string | undefined,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!imageSrc) return null;
|
||||||
|
try {
|
||||||
|
const cmsBase = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
|
||||||
|
const transformUrl = `${cmsBase}/api/transform?url=${encodeURIComponent(imageSrc)}&w=${w}&h=${h}&fit=inside&format=jpeg&quality=80`;
|
||||||
|
const res = await fetch(transformUrl);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
return `data:image/jpeg;base64,${buf.toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/</g, "‹").replace(/>/g, "›");
|
return s.replace(/</g, "‹").replace(/>/g, "›");
|
||||||
}
|
}
|
||||||
@@ -150,12 +168,24 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
|
|||||||
tags?: unknown;
|
tags?: unknown;
|
||||||
created?: string;
|
created?: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
|
postImage?: { src?: string } | string;
|
||||||
|
image?: { src?: string } | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { w, h } = SIZES[formatParam];
|
const { w, h } = SIZES[formatParam];
|
||||||
const fonts = await loadFonts();
|
const fonts = await loadFonts();
|
||||||
const logoSvg = await loadLogo();
|
const logoSvg = await loadLogo();
|
||||||
|
|
||||||
|
const rawImageSrc = post.postImage ?? post.image;
|
||||||
|
const imageSrc =
|
||||||
|
typeof rawImageSrc === "string"
|
||||||
|
? rawImageSrc
|
||||||
|
: rawImageSrc && typeof rawImageSrc === "object"
|
||||||
|
? (rawImageSrc as { src?: string }).src
|
||||||
|
: undefined;
|
||||||
|
const thumbSize = formatParam === "story" ? 280 : 180;
|
||||||
|
const postThumb = imageSrc ? await loadPostImage(imageSrc, thumbSize * 2, thumbSize * 2) : null;
|
||||||
|
|
||||||
const headline = (post.headline ?? post.linkName ?? "").trim();
|
const headline = (post.headline ?? post.linkName ?? "").trim();
|
||||||
const subheadline = (post.subheadline ?? "").trim();
|
const subheadline = (post.subheadline ?? "").trim();
|
||||||
const excerpt = (post.excerpt ?? "").trim();
|
const excerpt = (post.excerpt ?? "").trim();
|
||||||
@@ -302,13 +332,20 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const qrAlign = verticalStack ? "flex-start" : "center";
|
const qrAlign = verticalStack ? "flex-start" : "center";
|
||||||
|
const thumbMax = qrSize + 28;
|
||||||
|
const thumbBlock = postThumb
|
||||||
|
? `<div style="display:flex;align-items:center;justify-content:center;max-width:${thumbMax}px;max-height:${thumbMax}px;"><img src="${postThumb}" style="max-width:${thumbMax}px;max-height:${thumbMax}px;object-fit:contain;" /></div>`
|
||||||
|
: "";
|
||||||
const qrBlock = `
|
const qrBlock = `
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:${verticalStack ? 16 : 20}px;">
|
||||||
<div style="display:flex;flex-direction:column;align-items:${qrAlign};gap:8px;">
|
<div style="display:flex;flex-direction:column;align-items:${qrAlign};gap:8px;">
|
||||||
<div style="display:flex;background:${palette.qrBoxBg};padding:14px;border-radius:12px;">
|
<div style="display:flex;background:${palette.qrBoxBg};padding:14px;border-radius:12px;">
|
||||||
<img src="${qrDataUrl}" style="width:${qrSize}px;height:${qrSize}px;" />
|
<img src="${qrDataUrl}" style="width:${qrSize}px;height:${qrSize}px;" />
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;font-size:${isStory ? 22 : 16}px;color:${palette.textDim};font-weight:500;letter-spacing:0.05em;">Beitrag lesen</div>
|
<div style="display:flex;font-size:${isStory ? 22 : 16}px;color:${palette.textDim};font-weight:500;letter-spacing:0.05em;">Beitrag lesen</div>
|
||||||
</div>
|
</div>
|
||||||
|
${thumbBlock}
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const footerBlock = `
|
const footerBlock = `
|
||||||
|
|||||||
Reference in New Issue
Block a user