diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-02 00:55:08 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-02 00:55:08 +0100 |
| commit | 77a3708213606d520833944141c3aa9169954e07 (patch) | |
| tree | a9d9edf5abc209a1bb02a109ceed6fc5e5c445fb /apps/web/src/app | |
| parent | 9a00e8ac5b77e8d82102457aaffd19945a2f52cb (diff) | |
| download | opencut-77a3708213606d520833944141c3aa9169954e07.tar.gz | |
web: refactor changelog to support a dedicated page for each version + links for each release
Diffstat (limited to 'apps/web/src/app')
| -rw-r--r-- | apps/web/src/app/changelog/[version]/page.tsx | 98 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/components/release.tsx | 106 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/page.tsx | 118 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/utils.ts | 43 |
4 files changed, 268 insertions, 97 deletions
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx new file mode 100644 index 00000000..5b24af1e --- /dev/null +++ b/apps/web/src/app/changelog/[version]/page.tsx @@ -0,0 +1,98 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { BasePage } from "@/app/base-page"; +import { allChangelogs } from "content-collections"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { getSortedReleases } from "../utils"; +import { + ReleaseArticle, + ReleaseMeta, + ReleaseTitle, + ReleaseDescription, + ReleaseChanges, +} from "../components/release"; + +type Props = { params: Promise<{ version: string }> }; + +export async function generateStaticParams() { + return allChangelogs.map((r) => ({ version: r.version })); +} + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const { version } = await params; + const release = allChangelogs.find((r) => r.version === version); + if (!release) return {}; + return { + title: `${release.title} (${release.version}) - OpenCut Changelog`, + description: release.description, + }; +} + +export default async function ReleaseDetailPage({ params }: Props) { + const { version } = await params; + const releases = getSortedReleases(); + const index = releases.findIndex((r) => r.version === version); + + if (index === -1) notFound(); + + const release = releases[index]; + const newer = index > 0 ? releases[index - 1] : null; + const older = index < releases.length - 1 ? releases[index + 1] : null; + + return ( + <BasePage> + <div className="mx-auto w-full max-w-3xl flex flex-col gap-12"> + <Link + href="/changelog" + className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit" + > + <ChevronLeftIcon className="size-4" /> + All releases + </Link> + + <ReleaseArticle variant="detail"> + <div className="flex flex-col gap-4"> + <ReleaseMeta release={release} /> + <ReleaseTitle as="h1">{release.title}</ReleaseTitle> + {release.description && ( + <ReleaseDescription>{release.description}</ReleaseDescription> + )} + </div> + <ReleaseChanges release={release} /> + </ReleaseArticle> + + <nav className="flex items-center justify-between border-t pt-8"> + {older ? ( + <Link + href={`/changelog/${older.version}`} + className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group" + > + <ChevronLeftIcon className="size-4" /> + <div className="flex flex-col"> + <span className="text-xs text-muted-foreground/60">Older</span> + <span className="font-medium">{older.title}</span> + </div> + </Link> + ) : ( + <div /> + )} + {newer ? ( + <Link + href={`/changelog/${newer.version}`} + className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right" + > + <div className="flex flex-col"> + <span className="text-xs text-muted-foreground/60">Newer</span> + <span className="font-medium">{newer.title}</span> + </div> + <ChevronRightIcon className="size-4" /> + </Link> + ) : ( + <div /> + )} + </nav> + </div> + </BasePage> + ); +} diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx new file mode 100644 index 00000000..5fed2f27 --- /dev/null +++ b/apps/web/src/app/changelog/components/release.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { cn } from "@/utils/ui"; +import { getSectionTitle, groupAndOrderChanges } from "../utils"; +import type { Release } from "../utils"; + +export function ReleaseArticle({ + variant, + isLatest, + children, +}: { + variant: "list" | "detail"; + isLatest?: boolean; + children: ReactNode; +}) { + if (variant === "list") { + return ( + <article className="relative sm:pl-10"> + <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block"> + <div + className={cn( + "size-[11px] rounded-full border-[1.5px]", + isLatest + ? "border-foreground bg-foreground" + : "border-muted-foreground/30 bg-background", + )} + /> + </div> + <div className="flex flex-col gap-5">{children}</div> + </article> + ); + } + + return <article className="flex flex-col gap-8">{children}</article>; +} + +export function ReleaseMeta({ release }: { release: Release }) { + return ( + <span className="text-sm font-medium tracking-widest text-muted-foreground"> + {release.version} — {release.date} + </span> + ); +} + +const titleSizes: Record<"h1" | "h2", string> = { + h1: "text-4xl", + h2: "text-2xl", +}; + +export function ReleaseTitle({ + as: As, + href, + children, +}: { + as: "h1" | "h2"; + href?: string; + children: ReactNode; +}) { + return ( + <As className={cn("font-bold tracking-tight", titleSizes[As])}> + {href ? ( + <Link href={href} className="hover:underline underline-offset-4"> + <>{children}</> + </Link> + ) : ( + children + )} + </As> + ); +} + +export function ReleaseDescription({ children }: { children: ReactNode }) { + return ( + <p className="text-base text-foreground leading-relaxed max-w-xl"> + {children} + </p> + ); +} + +export function ReleaseChanges({ release }: { release: Release }) { + const { grouped, orderedTypes } = groupAndOrderChanges({ + changes: release.changes, + }); + + return ( + <div className="flex flex-col gap-4"> + {orderedTypes.map((type) => ( + <div key={type} className="flex flex-col gap-1.5"> + <h3 className="text-base font-semibold text-foreground"> + {getSectionTitle(type)}: + </h3> + <ul className="list-disc pl-5 space-y-1.5"> + {grouped[type].map((change) => ( + <li + key={change.text} + className="text-base text-foreground leading-relaxed" + > + {change.text} + </li> + ))} + </ul> + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx index a9072485..8cb585b6 100644 --- a/apps/web/src/app/changelog/page.tsx +++ b/apps/web/src/app/changelog/page.tsx @@ -1,8 +1,14 @@ import type { Metadata } from "next"; import { BasePage } from "@/app/base-page"; import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; -import { allChangelogs } from "content-collections"; +import { type Release as ReleaseType, getSortedReleases } from "./utils"; +import { + ReleaseArticle, + ReleaseMeta, + ReleaseTitle, + ReleaseDescription, + ReleaseChanges, +} from "./components/release"; export const metadata: Metadata = { title: "Changelog - OpenCut", @@ -14,25 +20,8 @@ export const metadata: Metadata = { }, }; -const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; - -const knownSectionTitles: Record<string, string> = { - new: "Features", - improved: "Improvements", - fixed: "Fixes", - breaking: "Breaking Changes", -}; - -function getSectionTitle(type: string): string { - return ( - knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) - ); -} - export default function ChangelogPage() { - const releases = [...allChangelogs].sort((a, b) => - b.version.localeCompare(a.version, undefined, { numeric: true }), - ); + const releases = getSortedReleases(); return ( <BasePage title="Changelog" description="See what's new in OpenCut"> @@ -48,7 +37,6 @@ export default function ChangelogPage() { <div key={release.version} className="flex flex-col"> <ReleaseEntry release={release} /> {releaseIndex < releases.length - 1 && ( - // ml-1.5 aligns with the center of the 11px timeline dot <Separator className="my-10 sm:ml-1.5" /> )} </div> @@ -60,83 +48,19 @@ export default function ChangelogPage() { ); } -type Change = { type: string; text: string }; -type Release = (typeof allChangelogs)[number]; - -function groupAndOrderChanges({ changes }: { changes: Change[] }) { - const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => { - if (!acc[change.type]) { - acc[change.type] = []; - } - acc[change.type].push(change); - return acc; - }, {}); - - const customTypes = Object.keys(grouped).filter( - (type) => !knownSectionOrder.includes(type), - ); - const orderedTypes = [ - ...knownSectionOrder.filter((type) => grouped[type]?.length > 0), - ...customTypes, - ]; - - return { grouped, orderedTypes }; -} - -function ReleaseEntry({ release }: { release: Release }) { - const { grouped: groupedChanges, orderedTypes } = groupAndOrderChanges({ - changes: release.changes, - }); - +function ReleaseEntry({ release }: { release: ReleaseType }) { return ( - <article className="relative sm:pl-10"> - <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block"> - <div - className={cn( - "size-[11px] rounded-full border-[1.5px]", - release.isLatest - ? "border-foreground bg-foreground" - : "border-muted-foreground/30 bg-background", - )} - /> - </div> - - <div className="flex flex-col gap-5"> - <div className="flex items-center gap-2.5 flex-wrap"> - <span className="text-sm font-medium tracking-widest text-muted-foreground"> - {release.version} - {release.date} - </span> - </div> - - <div className="flex flex-col gap-4"> - <h2 className="text-2xl font-bold tracking-tight">{release.title}</h2> - {release.description && ( - <p className="text-base text-foreground leading-relaxed max-w-xl"> - {release.description} - </p> - )} - </div> - - <div className="flex flex-col gap-4"> - {orderedTypes.map((type) => ( - <div key={type} className="flex flex-col gap-1.5"> - <h3 className="text-base font-semibold text-foreground"> - {getSectionTitle(type)}: - </h3> - <ul className="list-disc pl-5 space-y-1.5"> - {groupedChanges[type].map((change) => ( - <li - key={change.text} - className="text-base text-foreground leading-relaxed" - > - {change.text} - </li> - ))} - </ul> - </div> - ))} - </div> + <ReleaseArticle variant="list" isLatest={release.isLatest}> + <ReleaseMeta release={release} /> + <div className="flex flex-col gap-4"> + <ReleaseTitle as="h2" href={`/changelog/${release.version}`}> + {release.title} + </ReleaseTitle> + {release.description && ( + <ReleaseDescription>{release.description}</ReleaseDescription> + )} </div> - </article> + <ReleaseChanges release={release} /> + </ReleaseArticle> ); } diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts new file mode 100644 index 00000000..163207c0 --- /dev/null +++ b/apps/web/src/app/changelog/utils.ts @@ -0,0 +1,43 @@ +import { allChangelogs } from "content-collections"; + +export type Change = { type: string; text: string }; +export type Release = (typeof allChangelogs)[number]; + +const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; + +const knownSectionTitles: Record<string, string> = { + new: "Features", + improved: "Improvements", + fixed: "Fixes", + breaking: "Breaking Changes", +}; + +export function getSectionTitle(type: string): string { + return ( + knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) + ); +} + +export function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => { + if (!acc[change.type]) acc[change.type] = []; + acc[change.type].push(change); + return acc; + }, {}); + + const customTypes = Object.keys(grouped).filter( + (type) => !knownSectionOrder.includes(type), + ); + const orderedTypes = [ + ...knownSectionOrder.filter((type) => grouped[type]?.length > 0), + ...customTypes, + ]; + + return { grouped, orderedTypes }; +} + +export function getSortedReleases() { + return [...allChangelogs].sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); +} |
