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 { + 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 ( + +
+ + + All releases + + + +
+ + {release.title} + {release.description && ( + {release.description} + )} +
+ +
+ + +
+
+ ); +} 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 ( +
+
+
+
+
{children}
+
+ ); + } + + return
{children}
; +} + +export function ReleaseMeta({ release }: { release: Release }) { + return ( + + {release.version} — {release.date} + + ); +} + +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 ( + + {href ? ( + + <>{children} + + ) : ( + children + )} + + ); +} + +export function ReleaseDescription({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function ReleaseChanges({ release }: { release: Release }) { + const { grouped, orderedTypes } = groupAndOrderChanges({ + changes: release.changes, + }); + + return ( +
+ {orderedTypes.map((type) => ( +
+

+ {getSectionTitle(type)}: +

+
    + {grouped[type].map((change) => ( +
  • + {change.text} +
  • + ))} +
+
+ ))} +
+ ); +} 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 = { - 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 ( @@ -48,7 +37,6 @@ export default function ChangelogPage() {
{releaseIndex < releases.length - 1 && ( - // ml-1.5 aligns with the center of the 11px timeline dot )}
@@ -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>((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 ( -
-
-
+ + +
+ + {release.title} + + {release.description && ( + {release.description} + )}
- -
-
- - {release.version} - {release.date} - -
- -
-

{release.title}

- {release.description && ( -

- {release.description} -

- )} -
- -
- {orderedTypes.map((type) => ( -
-

- {getSectionTitle(type)}: -

-
    - {groupedChanges[type].map((change) => ( -
  • - {change.text} -
  • - ))} -
-
- ))} -
-
-
+ + ); } 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 = { + 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>((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 }), + ); +}