diff options
Diffstat (limited to 'apps/web/src/app/changelog/components')
| -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 |
2 files changed, 240 insertions, 174 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> + ); +} |
