diff --git a/CLAUDE.md b/CLAUDE.md index d2410e56..450bba6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/ + + + +# 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. + + diff --git a/app/(home)/blog/[slug]/page.tsx b/app/(home)/blog/[slug]/page.tsx index 44086464..efe6b18f 100644 --- a/app/(home)/blog/[slug]/page.tsx +++ b/app/(home)/blog/[slug]/page.tsx @@ -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 }> }; @@ -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 ( -
-
- -
-
+ + + + ); } diff --git a/app/(home)/blog/components/beats-list.tsx b/app/(home)/blog/components/beats-list.tsx new file mode 100644 index 00000000..f1e5687a --- /dev/null +++ b/app/(home)/blog/components/beats-list.tsx @@ -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 ( +
    + {shown.map((beat, index) => ( +
  1. + + {String(index + 1).padStart(2, "0")} + + + {beat} + +
  2. + ))} + {more > 0 && ( +
  3. + + +{more} + + + more beats in the storyboard + +
  4. + )} +
+ ); +} diff --git a/app/(home)/blog/components/blog-header.tsx b/app/(home)/blog/components/blog-header.tsx new file mode 100644 index 00000000..2b06c507 --- /dev/null +++ b/app/(home)/blog/components/blog-header.tsx @@ -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 ( + +
+ + + Subscribe via RSS + +
+
+ ); +} diff --git a/app/(home)/blog/components/featured-post.tsx b/app/(home)/blog/components/featured-post.tsx new file mode 100644 index 00000000..a7461c8a --- /dev/null +++ b/app/(home)/blog/components/featured-post.tsx @@ -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 ( +
+
+ {post.video && ( + + )} + +
+
+ +

+ {post.title} +

+

+ {post.description} +

+
+ +
+ +
+ + Read the making-of + + {post.prompt && ( + + Copy the prompt + + )} +
+
+
+
+
+ ); +} diff --git a/app/(home)/blog/components/guide-card.tsx b/app/(home)/blog/components/guide-card.tsx new file mode 100644 index 00000000..73a4e59f --- /dev/null +++ b/app/(home)/blog/components/guide-card.tsx @@ -0,0 +1,35 @@ +import { LandingButton } from "../../components/landing/ui/landing-button"; + +const BUTTON = + "h-12 rounded-control-sm px-4 text-[15px] leading-5 font-medium md:h-10"; + +/** Closing card of the index: where the recipe behind every cut lives. */ +export function GuideCard() { + return ( + + ); +} diff --git a/app/(home)/blog/components/more-posts.tsx b/app/(home)/blog/components/more-posts.tsx new file mode 100644 index 00000000..6b42af70 --- /dev/null +++ b/app/(home)/blog/components/more-posts.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import type { BlogPostSummary } from "../lib/post-types"; +import { PostCard } from "./post-card"; + +/** The next three posts, newest first, under the article. */ +export function MorePosts({ posts }: { posts: BlogPostSummary[] }) { + if (posts.length === 0) return null; + + return ( +
+
+ + + All posts + +
+
+ {posts.map((post) => ( + + ))} +
+
+ ); +} diff --git a/app/(home)/blog/components/post-card.tsx b/app/(home)/blog/components/post-card.tsx new file mode 100644 index 00000000..4baeb2c1 --- /dev/null +++ b/app/(home)/blog/components/post-card.tsx @@ -0,0 +1,67 @@ +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { type BlogPostSummary, POST_KIND_LABEL } from "../lib/post-types"; +import { PostMeta } from "./post-meta"; +import { PostPoster } from "./post-poster"; + +/** + * Index card: poster, meta, title, description; the whole card links to the + * post. `lg` sits two-up, `sm` in the three-column grid. + */ +export function PostCard({ + post, + size, +}: { + post: BlogPostSummary; + size: "lg" | "sm"; +}) { + const lg = size === "lg"; + const beats = post.beats.length; + + return ( + + +
+ {lg || beats === 0 ? ( + + ) : ( + + )} +

+ {post.title} +

+

+ {post.description} +

+
+ + ); +} diff --git a/app/(home)/blog/components/post-index-nav.tsx b/app/(home)/blog/components/post-index-nav.tsx new file mode 100644 index 00000000..9add8484 --- /dev/null +++ b/app/(home)/blog/components/post-index-nav.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { type ReactNode, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useActiveSection } from "../../components/site-page/use-active-section"; + +export type PostHeading = { title: ReactNode; url: string; depth: number }; + +/** + * Sticky "In this post" index beside the article, numbered like the legal + * page; the heading being read is raised. + */ +export function PostIndexNav({ headings }: { headings: PostHeading[] }) { + const ids = useMemo( + () => headings.map((heading) => heading.url.replace(/^#/, "")), + [headings], + ); + const active = useActiveSection(ids); + + return ( + + ); +} diff --git a/app/(home)/blog/components/post-index.tsx b/app/(home)/blog/components/post-index.tsx new file mode 100644 index 00000000..b1098b14 --- /dev/null +++ b/app/(home)/blog/components/post-index.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { + type BlogPostSummary, + POST_KIND_LABEL, + type PostKind, +} from "../lib/post-types"; +import { GuideCard } from "./guide-card"; +import { PostCard } from "./post-card"; + +type Filter = "all" | PostKind; + +const FILTERS: { id: Filter; label: string }[] = [ + { id: "all", label: "All posts" }, + { id: "launch", label: `${POST_KIND_LABEL.launch}s` }, + { id: "product", label: POST_KIND_LABEL.product }, +]; + +/** + * Everything but the featured post: a kind filter, the two newest as large + * cards, the rest in a three-column grid closed by the guide card. + */ +export function PostIndex({ + posts, + counts, +}: { + posts: BlogPostSummary[]; + /** Totals per filter, featured post included. */ + counts: Record; +}) { + const [filter, setFilter] = useState("all"); + const shown = + filter === "all" ? posts : posts.filter((post) => post.kind === filter); + const large = shown.slice(0, 2); + const rest = shown.slice(2); + + return ( +
+
+
+ {FILTERS.map((item) => { + const active = item.id === filter; + return ( + + ); + })} +
+
+ + {large.length > 0 && ( +
+ {large.map((post) => ( + + ))} +
+ )} + +
+ {rest.map((post) => ( + + ))} + +
+
+ ); +} diff --git a/app/(home)/blog/components/post-meta.tsx b/app/(home)/blog/components/post-meta.tsx new file mode 100644 index 00000000..bd744c9b --- /dev/null +++ b/app/(home)/blog/components/post-meta.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** Date (mono on desktop) followed by an optional tag or trailing note. */ +export function PostMeta({ + iso, + date, + tag, + trailing, + className, +}: { + iso: string; + date: string; + tag?: ReactNode; + /** Pushed to the far end, e.g. "9 beats". */ + trailing?: ReactNode; + className?: string; +}) { + return ( +
+ + + {tag && {tag}} + + {trailing && {trailing}} +
+ ); +} + +export function PostTag({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/(home)/blog/components/post-poster.tsx b/app/(home)/blog/components/post-poster.tsx new file mode 100644 index 00000000..96c9c8bc --- /dev/null +++ b/app/(home)/blog/components/post-poster.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/lib/utils"; +import { formatDuration } from "@/lib/video-duration"; +import { PlayGlyph } from "../../components/site-page/play-glyph"; + +/** + * Poster frame of a post's video with a play key: a plain 36px key on large + * cards, a key + duration chip on small ones. + */ +export function PostPoster({ + poster, + duration, + size, +}: { + poster: string | null; + duration: number | null; + size: "lg" | "sm"; +}) { + return ( +
+ {poster && ( + // biome-ignore lint/performance/noImgElement: remote CDN video posters of arbitrary origin + + )} + {size === "lg" ? ( + + + + ) : ( + + + + + {duration !== null && ( + + {formatDuration(duration)} + + )} + + )} +
+ ); +} diff --git a/app/(home)/blog/components/screening-player.tsx b/app/(home)/blog/components/screening-player.tsx new file mode 100644 index 00000000..5da8404c --- /dev/null +++ b/app/(home)/blog/components/screening-player.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; +import { formatDuration } from "@/lib/video-duration"; +import { PauseGlyph, PlayGlyph } from "../../components/site-page/play-glyph"; + +/** + * The screening-room player: the video on a dark stage, a white play key, + * the elapsed / total time, a ruler with a scrubbable playhead and the beat + * count. Plays only on request; nothing autoplays. + */ +export function ScreeningPlayer({ + src, + poster, + label, + duration: durationHint, + beats, + className, +}: { + src: string; + poster?: string | null; + label: string; + /** Seconds, known on the server; replaced by the video's own metadata. */ + duration: number | null; + beats: number; + className?: string; +}) { + const videoRef = useRef(null); + const rulerRef = useRef(null); + const headRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [time, setTime] = useState(0); + const [duration, setDuration] = useState(durationHint ?? 0); + + // `timeupdate` fires about four times a second, too coarse for the + // playhead, so it is painted from the video's own clock every frame. + const paintHead = useCallback(() => { + const video = videoRef.current; + const ruler = rulerRef.current; + const head = headRef.current; + if (!video || !ruler || !head || duration <= 0) return; + const progress = Math.min(video.currentTime / duration, 1); + head.style.transform = `translateX(${(ruler.clientWidth - 19) * progress}px)`; + }, [duration]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: repaint when a seek or `timeupdate` moves the time + useLayoutEffect(() => { + paintHead(); + }, [time, paintHead]); + + useEffect(() => { + const ruler = rulerRef.current; + if (!ruler) return; + const observer = new ResizeObserver(paintHead); + observer.observe(ruler); + return () => observer.disconnect(); + }, [paintHead]); + + useEffect(() => { + if (!playing) return; + let frame = 0; + const tick = () => { + paintHead(); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [playing, paintHead]); + + const toggle = () => { + const video = videoRef.current; + if (!video) return; + if (video.paused) void video.play().catch(() => {}); + else video.pause(); + }; + + return ( +
+
+ {/* biome-ignore lint/a11y/useMediaCaption: the CDN videos ship without caption tracks */} +
+ +
+ + + {formatDuration(time)} / {formatDuration(duration)} + + +
+ {duration > 0 && } +
+ + {beats > 0 && ( + + {beats} beats + + )} +
+
+ ); +} + +/** + * Seconds along the ruler: every 10s plus the end on desktop (with hairline + * separators), start / a third / end on mobile. + */ +function RulerLabels({ duration }: { duration: number }) { + const end = Math.round(duration); + const stops: number[] = []; + for (let t = 0; t < end; t += 10) stops.push(t); + if (stops.length > 1 && end - stops[stops.length - 1] <= 5) stops.pop(); + stops.push(end); + const third = Math.round(end / 30) * 10; + const compact = new Set([0, third, end]); + + return stops.map((t, index) => ( +