diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-14 23:55:20 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-14 23:55:20 +0200 |
| commit | 8fe66090e7d4a3a0dcde02cfa828a5c3bee63122 (patch) | |
| tree | d7dfd95a38d8323f1e3771646c2d14dbfd50f18d /apps/web/src | |
| parent | 91e1232112421438ce73b9361896f9b58c84fb3e (diff) | |
| download | opencut-8fe66090e7d4a3a0dcde02cfa828a5c3bee63122.tar.gz | |
feat: add support for technical details in changelog entries
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/app/changelog/components/copy-markdown-button.tsx | 156 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/components/release.tsx | 258 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/utils.ts | 135 |
3 files changed, 322 insertions, 227 deletions
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 (
- <Button
- size="sm"
- variant="text"
- onClick={handleCopy}
- className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
- title="Copy as markdown"
- >
- {copied ? (
- <CheckIcon className="size-4" />
- ) : (
- <ClipboardIcon className="size-4" />
- )}
- {copied ? "Copied!" : "Copy markdown"}
- </Button>
- );
-}
+"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( + "<details>", + `<summary>${getSectionTitle({ type })}</summary>`, + "", + ); + for (const change of grouped[type]) { + lines.push(`- ${change.text}`); + } + lines.push("", "</details>", ""); + 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 ( + <Button + size="sm" + variant="text" + onClick={handleCopy} + className={cn( + "flex items-center gap-1.5", + copied && "pointer-events-none", + )} + title="Copy as markdown" + > + {copied ? ( + <CheckIcon className="size-4" /> + ) : ( + <ClipboardIcon className="size-4" /> + )} + {copied ? "Copied!" : "Copy markdown"} + </Button> + ); +} 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 (
- <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>
- );
-}
+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 ( + <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) => ( + <ReleaseChangeSection key={type} type={type} changes={grouped[type]} /> + ))} + </div> + ); +} + +function ReleaseChangeSection({ + type, + changes, +}: { + type: string; + changes: Change[]; +}) { + const title = getSectionTitle({ type }); + + if (isSectionCollapsible({ type })) { + return ( + <details className="group flex flex-col gap-1.5"> + <summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 text-base font-semibold text-foreground [&::-webkit-details-marker]:hidden"> + <span>{title}:</span> + <HugeiconsIcon + icon={ArrowRightIcon} + className="size-3 shrink-0 text-muted-foreground group-open:rotate-90" + /> + </summary> + <ReleaseChangeList changes={changes} /> + </details> + ); + } + + return ( + <div className="flex flex-col gap-1.5"> + <h3 className="text-base font-semibold text-foreground">{title}:</h3> + <ReleaseChangeList changes={changes} /> + </div> + ); +} + +function ReleaseChangeList({ + changes, + className, +}: { + changes: Change[]; + className?: string; +}) { + return ( + <ul className={cn("list-disc space-y-1.5 pl-5", className)}> + {changes.map((change) => ( + <li + key={change.text} + className="text-base leading-relaxed text-foreground" + > + {change.text} + </li> + ))} + </ul> + ); +} 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<string, string> = {
- 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<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 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<string, ChangeSectionConfig> = { + 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<string, number>(); + const grouped = changes.reduce<Record<string, Change[]>>((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); +} |
