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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,13 @@ bun run build # production-сборка сайта
bun run typecheck # tsc --noEmit
bun run lint # biome check
bun run registry:build # пересборка registry-artifacts из registry/

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
158 changes: 88 additions & 70 deletions app/(home)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { ArrowLeft } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { blog } from "@/.source/server";
import { GuideVideo } from "@/components/docs/guide-video";
import { blogDateFormatter, blogSlug } from "@/lib/blog";
import { sortByDateDesc } from "@/lib/changelog";
import { blogSlug } from "@/lib/blog";
import { cn } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
import { landingButtonVariants } from "../../components/landing/ui/landing-button";
import { CopyTextButton } from "../../components/site-page/copy-text-button";
import { pageHeaderButton } from "../../components/site-page/page-header";
import { MorePosts } from "../components/more-posts";
import { PostIndexNav } from "../components/post-index-nav";
import { PostMeta } from "../components/post-meta";
import { ScreeningPlayer } from "../components/screening-player";
import { POST_KIND_LABEL } from "../lib/post-types";
import { getBlogPosts } from "../lib/posts";

type PageProps = { params: Promise<{ slug: string }> };

Expand Down Expand Up @@ -54,87 +62,97 @@ export async function generateMetadata({

const postComponents = { ...getMDXComponents(), GuideVideo };

const MORE_POSTS = 3;

export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();

const iso = post.date.toISOString().slice(0, 10);
const posts = await getBlogPosts();
const index = posts.findIndex((item) => item.slug === slug);
const summary = posts[index];
const more = [...posts.slice(index + 1), ...posts.slice(0, index)].slice(
0,
MORE_POSTS,
);
const headings = post.toc.filter((item) => item.depth <= 3);
const MDX = post.body;
const sorted = sortByDateDesc(blog);
const index = sorted.findIndex((p) => blogSlug(p) === slug);
const next = index === -1 ? undefined : sorted[index + 1];

return (
<section className="pt-10 pb-24 sm:pt-14">
<div className="section flex flex-col gap-8">
<Link
href="/blog"
className="group relative inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors before:absolute before:inset-x-0 before:-inset-y-2 before:content-[''] hover:text-foreground"
>
<ArrowLeft
className="size-4 transition-transform group-hover:-translate-x-0.5"
aria-hidden="true"
/>
Blog
</Link>

{post.video ? (
<GuideVideo
src={post.video}
poster={post.videoPoster}
label={post.title}
<>
<article>
<header className="landing-container flex flex-col gap-5 pt-12 pb-8 md:pt-20 md:pb-12 xl:pt-28 xl:pb-14">
<Link
href="/blog"
className={cn(
landingButtonVariants({ variant: "secondary" }),
pageHeaderButton,
"self-start",
)}
>
<span aria-hidden="true" className="text-primary">
←
</span>
Blog
</Link>
<PostMeta
iso={summary.iso}
date={summary.dateLabel}
tag={POST_KIND_LABEL[summary.kind]}
className="pt-3"
/>
) : null}

<div className="flex flex-col gap-3">
<h1 className="text-balance text-3xl font-semibold leading-[1.1] tracking-tight text-foreground sm:text-4xl">
<h1 className="max-w-[920px] text-[34px] leading-[38px] tracking-heading text-balance text-foreground md:text-h3">
{post.title}
</h1>
<time
dateTime={iso}
className="font-mono text-xs font-medium text-muted-foreground"
>
{blogDateFormatter.format(post.date)}
</time>
</div>

<div className="typeset typeset-docs max-w-2xl">
<MDX components={postComponents} />
</div>
<p className="max-w-[680px] text-[18px] leading-[26px] text-muted-foreground text-pretty md:text-lead">
{post.description}
</p>
</header>

<div className="max-w-2xl border-t border-border pt-8">
{next ? (
<>
<p className="font-mono text-xs font-medium text-muted-foreground">
Read next
</p>
<Link
href={`/blog/${blogSlug(next)}`}
className="group mt-3 flex flex-col gap-1"
>
<span className="text-balance text-lg font-semibold tracking-tight text-foreground group-hover:underline">
{next.title}
</span>
<span className="text-pretty text-sm leading-relaxed text-muted-foreground">
{next.description}
</span>
</Link>
</>
) : (
<Link
href="/blog"
className="group relative inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors before:absolute before:inset-x-0 before:-inset-y-2 before:content-[''] hover:text-foreground"
>
<ArrowLeft
className="size-4 transition-transform group-hover:-translate-x-0.5"
aria-hidden="true"
<div className="landing-container xl:flex xl:gap-[120px]">
<div className="flex min-w-0 flex-col gap-10 md:gap-14 xl:w-[820px] xl:shrink-0">
{summary.video && (
<ScreeningPlayer
src={summary.video}
poster={summary.poster}
label={post.title}
duration={summary.duration}
beats={summary.beats.length}
/>
All posts
</Link>
)}
<div className="typeset typeset-docs max-w-[680px]">
<MDX components={postComponents} />
</div>
</div>

{(headings.length > 0 || summary.prompt) && (
<aside className="hidden w-[300px] shrink-0 flex-col gap-7 self-start xl:sticky xl:top-8 xl:flex">
{headings.length > 0 && <PostIndexNav headings={headings} />}
{summary.prompt && (
<div className="flex flex-col gap-3.5 px-2">
<p className="text-small text-muted-foreground">
The brief behind this cut, ready to paste into your agent.
</p>
<CopyTextButton
text={summary.prompt}
copiedLabel="Prompt copied"
className={cn(
landingButtonVariants({ variant: "secondary" }),
pageHeaderButton,
"self-start",
)}
>
Copy the prompt
</CopyTextButton>
</div>
)}
</aside>
)}
</div>
</div>
</section>
</article>

<MorePosts posts={more} />
</>
);
}
56 changes: 56 additions & 0 deletions app/(home)/blog/components/beats-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { cn } from "@/lib/utils";

/**
* Numbered storyboard beats; the first is lit. With `limit`, the rest
* collapse into a "+N more beats in the storyboard" row.
*/
export function BeatsList({
beats,
limit,
className,
}: {
beats: string[];
limit?: number;
className?: string;
}) {
const shown = limit ? beats.slice(0, limit) : beats;
const more = beats.length - shown.length;

return (
<ol className={cn("flex flex-col border-t border-divider", className)}>
{shown.map((beat, index) => (
<li
key={beat}
className="flex gap-3 border-b border-hairline py-3 last:border-b-0 md:py-2.5"
>
<span
className={cn(
"w-6 shrink-0 text-caption leading-5 tabular-nums",
index === 0 ? "text-primary" : "text-subtle",
)}
>
{String(index + 1).padStart(2, "0")}
</span>
<span
className={cn(
"text-[15px] leading-5 md:text-small",
index === 0 ? "text-foreground" : "text-body",
)}
>
{beat}
</span>
</li>
))}
{more > 0 && (
<li className="flex gap-3 py-3 md:py-2.5">
<span className="w-6 shrink-0 text-caption leading-5 text-subtle tabular-nums">
+{more}
</span>
<span className="text-[15px] leading-5 text-muted-foreground md:text-small">
more beats in the storyboard
</span>
</li>
)}
</ol>
);
}
45 changes: 45 additions & 0 deletions app/(home)/blog/components/blog-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { landingButtonVariants } from "../../components/landing/ui/landing-button";
import {
pageHeaderButton,
SitePageHeader,
} from "../../components/site-page/page-header";

export const BLOG_LEAD =
"The storyboard, the decisions that made each one work, and the prompt that rebuilds it.";

export function BlogHeader({ count }: { count: number }) {
return (
<SitePageHeader
eyebrow={`Blog, ${count} posts`}
title="Making-of stories"
continuation="behind every video we cut."
lead={BLOG_LEAD}
>
<div className="flex">
<a
href="/blog/rss.xml"
className={cn(
landingButtonVariants({ variant: "secondary" }),
pageHeaderButton,
)}
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="size-3.5 shrink-0 fill-none stroke-current stroke-[2.6] [stroke-linecap:round]"
>
<circle
cx="5"
cy="19"
r="2.2"
className="fill-current stroke-none"
/>
<path d="M3 11a10 10 0 0 1 10 10M3 4a17 17 0 0 1 17 17" />
</svg>
Subscribe via RSS
</a>
</div>
</SitePageHeader>
);
}
77 changes: 77 additions & 0 deletions app/(home)/blog/components/featured-post.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { cn } from "@/lib/utils";
import {
LandingButton,
landingButtonVariants,
} from "../../components/landing/ui/landing-button";
import { CopyTextButton } from "../../components/site-page/copy-text-button";
import { type BlogPostSummary, POST_KIND_LABEL } from "../lib/post-types";
import { BeatsList } from "./beats-list";
import { PostMeta } from "./post-meta";
import { ScreeningPlayer } from "./screening-player";

const BUTTON =
"h-12 w-full rounded-control-sm px-4 text-[15px] leading-5 font-medium md:h-10 md:w-auto";

/**
* The screening room: the newest launch video in its player, next to the
* first beats of its storyboard and the two ways in (read it, copy the
* prompt that rebuilds it).
*/
export function FeaturedPost({ post }: { post: BlogPostSummary }) {
return (
<section
aria-label="Featured making-of"
className="landing-container md:pb-16 xl:pb-[88px]"
>
<div className="flex flex-col gap-2 rounded-panel bg-surface p-2 shadow-[0_0_0_1px_var(--hairline),0_24px_48px_-32px_#0e10122e] md:p-2.5 xl:flex-row xl:rounded-shell">
{post.video && (
<ScreeningPlayer
src={post.video}
poster={post.poster}
label={post.title}
duration={post.duration}
beats={post.beats.length}
className="xl:w-[820px] xl:shrink-0"
/>
)}

<div className="flex min-w-0 grow flex-col justify-between gap-6 px-3 pt-4 pb-3 md:gap-7 md:pt-7 md:pr-6 md:pb-3.5 md:pl-7">
<div className="flex flex-col gap-3 md:gap-4">
<PostMeta
iso={post.iso}
date={post.dateLabel}
tag={POST_KIND_LABEL[post.kind]}
/>
<h2 className="text-[24px] leading-[30px] font-medium tracking-title text-foreground text-balance md:text-[30px] md:leading-[34px]">
{post.title}
</h2>
<p className="text-ui leading-6 text-muted-foreground text-pretty">
{post.description}
</p>
</div>

<div className="flex flex-col gap-6">
<BeatsList beats={post.beats} limit={3} />
<div className="flex flex-col gap-2 md:flex-row">
<LandingButton href={`/blog/${post.slug}`} className={BUTTON}>
Read the making-of
</LandingButton>
{post.prompt && (
<CopyTextButton
text={post.prompt}
copiedLabel="Prompt copied"
className={cn(
landingButtonVariants({ variant: "secondary" }),
BUTTON,
)}
>
Copy the prompt
</CopyTextButton>
)}
</div>
</div>
</div>
</div>
</section>
);
}
Loading
Loading