diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 02:05:41 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 02:05:41 +0200 |
| commit | 370944f443cb997ef71dda0c8ef6f4e92bea87d7 (patch) | |
| tree | 7a841c0291adb7c261f692121b81a3e9d1a9c615 /apps/web/src/app/changelog | |
| parent | c839db92341b9058eac3ff92cc586df060b768da (diff) | |
| download | opencut-370944f443cb997ef71dda0c8ef6f4e92bea87d7.tar.gz | |
feat: new-release notice and colocate changelog under domain
Diffstat (limited to 'apps/web/src/app/changelog')
| -rw-r--r-- | apps/web/src/app/changelog/[version]/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/components/copy-markdown-button.tsx | 98 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/components/release.tsx | 153 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/page.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/utils.ts | 82 |
5 files changed, 8 insertions, 338 deletions
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx index 522f454a..f069832b 100644 --- a/apps/web/src/app/changelog/[version]/page.tsx +++ b/apps/web/src/app/changelog/[version]/page.tsx @@ -3,15 +3,15 @@ import Link from "next/link"; import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import { getReleaseByVersion, getSortedReleases } from "../utils";
+import { getReleaseByVersion, getSortedReleases } from "@/lib/changelog/utils";
import {
ReleaseArticle,
ReleaseMeta,
ReleaseTitle,
ReleaseDescription,
ReleaseChanges,
-} from "../components/release";
-import { CopyMarkdownButton } from "../components/copy-markdown-button";
+} from "@/lib/changelog/components/release";
+import { CopyMarkdownButton } from "@/lib/changelog/components/copy-markdown-button";
type Props = { params: Promise<{ version: string }> };
diff --git a/apps/web/src/app/changelog/components/copy-markdown-button.tsx b/apps/web/src/app/changelog/components/copy-markdown-button.tsx deleted file mode 100644 index bc7e94bf..00000000 --- a/apps/web/src/app/changelog/components/copy-markdown-button.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"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( - buildCollapsibleMarkdownSection({ - title: getSectionTitle({ type }), - changes: grouped[type], - }), - "", - ); - continue; - } - - lines.push(`## ${getSectionTitle({ type })}`); - for (const change of grouped[type]) { - lines.push(`- ${change.text}`); - } - lines.push(""); - } - - return lines.join("\n").trimEnd(); -} - -function buildCollapsibleMarkdownSection({ - title, - changes, -}: { - title: string; - changes: Change[]; -}): string { - const bulletLines = changes.map((change) => `- ${change.text}`).join("\n"); - - return `<details>\n<summary>${title}</summary>\n\n${bulletLines}\n\n</details>`; -} - -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 deleted file mode 100644 index 475b0a6d..00000000 --- a/apps/web/src/app/changelog/components/release.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import type { ReactNode } from "react"; -import Link from "next/link"; -import { cn } from "@/utils/ui"; -import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper"; -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" - > - <ReactMarkdownWrapper inline>{change.text}</ReactMarkdownWrapper> - </li> - ))} - </ul> - ); -} diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx index 20d599a6..8440eaa2 100644 --- a/apps/web/src/app/changelog/page.tsx +++ b/apps/web/src/app/changelog/page.tsx @@ -1,14 +1,17 @@ import type { Metadata } from "next"; import { BasePage } from "@/app/base-page"; import { Separator } from "@/components/ui/separator"; -import { type Release as ReleaseType, getSortedReleases } from "./utils"; +import { + type Release as ReleaseType, + getSortedReleases, +} from "@/lib/changelog/utils"; import { ReleaseArticle, ReleaseMeta, ReleaseTitle, ReleaseDescription, ReleaseChanges, -} from "./components/release"; +} from "@/lib/changelog/components/release"; export const metadata: Metadata = { title: "Changelog - OpenCut", diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts deleted file mode 100644 index 682d376a..00000000 --- a/apps/web/src/app/changelog/utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -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); -} |
