import { defineOG } from "$hatk"; import type { GrainActorProfile, Photo } from "$hatk"; import { allFonts } from "./fonts.ts"; import { calculateCollageLayout } from "./collage.ts"; import { resolveHandle } from "../helpers/resolveHandle.ts"; import { unlabeledFilter } from "../labels/_hidden.ts"; export default defineOG("/og/profile/:actor/gallery/:rkey", async (ctx) => { const { db, params, fetchImage, lookup, blobUrl } = ctx; const { rkey } = params; // The page links itself by handle, so the crawler arrives with one. const did = (await resolveHandle(db, params.actor)) ?? params.actor; const galleryUri = `at://${did}/social.grain.gallery/${rkey}`; // Fetch gallery record. A gallery in a space shares its URI with nothing // public, but its row is indexed all the same, and this card goes to anyone. const rows = (await db.query( `SELECT uri, did, cid, title, description FROM "social.grain.gallery" WHERE uri = $1 AND space IS NULL`, [galleryUri], )) as Array<{ uri: string; did: string; cid: string; title: string; description: string | null; }>; const gallery = rows[0]; if (!gallery) { return { element: { type: "div", props: { style: { display: "flex", width: "100%", height: "100%", background: "#ffffff", color: "#171717", alignItems: "center", justifyContent: "center", }, children: "Gallery not found", }, }, }; } // Fetch author profile const profiles = await lookup("social.grain.actor.profile", "did", [did]); const author = profiles.get(did); const avatarRef = author ? blobUrl(did, author.value.avatar) : null; // A link preview has no way to blur or warn, so a gallery carrying any // label, from a labeler or its author, goes out as a title card with no // photos. Same for an author whose account is labeled. const labeled = (await db.query( `SELECT 1 WHERE NOT (${unlabeledFilter("$1")} AND ${unlabeledFilter("$2")}) OR EXISTS (SELECT 1 FROM "social.grain.gallery__labels_self_labels" WHERE parent_uri = $1)`, [galleryUri, did], )) as unknown[]; // Fetch gallery photos (up to 6) const itemRows = labeled.length ? [] : ((await db.query( `SELECT item FROM "social.grain.gallery.item" WHERE gallery = $1 AND space IS NULL ORDER BY position ASC LIMIT 6`, [galleryUri], )) as Array<{ item: string }>); const photoUris = itemRows.map((r) => r.item); const photoRecords = photoUris.length > 0 ? await ctx.getRecords("social.grain.photo", photoUris) : new Map(); // Fetch images as base64. A lone photo is drawn across the whole frame, so it // needs more pixels than a thumbnail has. const photoSize = photoUris.length === 1 ? "feed_fullsize" : "feed_thumbnail"; const imageData: Array<{ url: string; aspectRatio: number }> = []; for (const uri of photoUris) { const rec = photoRecords.get(uri); if (!rec) continue; const url = blobUrl(rec.did, rec.value.photo, photoSize); if (!url) continue; const dataUrl = await fetchImage(url); if (!dataUrl) continue; const ar = rec.value.aspectRatio ? rec.value.aspectRatio.width / rec.value.aspectRatio.height : 4 / 3; imageData.push({ url: dataUrl, aspectRatio: ar }); } const avatarDataUrl = avatarRef ? await fetchImage(avatarRef) : null; // Layout constants — matches grain-next og-server const width = 1200; const height = 630; const titleBarHeight = 100; const collageHeight = height - titleBarHeight; const gap = 12; const padding = 24; // Calculate collage layout const placements = calculateCollageLayout( imageData, width - padding * 2, collageHeight - padding * 2, gap, ); return { element: { type: "div", props: { style: { display: "flex", flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#ffffff", position: "relative", }, children: [ // Collage area — absolute positioned images { type: "div", props: { style: { display: "flex", width: `${width}px`, height: `${collageHeight}px`, position: "relative", padding: `${padding}px`, }, children: placements.map( (p: { item: { url: string }; x: number; y: number; width: number; height: number; }) => ({ type: "img", props: { src: p.item.url, style: { position: "absolute", left: `${p.x + padding}px`, top: `${p.y + padding}px`, width: `${p.width}px`, height: `${p.height}px`, objectFit: "cover", }, }, }), ), }, }, // Title bar { type: "div", props: { style: { position: "absolute", bottom: "0", left: "0", right: "0", height: `${titleBarHeight}px`, background: "#ffffff", display: "flex", alignItems: "flex-end", padding: "0 24px 20px 24px", }, children: [ { type: "div", props: { style: { display: "flex", alignItems: "center", gap: "16px", width: "100%", }, children: [ // Avatar ...(avatarDataUrl ? [ { type: "img", props: { src: avatarDataUrl, style: { width: "56px", height: "56px", borderRadius: "28px", objectFit: "cover" as const, }, }, }, ] : []), // Title and handle { type: "div", props: { style: { display: "flex", flexDirection: "column", gap: "4px", flex: "1", overflow: "hidden", }, children: [ { type: "div", props: { children: gallery.title, style: { fontSize: 36, fontWeight: 700, color: "#171717", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" as const, }, }, }, { type: "div", props: { children: `@${author?.handle || did.slice(0, 24)}`, style: { fontSize: 20, color: "#525252", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" as const, }, }, }, ], }, }, // Grain branding { type: "div", props: { children: "grain", style: { fontSize: 32, fontFamily: "Syne", fontWeight: 800, color: "#171717", letterSpacing: "-0.02em", }, }, }, ], }, }, ], }, }, ], }, }, options: { fonts: allFonts() }, meta: { title: `${gallery.title} by @${author?.handle || did.slice(0, 24)} — Grain`, description: gallery.description || `Photo gallery on Grain`, }, }; });