Add banner-led event social previews and refresh Open Graph branding
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
import { createServerCaller } from "@/trpc/server";
|
||||||
|
import { renderSocialImage } from "@/server/social-image";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const alt = "Shared event album on Manyangles";
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
export default async function EventImage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const caller = await createServerCaller();
|
||||||
|
const event = await caller.event.bySlug(slug).catch(() => null);
|
||||||
|
return renderSocialImage(event ?? undefined);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { MapPinIcon } from "lucide-react";
|
import { MapPinIcon } from "lucide-react";
|
||||||
import { EventMap } from "@/components/event-map";
|
import { EventMap } from "@/components/event-map";
|
||||||
import { createServerCaller } from "@/trpc/server";
|
import { createServerCaller } from "@/trpc/server";
|
||||||
@@ -8,6 +9,19 @@ import { galleryIsPublic } from "@/lib/publishing";
|
|||||||
import { GuestGallery } from "./guest-gallery";
|
import { GuestGallery } from "./guest-gallery";
|
||||||
import { GuestUpload } from "./guest-upload";
|
import { GuestUpload } from "./guest-upload";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const caller = await createServerCaller();
|
||||||
|
const event = await caller.event.bySlug(slug).catch(() => null);
|
||||||
|
if (!event) return { title: "Shared album", robots: { index: false, follow: false } };
|
||||||
|
const description = "Share your photos and memories on Manyangles.";
|
||||||
|
return {
|
||||||
|
title: event.title, description,
|
||||||
|
openGraph: { title: event.title, description, type: "website" },
|
||||||
|
twitter: { card: "summary_large_image", title: event.title, description },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function EventPage({
|
export default async function EventPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,60 +1,6 @@
|
|||||||
import { ImageResponse } from "next/og";
|
import { renderSocialImage } from "@/server/social-image";
|
||||||
import { BRAND_NAME, BRAND_TAGLINE } from "@/lib/brand";
|
export const runtime = "nodejs";
|
||||||
|
export const alt = "Manyangles — Every angle. One shared album.";
|
||||||
export const size = { width: 1200, height: 630 };
|
export const size = { width: 1200, height: 630 };
|
||||||
export const contentType = "image/png";
|
export const contentType = "image/png";
|
||||||
|
export default function OpenGraphImage() { return renderSocialImage(); }
|
||||||
export default function OpenGraphImage() {
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#f4f1ea",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 88,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="112"
|
|
||||||
height="112"
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" fill="none" stroke="#3156c8" strokeWidth="2.5" strokeLinecap="round" />
|
|
||||||
<circle cx="21" cy="11" r="2" fill="#3156c8" />
|
|
||||||
<path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" fill="none" stroke="#3156c8" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 36,
|
|
||||||
fontSize: 92,
|
|
||||||
lineHeight: 1,
|
|
||||||
color: "#18181b",
|
|
||||||
fontFamily: "Georgia, serif",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: -5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{BRAND_NAME}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 18,
|
|
||||||
fontSize: 28,
|
|
||||||
maxWidth: 760,
|
|
||||||
color: "#71717a",
|
|
||||||
lineHeight: 1.25,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{BRAND_TAGLINE}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { BRAND_NAME, BRAND_TAGLINE } from "@/lib/brand";
|
||||||
|
|
||||||
|
export async function renderSocialImage(event?: { title: string; bannerUrl: string | null }) {
|
||||||
|
const [funnel, geologica, banner] = await Promise.all([
|
||||||
|
readFile(path.join(process.cwd(), "public/fonts/funnel-display-semibold.ttf")),
|
||||||
|
readFile(path.join(process.cwd(), "public/fonts/geologica-semibold.ttf")),
|
||||||
|
event?.bannerUrl ? loadBanner(event.bannerUrl) : null,
|
||||||
|
]);
|
||||||
|
const title = event?.title ?? BRAND_TAGLINE;
|
||||||
|
return new ImageResponse(
|
||||||
|
<div style={{ width: "100%", height: "100%", display: "flex", background: "#263854", color: "#ffffff", position: "relative" }}>
|
||||||
|
{banner ? <img alt="" src={banner} width={1200} height={630} style={{ position: "absolute", top: 0, left: 0, objectFit: "cover" }} /> : null}
|
||||||
|
<div style={{ position: "absolute", top: 0, left: 0, width: 1200, height: 630, background: banner ? "linear-gradient(to bottom, rgba(12,18,29,0) 15%, rgba(12,18,29,0.55) 55%, rgba(12,18,29,0.96) 100%)" : "linear-gradient(140deg, #405777 0%, #172335 100%)" }} />
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", justifyContent: "flex-end", padding: "52px 64px", width: "100%", height: "100%" }}>
|
||||||
|
<div style={{ display: "flex", fontFamily: "Funnel", fontSize: title.length > 65 ? 48 : 64, lineHeight: 1.08, maxWidth: 1060, marginBottom: 30 }}>{title.length > 140 ? `${title.slice(0, 137)}…` : title}</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||||
|
<svg width="42" height="42" viewBox="0 0 32 32" fill="none">
|
||||||
|
<path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="white" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<circle cx="21" cy="11" r="2" fill="white" />
|
||||||
|
<path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontFamily: "Geologica", fontSize: 32 }}>{BRAND_NAME}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
{ width: 1200, height: 630, fonts: [
|
||||||
|
{ name: "Funnel", data: funnel, weight: 600 },
|
||||||
|
{ name: "Geologica", data: geologica, weight: 600 },
|
||||||
|
], headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBanner(url: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(5000) });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
// Use the optimized display variant, never the uploaded original.
|
||||||
|
const png = await sharp(Buffer.from(await response.arrayBuffer())).resize(1200, 630, { fit: "cover" }).png().toBuffer();
|
||||||
|
return `data:image/png;base64,${png.toString("base64")}`;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user