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/lib/changelog/components | |
| parent | c839db92341b9058eac3ff92cc586df060b768da (diff) | |
| download | opencut-370944f443cb997ef71dda0c8ef6f4e92bea87d7.tar.gz | |
feat: new-release notice and colocate changelog under domain
Diffstat (limited to 'apps/web/src/lib/changelog/components')
3 files changed, 341 insertions, 0 deletions
diff --git a/apps/web/src/lib/changelog/components/changelog-notification.tsx b/apps/web/src/lib/changelog/components/changelog-notification.tsx new file mode 100644 index 00000000..c45eaaac --- /dev/null +++ b/apps/web/src/lib/changelog/components/changelog-notification.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Button } from "@/components/ui/button"; +import { getSortedReleases } from "../utils"; +import type { Release } from "../utils"; + +const STORAGE_KEY = "last-seen-version"; + +export function ChangelogNotification() { + const [release, setRelease] = useState<Release | null>(null); + + useEffect(() => { + const releases = getSortedReleases(); + const latest = releases[0]; + if (!latest) return; + + let storedVersion: string | null = null; + try { + storedVersion = localStorage.getItem(STORAGE_KEY); + } catch { + // localStorage unavailable + } + + // First-time visitor: record the version silently, nothing to announce. + if (storedVersion === null) { + try { + localStorage.setItem(STORAGE_KEY, latest.version); + } catch { + // ignore + } + return; + } + + // Already seen this version. + if (storedVersion === latest.version) return; + + // New version since last visit: record it and show the notification. + try { + localStorage.setItem(STORAGE_KEY, latest.version); + } catch { + // ignore + } + + setRelease(latest); + }, []); + + if (!release) return null; + + return ( + <div className="fixed bottom-5 left-5 z-50 flex w-72 flex-col gap-3 rounded-xl border bg-card p-4 shadow-lg"> + <div className="flex items-start justify-between gap-2"> + <div className="flex flex-col gap-1"> + <span className="text-sm font-semibold leading-snug"> + {release.title} + </span> + <span className="text-xs text-muted-foreground"> + v{release.version} + </span> + </div> + <Button + variant="ghost" + size="icon" + className="-mr-1 -mt-1 shrink-0" + onClick={() => setRelease(null)} + aria-label="Dismiss" + > + <HugeiconsIcon icon={Cancel01Icon} className="size-4" /> + </Button> + </div> + + {release.summary && ( + <p className="text-xs leading-relaxed text-muted-foreground"> + {release.summary} + </p> + )} + + <div className="flex justify-end"> + <Button asChild size="sm"> + <Link href="/changelog" onClick={() => setRelease(null)}> + See full changelog + </Link> + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/lib/changelog/components/copy-markdown-button.tsx b/apps/web/src/lib/changelog/components/copy-markdown-button.tsx new file mode 100644 index 00000000..bc7e94bf --- /dev/null +++ b/apps/web/src/lib/changelog/components/copy-markdown-button.tsx @@ -0,0 +1,98 @@ +"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/lib/changelog/components/release.tsx b/apps/web/src/lib/changelog/components/release.tsx new file mode 100644 index 00000000..475b0a6d --- /dev/null +++ b/apps/web/src/lib/changelog/components/release.tsx @@ -0,0 +1,153 @@ +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> + ); +} |
