diff --git a/apps/web/src/app/changelog/components/copy-markdown-button.tsx b/apps/web/src/app/changelog/components/copy-markdown-button.tsx index 7ea18b2a..ddf7bbb1 100644 --- a/apps/web/src/app/changelog/components/copy-markdown-button.tsx +++ b/apps/web/src/app/changelog/components/copy-markdown-button.tsx @@ -1,68 +1,88 @@ -"use client"; - -import { useState } from "react"; -import { CheckIcon, ClipboardIcon } from "lucide-react"; -import { getSectionTitle, groupAndOrderChanges } from "../utils"; -import type { Change } from "../utils"; -import { cn } from "@/utils/ui"; -import { Button } from "@/components/ui/button"; - -function buildMarkdown({ - description, - changes, -}: { - description?: string; - changes: Change[]; -}): string { - const lines: string[] = []; - - if (description) { - lines.push(description, ""); - } - - const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); - - for (const type of orderedTypes) { - lines.push(`## ${getSectionTitle({ type })}`); - for (const change of grouped[type]) { - lines.push(`- ${change.text}`); - } - lines.push(""); - } - - return lines.join("\n").trimEnd(); -} - -export function CopyMarkdownButton({ - description, - changes, -}: { - description?: string; - changes: Change[]; -}) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - const markdown = buildMarkdown({ description, changes }); - await navigator.clipboard.writeText(markdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - - ); -} +"use client"; + +import { useState } from "react"; +import { CheckIcon, ClipboardIcon } from "lucide-react"; +import { + getSectionTitle, + groupAndOrderChanges, + isSectionCollapsible, +} from "../utils"; +import type { Change } from "../utils"; +import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; + +function buildMarkdown({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}): string { + const lines: string[] = []; + + if (description) { + lines.push(description, ""); + } + + const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); + + for (const type of orderedTypes) { + if (isSectionCollapsible({ type })) { + lines.push( + "
", + `${getSectionTitle({ type })}`, + "", + ); + for (const change of grouped[type]) { + lines.push(`- ${change.text}`); + } + lines.push("", "
", ""); + continue; + } + + lines.push(`## ${getSectionTitle({ type })}`); + for (const change of grouped[type]) { + lines.push(`- ${change.text}`); + } + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} + +export function CopyMarkdownButton({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const markdown = buildMarkdown({ description, changes }); + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx index 10dcf561..0da74bf4 100644 --- a/apps/web/src/app/changelog/components/release.tsx +++ b/apps/web/src/app/changelog/components/release.tsx @@ -1,106 +1,152 @@ -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 })}: -

- -
- ))} -
- ); -} +import type { ReactNode } from "react"; +import Link from "next/link"; +import { cn } from "@/utils/ui"; +import { + getSectionTitle, + groupAndOrderChanges, + isSectionCollapsible, + type Change, + type Release, +} from "../utils"; +import { ArrowRightIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +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) => ( + + ))} +
+ ); +} + +function ReleaseChangeSection({ + type, + changes, +}: { + type: string; + changes: Change[]; +}) { + const title = getSectionTitle({ type }); + + if (isSectionCollapsible({ type })) { + return ( +
+ + {title}: + + + +
+ ); + } + + return ( +
+

{title}:

+ +
+ ); +} + +function ReleaseChangeList({ + changes, + className, +}: { + changes: Change[]; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts index 3f38ab6b..682d376a 100644 --- a/apps/web/src/app/changelog/utils.ts +++ b/apps/web/src/app/changelog/utils.ts @@ -1,53 +1,82 @@ -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 }: { 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 }; -} - -function isPublishedRelease({ published }: Release) { - return published !== false; -} - -export function getSortedReleases() { - return allChangelogs - .filter(isPublishedRelease) - .sort((a, b) => - b.version.localeCompare(a.version, undefined, { numeric: true }), - ); -} - -export function getReleaseByVersion({ version }: { version: string }) { - return getSortedReleases().find((release) => release.version === version); -} +import { allChangelogs } from "content-collections"; + +export type Change = { type: string; text: string }; +export type Release = (typeof allChangelogs)[number]; + +type ChangeSectionConfig = { + title: string; + order: number; + collapsible?: boolean; +}; + +const knownSectionConfigs: Record = { + new: { title: "Features", order: 0 }, + improved: { title: "Improvements", order: 1 }, + fixed: { title: "Fixes", order: 2 }, + breaking: { title: "Breaking Changes", order: 3 }, + technical: { + title: "Technical details", + order: 4, + collapsible: true, + }, +}; + +function getSectionConfig({ type }: { type: string }): ChangeSectionConfig { + return ( + knownSectionConfigs[type] ?? { + title: type.charAt(0).toUpperCase() + type.slice(1), + order: Number.MAX_SAFE_INTEGER, + } + ); +} + +export function getSectionTitle({ type }: { type: string }): string { + return getSectionConfig({ type }).title; +} + +export function isSectionCollapsible({ type }: { type: string }): boolean { + return getSectionConfig({ type }).collapsible ?? false; +} + +export function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const typeEncounterOrder = new Map(); + const grouped = changes.reduce>((acc, change) => { + if (!typeEncounterOrder.has(change.type)) { + typeEncounterOrder.set(change.type, typeEncounterOrder.size); + } + if (!acc[change.type]) acc[change.type] = []; + acc[change.type].push(change); + return acc; + }, {}); + + const orderedTypes = Object.keys(grouped).sort((left, right) => { + const orderDifference = + getSectionConfig({ type: left }).order - + getSectionConfig({ type: right }).order; + if (orderDifference !== 0) { + return orderDifference; + } + return ( + (typeEncounterOrder.get(left) ?? Number.MAX_SAFE_INTEGER) - + (typeEncounterOrder.get(right) ?? Number.MAX_SAFE_INTEGER) + ); + }); + + return { grouped, orderedTypes }; +} + +function isPublishedRelease({ published }: Release) { + return published !== false; +} + +export function getSortedReleases() { + return allChangelogs + .filter(isPublishedRelease) + .sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); +} + +export function getReleaseByVersion({ version }: { version: string }) { + return getSortedReleases().find((release) => release.version === version); +}