Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 14 additions & 18 deletions scripts/backfill-generation-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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}`);
}
Expand Down
4 changes: 3 additions & 1 deletion src/app/g/[slug]/generation-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { GenerationCard } from "@/components/generation-card";
import { VoteControls } from "@/components/vote-controls";
import { clearActiveGenerationId } from "@/lib/generation/active-generation-storage";
import type { GenerationStatus } from "@/lib/generation/types";

Check warning on line 10 in src/app/g/[slug]/generation-detail-client.tsx

View workflow job for this annotation

GitHub Actions / Build, test, and lint

'GenerationStatus' is defined but never used
import { isGenerationInProgress } from "@/lib/generation/types";
import { useGenerationStatus } from "@/hooks/use-generation-status";
import type { PublicGeneration } from "@/lib/public-generation";
Expand Down Expand Up @@ -51,6 +51,7 @@
target: initial.target,
errorMessage: initial.errorMessage,
imageUrl: initial.imageUrl,
ogImageUrl: initial.ogImageUrl,
imageDownloadUrl: initial.imageDownloadUrl,
}
: null,
Expand All @@ -65,6 +66,7 @@
status: statusData.status,
errorMessage: statusData.errorMessage,
imageUrl: statusData.imageUrl ?? initial.imageUrl,
ogImageUrl: statusData.ogImageUrl ?? initial.ogImageUrl,
imageDownloadUrl: statusData.imageDownloadUrl ?? initial.imageDownloadUrl,
}
: initial;
Expand Down Expand Up @@ -216,7 +218,7 @@
slug={gen.slug}
builder={gen.builder}
target={gen.target}
imageUrl={gen.imageUrl}
imageUrl={gen.ogImageUrl ?? gen.imageUrl}
imageDownloadUrl={gen.imageDownloadUrl ?? null}
/>
</div>
Expand Down
30 changes: 23 additions & 7 deletions src/app/g/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -31,10 +35,18 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}

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 {
Expand All @@ -60,7 +72,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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" });
Expand Down
41 changes: 18 additions & 23 deletions src/app/g/[slug]/published-generation-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
)}
>
<div className={cn(isMobile ? MOBILE_HERO_BOX : DESKTOP_HERO_BOX)}>
<Zoom>
{/* eslint-disable-next-line @next/next/no-img-element -- rmiz measures native <img>; Next/Image breaks zoom geometry */}
<img
src={imageUrl}
alt={title}
width={isMobile ? 1024 : 1792}
height={isMobile ? 1792 : 1024}
sizes={
variant === "paper"
? "(max-width: 1024px) 100vw, 65vw"
: "(max-width: 768px) 100vw, 600px"
}
className="h-full w-full object-contain"
fetchPriority="high"
decoding="async"
/>
</Zoom>
</div>
<Zoom>
{/* eslint-disable-next-line @next/next/no-img-element -- rmiz measures native <img>; Next/Image breaks zoom geometry */}
<img
src={imageUrl}
alt={title}
sizes={
variant === "paper"
? "(max-width: 1024px) 100vw, 65vw"
: "(max-width: 768px) 100vw, 600px"
}
className={cn(HERO_IMG, isMobile && "max-w-[460px]")}
fetchPriority="high"
decoding="async"
/>
</Zoom>
</div>
{variant === "paper" ? (
<p className="border-t border-line bg-panel px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.08em] text-muted">
Expand Down
41 changes: 10 additions & 31 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <HomepageFeed initialItems={feed.items} filterOptions={filterOptions} />;
}

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,
Expand All @@ -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) => ({
Expand All @@ -71,20 +60,10 @@ export default async function HomePage() {
<div className="flex min-h-full flex-1 flex-col bg-canvas">
<HomepageHero
thumbnails={heroThumbnails}
ideasThisWeek={heroFeed.ideasThisWeek ?? 0}
ideasThisWeek={feed.ideasThisWeek ?? 0}
totalPublished={totalPublished}
/>
<Suspense
fallback={
<div className="px-4 py-10 sm:px-8 md:px-10 lg:px-16">
<p className="font-mono text-[10px] uppercase tracking-widest text-muted">
Loading the evidence locker…
</p>
</div>
}
>
<HomepageFeedSection />
</Suspense>
<HomepageFeed initialItems={feed.items} filterOptions={filterOptions} />
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/feed-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>
Expand Down
16 changes: 12 additions & 4 deletions src/components/generation-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
/>
</div>
)}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
/>
</div>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/components/generation-result-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export function GenerationResultView({
slug={result.slug}
builder={result.builder}
target={result.target}
imageUrl={result.imageUrl}
imageUrl={result.ogImageUrl ?? result.imageUrl}
/>
</>
);
Expand Down Expand Up @@ -348,7 +348,7 @@ export function GenerationResultView({
slug={result.slug}
builder={result.builder}
target={result.target}
imageUrl={result.imageUrl}
imageUrl={result.ogImageUrl ?? result.imageUrl}
/>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-generation-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type GenerationStatusPayload = {
target: string;
errorMessage: string | null;
imageUrl: string | null;
ogImageUrl: string | null;
imageDownloadUrl: string | null;
};

Expand Down
4 changes: 2 additions & 2 deletions src/lib/__tests__/screen-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
29 changes: 29 additions & 0 deletions src/lib/__tests__/slug-alias.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
1 change: 1 addition & 0 deletions src/lib/feed-public-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type PublicFeedQueryable = PublicFeedFilterQuery & {
options: { ascending: boolean },
) => PublicFeedQueryable;
gte: (column: string, value: string) => PromiseLike<PublicFeedQueryResult>;
like: (column: string, pattern: string) => PublicFeedQueryable;
limit: (count: number) => PublicFeedQueryable;
} & PromiseLike<PublicFeedQueryResult>;

Expand Down
Loading
Loading