Menu
popagent
publicLatest change 94028c0c43cb3f15a9ab31f7f067d5ef25844b92 - Preserve deployed changelog history by AkurAI Build
import type { ReactNode } from "react";
import { CircleDot, GitBranch, GitCommitHorizontal, Sparkles, Tag } from "lucide-react";
import changelogMarkdown from "../../CHANGELOG.md" with { type: "text" };
export type ChangelogRelease = {
title: string;
date?: string;
groups: Array<{ title: string; items: string[] }>;
};
function changelogLine(line: string): string {
return line
.replace(/<code>(.*?)<\/code>/g, "`$1`")
.replace(/^<h2>(.*?)<\/h2>$/, "## $1")
.replace(/^<h3>(.*?)<\/h3>$/, "### $1")
.replace(/^<li>(.*?)<\/li>$/, "- $1")
.replace(/<[^>]+>/g, "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll(""", "\"")
.replaceAll("'", "'");
}
export function parseChangelog(markdown: string): ChangelogRelease[] {
const releases: ChangelogRelease[] = [];
let release: ChangelogRelease | undefined;
let group: ChangelogRelease["groups"][number] | undefined;
for (const sourceLine of markdown.split("\n")) {
const line = changelogLine(sourceLine);
const releaseMatch = /^## \[?([^\]]+?)\]?\s*(?:-\s*(\d{4}-\d{2}-\d{2}))?$/.exec(line.trim());
if (releaseMatch) {
release = { title: releaseMatch[1]!.trim(), date: releaseMatch[2], groups: [] };
releases.push(release);
group = undefined;
continue;
}
const groupMatch = /^###\s+(.+)$/.exec(line.trim());
if (groupMatch && release) {
group = { title: groupMatch[1]!.trim(), items: [] };
release.groups.push(group);
continue;
}
const itemMatch = /^-\s+(.+)$/.exec(line.trim());
if (itemMatch && group) group.items.push(itemMatch[1]!.trim());
}
return releases.filter((entry) => entry.groups.some((entryGroup) => entryGroup.items.length));
}
function inlineText(text: string): ReactNode[] {
return text.split(/(`[^`]+`)/g).filter(Boolean).map((part, index) => part.startsWith("`") && part.endsWith("`")
? <code key={index} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-[0.82em] text-accent">{part.slice(1, -1)}</code>
: part);
}
const groupTone: Record<string, string> = {
added: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
changed: "border-sky-500/30 bg-sky-500/10 text-sky-400",
fixed: "border-amber-500/30 bg-amber-500/10 text-amber-400",
removed: "border-rose-500/30 bg-rose-500/10 text-rose-400",
security: "border-violet-500/30 bg-violet-500/10 text-violet-400",
};
export function ChangelogTimeline({ markdown }: { markdown: string }) {
const releases = parseChangelog(markdown);
return (
<main className="min-h-0 flex-1 overflow-y-auto bg-bg px-4 py-6 sm:px-6 lg:px-10 lg:py-9">
<div className="mx-auto max-w-5xl">
<header className="overflow-hidden rounded-xl border border-line bg-panel shadow-xl shadow-black/10">
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-panel-2/70 px-4 py-3 font-mono text-xs text-ink-muted">
<span className="font-semibold text-ink">popagent</span><span>/</span><span>CHANGELOG.md</span>
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-line bg-bg px-2.5 py-1"><GitBranch className="size-3" />main</span>
</div>
<div className="relative px-5 py-8 sm:px-8 sm:py-10">
<div aria-hidden="true" className="absolute -right-10 -top-14 size-44 rounded-full bg-accent/10 blur-3xl" />
<div className="relative flex items-start gap-4">
<div className="brand-orb flex size-11 shrink-0 items-center justify-center rounded-lg"><Sparkles className="size-5" /></div>
<div><p className="font-mono text-xs uppercase tracking-[0.16em] text-accent">Release feed</p><h1 className="mt-2 text-3xl font-semibold tracking-[-0.04em] sm:text-4xl">Commits with better manners.</h1><p className="mt-3 max-w-2xl text-sm leading-relaxed text-ink-muted">The mildly dramatic history of what shipped, what got fixed, and which tiny gremlins were politely escorted out.</p></div>
</div>
</div>
</header>
<section aria-label="Release feed" className="relative mt-7 space-y-6 pl-5 sm:pl-8">
<div aria-hidden="true" className="absolute bottom-8 left-[1.7rem] top-6 w-px bg-gradient-to-b from-accent via-line to-transparent sm:left-[2.45rem]" />
{releases.map((release, releaseIndex) => (
<article key={`${release.title}-${release.date ?? "pending"}`} className="relative pl-8 sm:pl-11">
<div aria-hidden="true" className={`absolute left-0 top-6 z-10 flex size-5 items-center justify-center rounded-full border-2 bg-bg ${releaseIndex === 0 ? "border-accent text-accent" : "border-line text-ink-dim"}`}><CircleDot className="size-2.5" /></div>
<div className="overflow-hidden rounded-xl border border-line bg-panel transition-colors hover:border-accent/30">
<header className="flex flex-wrap items-center gap-3 border-b border-line bg-panel-2/50 px-4 py-4 sm:px-5">
<span className="inline-flex items-center gap-2 font-mono text-sm font-semibold text-ink"><Tag className="size-4 text-accent" />{release.title}</span>
{release.date ? <time className="font-mono text-xs text-ink-dim" dateTime={release.date}>{release.date}</time> : <span className="rounded-full border border-accent/30 bg-accent-soft px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-accent">Next release</span>}
<span className="ml-auto inline-flex items-center gap-1.5 font-mono text-[10px] text-ink-dim"><GitCommitHorizontal className="size-3.5" />{release.groups.reduce((count, entry) => count + entry.items.length, 0)} changes</span>
</header>
<div className="divide-y divide-line">
{release.groups.map((group) => (
<section key={group.title} className="grid gap-3 px-4 py-5 sm:grid-cols-[7rem_minmax(0,1fr)] sm:px-5">
<div><span className={`inline-flex rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] ${groupTone[group.title.toLowerCase()] ?? "border-line bg-bg text-ink-muted"}`}>{group.title}</span></div>
<ul className="space-y-3 text-sm leading-relaxed text-ink-muted">
{group.items.map((item) => <li key={item} className="relative pl-5 before:absolute before:left-0 before:top-[0.7em] before:size-1.5 before:rounded-full before:bg-accent/70">{inlineText(item)}</li>)}
</ul>
</section>
))}
</div>
</div>
</article>
))}
</section>
</div>
</main>
);
}
export function ChangelogPage({ onOpenNavigation }: { onOpenNavigation: () => void }) {
return <div className="flex min-h-0 flex-1 flex-col"><header className="flex min-h-14 items-center border-b border-line bg-panel px-3 lg:hidden"><button type="button" aria-label="Open session navigation" onClick={onOpenNavigation} className="liquid-control flex size-10 items-center justify-center rounded"><GitBranch className="size-4" /></button><span className="ml-3 text-sm font-medium">Changelog</span></header><ChangelogTimeline markdown={changelogMarkdown} /></div>;
}