From dddf02de49ea97576189bce09edb3543354dc7b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 13:46:02 +0000 Subject: [PATCH] Polish mashup cards, OG share images, and first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point OG/Twitter and share at the existing 1200×686 JPEG, size the detail hero to the image, seed the homepage locker on the server, redirect dead uniqueness-suffix slugs, and top-weight 7:4 / square card crops. No watermark. Co-authored-by: Chinmay Kabi --- scripts/backfill-generation-variants.ts | 32 +++++------ src/app/g/[slug]/generation-detail-client.tsx | 4 +- src/app/g/[slug]/page.tsx | 30 +++++++--- .../g/[slug]/published-generation-hero.tsx | 41 ++++++------- src/app/page.tsx | 41 ++++--------- src/components/feed-tile.tsx | 2 +- src/components/generation-card.tsx | 16 ++++-- src/components/generation-result-view.tsx | 4 +- src/hooks/use-generation-status.ts | 1 + src/lib/__tests__/screen-type.test.ts | 4 +- src/lib/__tests__/slug-alias.test.ts | 29 ++++++++++ src/lib/feed-public-filters.ts | 1 + src/lib/generation-media-url.ts | 3 + .../generation/__tests__/card-crop.test.ts | 19 +++++++ .../__tests__/status-response.test.ts | 4 ++ src/lib/generation/ensure-variants.ts | 26 +++------ src/lib/generation/execute-image.ts | 27 +++++---- src/lib/generation/render-display-variants.ts | 57 +++++++++++++++++++ src/lib/generation/status-response.ts | 1 + src/lib/public-generation.ts | 43 ++++++++++++++ src/lib/screen-type.ts | 6 +- src/lib/slug.ts | 14 +++++ .../__tests__/format-og-description.test.ts | 14 +++++ src/lib/ui/format.ts | 7 +++ src/lib/ui/types.ts | 1 + 25 files changed, 305 insertions(+), 122 deletions(-) create mode 100644 src/lib/__tests__/slug-alias.test.ts create mode 100644 src/lib/generation/__tests__/card-crop.test.ts create mode 100644 src/lib/generation/render-display-variants.ts create mode 100644 src/lib/ui/__tests__/format-og-description.test.ts diff --git a/scripts/backfill-generation-variants.ts b/scripts/backfill-generation-variants.ts index 5f62a8a..8a9ccb8 100644 --- a/scripts/backfill-generation-variants.ts +++ b/scripts/backfill-generation-variants.ts @@ -2,9 +2,9 @@ * One-off backfill: generate publish-time image variants for all published generations. * * This produces and uploads: - * - `${image_path}.card.webp` (560w) + * - `${image_path}.card.webp` (560×320 landscape / 560×560 portrait, top-weighted) * - `${image_path}.detail.webp` (1280w) - * - `${image_path}.og.jpg` (1200w) + * - `${image_path}.og.jpg` (1200w JPEG) * * Usage: * yarn tsx scripts/backfill-generation-variants.ts @@ -27,6 +27,10 @@ config({ path: ".env.local" }); import sharp from "sharp"; import { getGenerationImagesBucket } from "@/lib/env-server"; +import { + contentTypeForDisplayVariant, + renderDisplayVariant, +} from "@/lib/generation/render-display-variants"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; type GenerationRow = { @@ -109,22 +113,14 @@ async function ensureVariantsForPath(opts: { const input = Buffer.from(await blob.arrayBuffer()); const basePipeline = sharp(input).rotate(); + const meta = await basePipeline.metadata(); + const imageSize = { width: meta.width ?? 1, height: meta.height ?? 1 }; - const card = await basePipeline - .clone() - .resize({ width: 560, withoutEnlargement: true }) - .webp({ quality: 82 }) - .toBuffer(); - const detail = await basePipeline - .clone() - .resize({ width: 1280, withoutEnlargement: true }) - .webp({ quality: 82 }) - .toBuffer(); - const og = await basePipeline - .clone() - .resize({ width: 1200, withoutEnlargement: true }) - .jpeg({ quality: 80 }) - .toBuffer(); + const [card, detail, og] = await Promise.all([ + renderDisplayVariant(basePipeline, "card", imageSize), + renderDisplayVariant(basePipeline, "detail", imageSize), + renderDisplayVariant(basePipeline, "og", imageSize), + ]); const byVariant = { card, @@ -143,7 +139,7 @@ async function ensureVariantsForPath(opts: { .from(bucket) .upload(v.path, payload, { upsert: true, - contentType: v.contentType, + contentType: contentTypeForDisplayVariant(key), }); if (upErr) throw new Error(`upload failed: ${upErr.message}`); } diff --git a/src/app/g/[slug]/generation-detail-client.tsx b/src/app/g/[slug]/generation-detail-client.tsx index d1bf2fd..8b882b6 100644 --- a/src/app/g/[slug]/generation-detail-client.tsx +++ b/src/app/g/[slug]/generation-detail-client.tsx @@ -51,6 +51,7 @@ export function GenerationDetailClient({ target: initial.target, errorMessage: initial.errorMessage, imageUrl: initial.imageUrl, + ogImageUrl: initial.ogImageUrl, imageDownloadUrl: initial.imageDownloadUrl, } : null, @@ -65,6 +66,7 @@ export function GenerationDetailClient({ status: statusData.status, errorMessage: statusData.errorMessage, imageUrl: statusData.imageUrl ?? initial.imageUrl, + ogImageUrl: statusData.ogImageUrl ?? initial.ogImageUrl, imageDownloadUrl: statusData.imageDownloadUrl ?? initial.imageDownloadUrl, } : initial; @@ -216,7 +218,7 @@ export function GenerationDetailClient({ slug={gen.slug} builder={gen.builder} target={gen.target} - imageUrl={gen.imageUrl} + imageUrl={gen.ogImageUrl ?? gen.imageUrl} imageDownloadUrl={gen.imageDownloadUrl ?? null} /> diff --git a/src/app/g/[slug]/page.tsx b/src/app/g/[slug]/page.tsx index 39e3108..a65c446 100644 --- a/src/app/g/[slug]/page.tsx +++ b/src/app/g/[slug]/page.tsx @@ -1,10 +1,14 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect } from "next/navigation"; import { preload } from "react-dom"; import { fetchFeedServer } from "@/lib/fetch-feed"; -import { getGenerationBySlugCached } from "@/lib/public-generation"; -import { formatResultTitle } from "@/lib/ui/format"; +import { OG_IMAGE_PIXEL_SIZE } from "@/lib/generation-media-url"; +import { + findPublishedSlugAlias, + getGenerationBySlugCached, +} from "@/lib/public-generation"; +import { formatOgDescription, formatResultTitle } from "@/lib/ui/format"; import type { FeedItem } from "@/lib/ui/types"; import { GenerationDetailClient } from "./generation-detail-client"; @@ -31,10 +35,18 @@ export async function generateMetadata({ params }: Props): Promise { } const title = formatResultTitle(gen.builder, gen.target); - const description = `A cursed AI UI screenshot from the ifXBuiltY evidence locker: ${title}.`; + const description = formatOgDescription(gen.builder, gen.target); const canonicalPath = `/g/${encodeURIComponent(gen.slug)}`; - const ogImages = gen.imageUrl - ? [{ url: gen.imageUrl, alt: title }] + const ogImages = gen.ogImageUrl + ? [ + { + url: gen.ogImageUrl, + width: OG_IMAGE_PIXEL_SIZE.width, + height: OG_IMAGE_PIXEL_SIZE.height, + alt: title, + type: "image/jpeg" as const, + }, + ] : undefined; return { @@ -60,7 +72,11 @@ export async function generateMetadata({ params }: Props): Promise { export default async function GenerationDetailPage({ params }: Props) { const { slug } = await params; const gen = await getGenerationBySlugCached(slug); - if (!gen) notFound(); + if (!gen) { + const alias = await findPublishedSlugAlias(slug); + if (alias) permanentRedirect(`/g/${encodeURIComponent(alias)}`); + notFound(); + } if (gen.status === "completed" && gen.imageUrl) { preload(gen.imageUrl, { as: "image" }); diff --git a/src/app/g/[slug]/published-generation-hero.tsx b/src/app/g/[slug]/published-generation-hero.tsx index 6760c1a..3b355d1 100644 --- a/src/app/g/[slug]/published-generation-hero.tsx +++ b/src/app/g/[slug]/published-generation-hero.tsx @@ -11,10 +11,9 @@ type Props = { variant?: "default" | "paper"; }; -/** Mobile 9:16 — fixed aspect box to avoid viewport-driven CLS. */ -const MOBILE_HERO_BOX = "w-full max-w-[460px] aspect-[9/16]"; -/** Desktop 16:9 — fixed aspect box, constrained by viewport height. */ -const DESKTOP_HERO_BOX = "w-full aspect-video max-h-[min(58svh,540px)]"; +/** Frame follows the image; cap height so 2–3 UI labels stay readable. */ +const HERO_IMG = + "mx-auto block h-auto w-full max-h-[min(82svh,920px)] object-contain object-top"; export function PublishedGenerationHero({ imageUrl, @@ -36,25 +35,21 @@ export function PublishedGenerationHero({ isMobile ? "px-3 py-4 sm:px-5 sm:py-5" : "px-2 py-3 sm:px-4 sm:py-4", )} > -
- - {/* eslint-disable-next-line @next/next/no-img-element -- rmiz measures native ; Next/Image breaks zoom geometry */} - {title} - -
+ + {/* eslint-disable-next-line @next/next/no-img-element -- rmiz measures native ; Next/Image breaks zoom geometry */} + {title} + {variant === "paper" ? (

diff --git a/src/app/page.tsx b/src/app/page.tsx index 7a9a9a2..cff8694 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; import { HomepageHero } from "@/components/homepage-hero"; import { HomepageFeed } from "@/components/homepage-feed"; @@ -23,24 +22,15 @@ export const revalidate = 120; const HERO_THUMBNAIL_LIMIT = 8; -async function HomepageFeedSection() { - const [feed, filterOptions] = await Promise.all([ - fetchFeedServer({ sort: "newest", limit: 24 }), - getFeedHierarchicalFilterOptions(), - ]); - - return ; -} - export default async function HomePage() { - // Keep the above-the-fold hero unblocked by heavier feed/filter work. - const [featuredGenerations, heroFeed, totalPublished] = await Promise.all([ - getHomepageFeaturedGenerations(), - fetchFeedServer({ sort: "newest", limit: 12 }), - getTotalPublishedCount(), - ]); + const [featuredGenerations, feed, filterOptions, totalPublished] = + await Promise.all([ + getHomepageFeaturedGenerations(), + fetchFeedServer({ sort: "newest", limit: 24 }), + getFeedHierarchicalFilterOptions(), + getTotalPublishedCount(), + ]); - // Build hero thumbnails — use featured first, supplement with feed items if needed const featuredThumbs = featuredGenerations.map((g) => ({ id: g.id, slug: g.slug, @@ -49,9 +39,8 @@ export default async function HomePage() { imageUrl: g.imageUrl, })); - // Fill remaining slots from feed items not already in featured const featuredIds = new Set(featuredThumbs.map((t) => t.id)); - const supplementThumbs = heroFeed.items + const supplementThumbs = feed.items .filter((item) => !featuredIds.has(item.id) && item.imageUrl) .slice(0, HERO_THUMBNAIL_LIMIT - featuredThumbs.length) .map((item) => ({ @@ -71,20 +60,10 @@ export default async function HomePage() {

- -

- Loading the evidence locker… -

-
- } - > - - + ); } diff --git a/src/components/feed-tile.tsx b/src/components/feed-tile.tsx index 9263a1b..5b0925b 100644 --- a/src/components/feed-tile.tsx +++ b/src/components/feed-tile.tsx @@ -47,7 +47,7 @@ export function FeedTile({ item, index, offsetClass }: Props) { alt="" fill sizes={FEED_TILE_PREVIEW_SIZES} - className="object-cover" + className="object-cover object-top" loading="lazy" unoptimized /> diff --git a/src/components/generation-card.tsx b/src/components/generation-card.tsx index 1130360..ce2d2bb 100644 --- a/src/components/generation-card.tsx +++ b/src/components/generation-card.tsx @@ -87,7 +87,7 @@ export function GenerationCard({ alt={label} fill sizes={GENERATION_CARD_IMAGE_SIZES} - className="object-cover transition-transform duration-300 ease-out motion-safe:group-hover:scale-[1.015]" + className="object-cover object-top transition-transform duration-300 ease-out motion-safe:group-hover:scale-[1.015]" loading={imagePriority ? "eager" : "lazy"} fetchPriority={imagePriority ? "high" : undefined} unoptimized @@ -145,7 +145,11 @@ export function GenerationCard({ slug={item.slug} builder={item.builder} target={item.target} - imageUrl={item.imageUrl} + imageUrl={ + item.imagePath + ? generationImageUrl(item.imagePath, "og") + : item.imageUrl + } /> )} @@ -189,7 +193,7 @@ export function GenerationCard({ alt={label} fill sizes={GENERATION_CARD_IMAGE_SIZES} - className="object-contain" + className="object-cover object-top" loading={imagePriority ? "eager" : "lazy"} fetchPriority={imagePriority ? "high" : undefined} unoptimized @@ -232,7 +236,11 @@ export function GenerationCard({ slug={item.slug} builder={item.builder} target={item.target} - imageUrl={item.imageUrl} + imageUrl={ + item.imagePath + ? generationImageUrl(item.imagePath, "og") + : item.imageUrl + } /> )} diff --git a/src/components/generation-result-view.tsx b/src/components/generation-result-view.tsx index a148671..51c13cf 100644 --- a/src/components/generation-result-view.tsx +++ b/src/components/generation-result-view.tsx @@ -244,7 +244,7 @@ export function GenerationResultView({ slug={result.slug} builder={result.builder} target={result.target} - imageUrl={result.imageUrl} + imageUrl={result.ogImageUrl ?? result.imageUrl} /> ); @@ -348,7 +348,7 @@ export function GenerationResultView({ slug={result.slug} builder={result.builder} target={result.target} - imageUrl={result.imageUrl} + imageUrl={result.ogImageUrl ?? result.imageUrl} /> ); diff --git a/src/hooks/use-generation-status.ts b/src/hooks/use-generation-status.ts index 59d6bba..b2a7b05 100644 --- a/src/hooks/use-generation-status.ts +++ b/src/hooks/use-generation-status.ts @@ -13,6 +13,7 @@ export type GenerationStatusPayload = { target: string; errorMessage: string | null; imageUrl: string | null; + ogImageUrl: string | null; imageDownloadUrl: string | null; }; diff --git a/src/lib/__tests__/screen-type.test.ts b/src/lib/__tests__/screen-type.test.ts index e750cf0..1bb9e43 100644 --- a/src/lib/__tests__/screen-type.test.ts +++ b/src/lib/__tests__/screen-type.test.ts @@ -26,8 +26,8 @@ describe("screen-type", () => { }); it("provides display aspect classes", () => { - expect(getDisplayAspectClass("mobile")).toBe("aspect-[9/16]"); - expect(getDisplayAspectClass("desktop web")).toBe("aspect-video"); + expect(getDisplayAspectClass("mobile")).toBe("aspect-square"); + expect(getDisplayAspectClass("desktop web")).toBe("aspect-[7/4]"); }); it("formats badges and labels", () => { diff --git a/src/lib/__tests__/slug-alias.test.ts b/src/lib/__tests__/slug-alias.test.ts new file mode 100644 index 0000000..109059e --- /dev/null +++ b/src/lib/__tests__/slug-alias.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { isNumericSlugAlias, makeGenerationSlugSnippet } from "@/lib/slug"; + +describe("isNumericSlugAlias", () => { + it("matches the live uniqueness suffix, not unrelated slugs", () => { + expect( + isNumericSlugAlias("microsoft-teams-tinder", "microsoft-teams-tinder-1"), + ).toBe(true); + expect( + isNumericSlugAlias("microsoft-teams-tinder", "microsoft-teams-tinder"), + ).toBe(true); + expect( + isNumericSlugAlias("microsoft-teams-tinder", "microsoft-teams-tinder-box"), + ).toBe(false); + expect( + isNumericSlugAlias("microsoft-teams-tinder", "spotify-tinder"), + ).toBe(false); + }); + + it("builds the colliding base slug from builder and target", () => { + expect( + makeGenerationSlugSnippet({ + builder: "Microsoft Teams", + target: "Tinder", + }), + ).toBe("microsoft-teams-tinder"); + }); +}); diff --git a/src/lib/feed-public-filters.ts b/src/lib/feed-public-filters.ts index 9c17c12..a93da52 100644 --- a/src/lib/feed-public-filters.ts +++ b/src/lib/feed-public-filters.ts @@ -33,6 +33,7 @@ export type PublicFeedQueryable = PublicFeedFilterQuery & { options: { ascending: boolean }, ) => PublicFeedQueryable; gte: (column: string, value: string) => PromiseLike; + like: (column: string, pattern: string) => PublicFeedQueryable; limit: (count: number) => PublicFeedQueryable; } & PromiseLike; diff --git a/src/lib/generation-media-url.ts b/src/lib/generation-media-url.ts index 9366e43..a1f84f0 100644 --- a/src/lib/generation-media-url.ts +++ b/src/lib/generation-media-url.ts @@ -1,5 +1,8 @@ export type GenerationMediaVariant = "card" | "detail" | "full" | "og"; +/** Desktop `.og.jpg` produced at generation time (1200×686). */ +export const OG_IMAGE_PIXEL_SIZE = { width: 1200, height: 686 } as const; + const DEFAULT_GENERATION_IMAGES_BUCKET = "generation-images"; /** diff --git a/src/lib/generation/__tests__/card-crop.test.ts b/src/lib/generation/__tests__/card-crop.test.ts new file mode 100644 index 0000000..6fba909 --- /dev/null +++ b/src/lib/generation/__tests__/card-crop.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { + CARD_CROP_DESKTOP, + CARD_CROP_MOBILE, + cardCropForAspect, +} from "@/lib/generation/render-display-variants"; + +describe("cardCropForAspect", () => { + it("uses 560×320 (7:4) for landscape desktop gens", () => { + expect(cardCropForAspect(1792, 1024)).toEqual(CARD_CROP_DESKTOP); + expect(CARD_CROP_DESKTOP.width / CARD_CROP_DESKTOP.height).toBeCloseTo(7 / 4); + }); + + it("uses 560×560 and top-weighted square for portrait or square gens", () => { + expect(cardCropForAspect(1024, 1792)).toEqual(CARD_CROP_MOBILE); + expect(cardCropForAspect(1024, 1024)).toEqual(CARD_CROP_MOBILE); + }); +}); diff --git a/src/lib/generation/__tests__/status-response.test.ts b/src/lib/generation/__tests__/status-response.test.ts index 77937df..56a5285 100644 --- a/src/lib/generation/__tests__/status-response.test.ts +++ b/src/lib/generation/__tests__/status-response.test.ts @@ -21,6 +21,9 @@ describe("toGenerationStatusResponse", () => { expect(completed.imageUrl).toBe( "https://proj.supabase.co/storage/v1/object/public/generation-images/user/duolingo-built-tinder.png.detail.webp", ); + expect(completed.ogImageUrl).toBe( + "https://proj.supabase.co/storage/v1/object/public/generation-images/user/duolingo-built-tinder.png.og.jpg", + ); expect(completed.imageDownloadUrl).toBe( "https://proj.supabase.co/storage/v1/object/public/generation-images/user/duolingo-built-tinder.png", ); @@ -35,6 +38,7 @@ describe("toGenerationStatusResponse", () => { image_path: "user/queued-job.png", }); expect(queued.imageUrl).toBeNull(); + expect(queued.ogImageUrl).toBeNull(); const completedNoMedia = toGenerationStatusResponse({ id: 4, diff --git a/src/lib/generation/ensure-variants.ts b/src/lib/generation/ensure-variants.ts index 7b278c3..78cea6a 100644 --- a/src/lib/generation/ensure-variants.ts +++ b/src/lib/generation/ensure-variants.ts @@ -4,12 +4,15 @@ import sharp from "sharp"; import { getGenerationImagesBucket } from "@/lib/env-server"; import { generationVariantObjectPath } from "@/lib/generation-media-url"; +import { + contentTypeForDisplayVariant, + renderDisplayVariant, +} from "@/lib/generation/render-display-variants"; import type { createSupabaseServiceClient } from "@/lib/supabase/service"; type ServiceClient = ReturnType; const DISPLAY_VARIANTS = ["card", "detail", "og"] as const; -type DisplayVariant = (typeof DISPLAY_VARIANTS)[number]; function basename(path: string): string { const idx = path.lastIndexOf("/"); @@ -21,21 +24,6 @@ function dirname(path: string): string { return idx <= 0 ? "" : path.slice(0, idx); } -function renderVariant(base: sharp.Sharp, variant: DisplayVariant): Promise { - switch (variant) { - case "card": - return base.clone().resize({ width: 560, withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(); - case "detail": - return base.clone().resize({ width: 1280, withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(); - case "og": - return base.clone().resize({ width: 1200, withoutEnlargement: true }).jpeg({ quality: 80 }).toBuffer(); - } -} - -function contentTypeFor(variant: DisplayVariant): string { - return variant === "og" ? "image/jpeg" : "image/webp"; -} - /** * Ensure the public display variants (card/detail/og) exist for an original image * object. Only the missing ones are generated. All storage I/O uses the service @@ -69,13 +57,15 @@ export async function ensureGenerationVariants(opts: { } const base = sharp(Buffer.from(await blob.arrayBuffer())).rotate(); + const meta = await base.metadata(); + const imageSize = { width: meta.width ?? 1, height: meta.height ?? 1 }; const results = await Promise.all( missing.map(async ({ variant, path }) => { - const bytes = await renderVariant(base, variant); + const bytes = await renderDisplayVariant(base, variant, imageSize); return service.storage.from(bucket).upload(path, bytes, { upsert: true, - contentType: contentTypeFor(variant), + contentType: contentTypeForDisplayVariant(variant), }); }), ); diff --git a/src/lib/generation/execute-image.ts b/src/lib/generation/execute-image.ts index 24dc1ae..6cda068 100644 --- a/src/lib/generation/execute-image.ts +++ b/src/lib/generation/execute-image.ts @@ -7,6 +7,10 @@ import { getCompanyScreenshots, } from "@/data/company-profiles"; import { generationVariantObjectPath } from "@/lib/generation-media-url"; +import { + contentTypeForDisplayVariant, + renderDisplayVariant, +} from "@/lib/generation/render-display-variants"; import { getDodoClient } from "@/lib/dodo/client"; import { getGenerationImageSize, @@ -122,18 +126,17 @@ async function uploadDisplayVariants(args: { imageBytes: Buffer; }): Promise { const base = sharp(args.imageBytes).rotate(); - - const [card, detail, og] = await Promise.all([ - base.clone().resize({ width: 560, withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(), - base.clone().resize({ width: 1280, withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(), - base.clone().resize({ width: 1200, withoutEnlargement: true }).jpeg({ quality: 80 }).toBuffer(), - ]); - - const variants: Array<{ bytes: Buffer; variant: "card" | "detail" | "og"; contentType: string }> = [ - { bytes: card, variant: "card", contentType: "image/webp" }, - { bytes: detail, variant: "detail", contentType: "image/webp" }, - { bytes: og, variant: "og", contentType: "image/jpeg" }, - ]; + const meta = await base.metadata(); + const imageSize = { width: meta.width ?? 1, height: meta.height ?? 1 }; + + const [card, detail, og] = await Promise.all( + (["card", "detail", "og"] as const).map(async (variant) => ({ + bytes: await renderDisplayVariant(base, variant, imageSize), + variant, + contentType: contentTypeForDisplayVariant(variant), + })), + ); + const variants = [card, detail, og]; const results = await Promise.all( variants.map(({ bytes, variant, contentType }) => diff --git a/src/lib/generation/render-display-variants.ts b/src/lib/generation/render-display-variants.ts new file mode 100644 index 0000000..5986730 --- /dev/null +++ b/src/lib/generation/render-display-variants.ts @@ -0,0 +1,57 @@ +import type { Sharp } from "sharp"; + +import type { GenerationMediaVariant } from "@/lib/generation-media-url"; + +export const CARD_CROP_DESKTOP = { width: 560, height: 320 } as const; +export const CARD_CROP_MOBILE = { width: 560, height: 560 } as const; + +type DisplayVariant = Exclude; + +export function cardCropForAspect( + width: number, + height: number, +): typeof CARD_CROP_DESKTOP | typeof CARD_CROP_MOBILE { + return height >= width ? CARD_CROP_MOBILE : CARD_CROP_DESKTOP; +} + +export function contentTypeForDisplayVariant(variant: DisplayVariant): string { + return variant === "og" ? "image/jpeg" : "image/webp"; +} + +export async function renderDisplayVariant( + base: Sharp, + variant: DisplayVariant, + imageSize: { width: number; height: number }, +): Promise { + switch (variant) { + case "card": { + const crop = cardCropForAspect(imageSize.width, imageSize.height); + return base + .clone() + .resize({ + width: crop.width, + height: crop.height, + fit: "cover", + position: "top", + }) + .webp({ quality: 82 }) + .toBuffer(); + } + case "detail": + return base + .clone() + .resize({ width: 1280, withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer(); + case "og": + return base + .clone() + .resize({ width: 1200, withoutEnlargement: true }) + .jpeg({ quality: 80 }) + .toBuffer(); + default: { + const _exhaustive: never = variant; + return _exhaustive; + } + } +} diff --git a/src/lib/generation/status-response.ts b/src/lib/generation/status-response.ts index a149a28..2a052e1 100644 --- a/src/lib/generation/status-response.ts +++ b/src/lib/generation/status-response.ts @@ -30,6 +30,7 @@ export function toGenerationStatusResponse(row: GenerationStatusRow) { target: row.target, errorMessage: row.error_message, imageUrl: hasImage && path ? generationImageUrl(path, "detail") : null, + ogImageUrl: hasImage && path ? generationImageUrl(path, "og") : null, imageDownloadUrl: hasImage && path ? generationImageUrl(path, "full") : null, }; } diff --git a/src/lib/public-generation.ts b/src/lib/public-generation.ts index e2d7649..8048521 100644 --- a/src/lib/public-generation.ts +++ b/src/lib/public-generation.ts @@ -1,7 +1,10 @@ import { getAnonSessionId } from "@/lib/anon-session"; +import { createPublicFeedClient } from "@/lib/feed-client"; +import { publicGenerationsQuery } from "@/lib/feed-public-filters"; import { generationImageUrl } from "@/lib/generation-media-url"; import type { GenerationStatus } from "@/lib/generation/types"; import { isGenerationStatus } from "@/lib/generation/types"; +import { isNumericSlugAlias } from "@/lib/slug"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServiceClient } from "@/lib/supabase/service"; import { cache } from "react"; @@ -24,6 +27,8 @@ export type PublicGeneration = { } | null; /** Optimized WebP for on-page display (~1280px wide max). */ imageUrl: string | null; + /** JPEG OG/Twitter share image (1200×686 for landscape gens). */ + ogImageUrl: string | null; /** Original bytes from storage (large download / save-as). */ imageDownloadUrl: string | null; upvoteCount: number; @@ -165,6 +170,7 @@ function mapRow( ? data.image_path?.trim() : null; const imageUrl = path ? generationImageUrl(path, "detail") : null; + const ogImageUrl = path ? generationImageUrl(path, "og") : null; const imageDownloadUrl = path ? generationImageUrl(path, "full") : null; return { @@ -180,6 +186,7 @@ function mapRow( errorMessage: data.error_message, creator, imageUrl, + ogImageUrl, imageDownloadUrl, upvoteCount: data.upvote_count, downvoteCount: data.downvote_count, @@ -252,6 +259,42 @@ export async function getGenerationBySlug( */ export const getGenerationBySlugCached = cache(getGenerationBySlug); +/** + * If `/g/{slug}` is not a public generation, find a published numeric alias + * (`{slug}-1`) so dead uniqueness-suffix paths can redirect. + */ +export async function findPublishedSlugAlias( + requested: string, +): Promise { + const trimmed = requested.trim(); + if (!trimmed) return null; + + const supabase = createPublicFeedClient(); + if (!supabase) return null; + + try { + const { data, error } = await publicGenerationsQuery(supabase, "slug") + .like("slug", `${trimmed}-%`) + .limit(50); + + if (error || !data) return null; + + const matches = (data as { slug: unknown }[]) + .map((row) => row.slug) + .filter((slug): slug is string => typeof slug === "string") + .filter((slug) => isNumericSlugAlias(trimmed, slug) && slug !== trimmed) + .sort((a, b) => { + const aN = Number(a.slice(trimmed.length + 1)); + const bN = Number(b.slice(trimmed.length + 1)); + return aN - bN; + }); + + return matches[0] ?? null; + } catch { + return null; + } +} + /** @deprecated Use getGenerationBySlug */ export async function getPublishedGenerationBySlug( slug: string, diff --git a/src/lib/screen-type.ts b/src/lib/screen-type.ts index 625498c..90b1d77 100644 --- a/src/lib/screen-type.ts +++ b/src/lib/screen-type.ts @@ -79,11 +79,11 @@ export function getGenerationImageSize( } } -/** Tailwind aspect ratio class for feed/detail frames. */ +/** Tailwind aspect ratio class for feed cards (mobile 1:1, desktop 7:4). */ export function getDisplayAspectClass(mode: string): string { return normalizeRenderMode(mode) === "mobile" - ? "aspect-[9/16]" - : "aspect-video"; + ? "aspect-square" + : "aspect-[7/4]"; } export function formatScreenBadge(screenType: string): string { diff --git a/src/lib/slug.ts b/src/lib/slug.ts index 5c3d0d9..c933947 100644 --- a/src/lib/slug.ts +++ b/src/lib/slug.ts @@ -12,3 +12,17 @@ export function makeGenerationSlugSnippet(input: SlugInput): string { .slice(0, 48); return slug || "generation"; } + +/** + * True when `candidate` is the same slug or a numeric uniqueness suffix + * (`microsoft-teams-tinder-1`) of `requested`. + */ +export function isNumericSlugAlias( + requested: string, + candidate: string, +): boolean { + if (candidate === requested) return true; + const prefix = `${requested}-`; + if (!candidate.startsWith(prefix)) return false; + return /^\d+$/.test(candidate.slice(prefix.length)); +} diff --git a/src/lib/ui/__tests__/format-og-description.test.ts b/src/lib/ui/__tests__/format-og-description.test.ts new file mode 100644 index 0000000..c890542 --- /dev/null +++ b/src/lib/ui/__tests__/format-og-description.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { formatOgDescription } from "../format"; + +describe("formatOgDescription", () => { + it("is a one-line joke from builder and target, not the locker template", () => { + const description = formatOgDescription("Microsoft Teams", "Tinder"); + expect(description).toBe( + "What if Microsoft Teams built Tinder? The UI is the punchline.", + ); + expect(description.includes("\n")).toBe(false); + expect(description.toLowerCase()).not.toContain("evidence locker"); + }); +}); diff --git a/src/lib/ui/format.ts b/src/lib/ui/format.ts index ada2ef2..a8f6edc 100644 --- a/src/lib/ui/format.ts +++ b/src/lib/ui/format.ts @@ -42,6 +42,13 @@ export function formatResultTitle(builder: string, target: string): string { return `if ${builder} built ${target}`; } +/** + * One-line OG/Twitter description. Keep it a joke, not a locker template. + */ +export function formatOgDescription(builder: string, target: string): string { + return `What if ${builder} built ${target}? The UI is the punchline.`; +} + /** * Determine if the Generate action should be enabled. * Returns true iff both builder and target are non-empty after trimming. diff --git a/src/lib/ui/types.ts b/src/lib/ui/types.ts index 12737b8..2f6b4e5 100644 --- a/src/lib/ui/types.ts +++ b/src/lib/ui/types.ts @@ -50,6 +50,7 @@ export type GenerationResult = { id: number; slug: string; imageUrl: string | null; + ogImageUrl?: string | null; builder: string; target: string; status?: "queued" | "processing" | "completed" | "failed";