177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { usePathname, useRouter, useSearchParams } from "@/lib/routing/client-router";
|
|
import ReactMarkdown from "react-markdown";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
PaginationEllipsis,
|
|
} from "@kandev/ui/pagination";
|
|
import { IconExternalLink } from "@tabler/icons-react";
|
|
import {
|
|
markdownComponents,
|
|
rehypePlugins,
|
|
remarkPlugins,
|
|
} from "@/components/shared/markdown-components";
|
|
import { getChangelog, type ChangelogEntry } from "@/lib/changelog";
|
|
import { getReleaseUrl } from "@/lib/release-notes";
|
|
|
|
const PAGE_SIZE = 10;
|
|
const PAGE_PARAM = "page";
|
|
|
|
function parsePageParam(raw: string | null, totalPages: number): number {
|
|
if (!raw) return 1;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (Number.isNaN(parsed) || parsed < 1) return 1;
|
|
if (parsed > totalPages) return totalPages;
|
|
return parsed;
|
|
}
|
|
|
|
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
|
const { t } = useTranslation();
|
|
const releaseUrl = getReleaseUrl(entry.version);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
{/* Version, date and the release notes body are all release data. */}
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Badge variant="secondary">v{entry.version}</Badge>
|
|
{entry.date && (
|
|
<span className="text-sm font-normal text-muted-foreground">{entry.date}</span>
|
|
)}
|
|
</CardTitle>
|
|
<a
|
|
href={releaseUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
>
|
|
{t("system:changelogViewOnGitHub")}
|
|
<IconExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="markdown-body text-sm">
|
|
<ReactMarkdown
|
|
remarkPlugins={remarkPlugins}
|
|
rehypePlugins={rehypePlugins}
|
|
components={markdownComponents}
|
|
>
|
|
{entry.notes}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function buildPageNumbers(currentPage: number, totalPages: number): (number | "ellipsis")[] {
|
|
if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
|
|
const pages: (number | "ellipsis")[] = [1];
|
|
if (currentPage > 3) pages.push("ellipsis");
|
|
|
|
const start = Math.max(2, currentPage - 1);
|
|
const end = Math.min(totalPages - 1, currentPage + 1);
|
|
for (let i = start; i <= end; i++) pages.push(i);
|
|
|
|
if (currentPage < totalPages - 2) pages.push("ellipsis");
|
|
pages.push(totalPages);
|
|
|
|
return pages;
|
|
}
|
|
|
|
export function ChangelogList() {
|
|
const { t } = useTranslation();
|
|
const changelog = getChangelog();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
|
|
const totalPages = Math.max(1, Math.ceil(changelog.length / PAGE_SIZE));
|
|
const currentPage = parsePageParam(searchParams.get(PAGE_PARAM), totalPages);
|
|
|
|
const setPage = useCallback(
|
|
(next: number) => {
|
|
const clamped = Math.min(Math.max(1, next), totalPages);
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (clamped === 1) {
|
|
params.delete(PAGE_PARAM);
|
|
} else {
|
|
params.set(PAGE_PARAM, String(clamped));
|
|
}
|
|
const qs = params.toString();
|
|
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
|
},
|
|
[pathname, router, searchParams, totalPages],
|
|
);
|
|
|
|
const pageEntries = useMemo(() => {
|
|
const start = (currentPage - 1) * PAGE_SIZE;
|
|
return changelog.slice(start, start + PAGE_SIZE);
|
|
}, [changelog, currentPage]);
|
|
|
|
if (changelog.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">{t("system:changelogEmpty")}</p>;
|
|
}
|
|
|
|
const pageNumbers = buildPageNumbers(currentPage, totalPages);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{pageEntries.map((entry) => (
|
|
<ChangelogEntryCard key={entry.version} entry={entry} />
|
|
))}
|
|
{totalPages > 1 && (
|
|
<Pagination data-testid="changelog-pagination">
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<PaginationPrevious
|
|
onClick={() => setPage(currentPage - 1)}
|
|
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
/>
|
|
</PaginationItem>
|
|
{pageNumbers.map((page, i) =>
|
|
page === "ellipsis" ? (
|
|
<PaginationItem key={`ellipsis-${i}`}>
|
|
<PaginationEllipsis />
|
|
</PaginationItem>
|
|
) : (
|
|
<PaginationItem key={page}>
|
|
<PaginationLink
|
|
isActive={currentPage === page}
|
|
onClick={() => setPage(page)}
|
|
className="cursor-pointer"
|
|
data-testid={`changelog-page-${page}`}
|
|
>
|
|
{page}
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
),
|
|
)}
|
|
<PaginationItem>
|
|
<PaginationNext
|
|
onClick={() => setPage(currentPage + 1)}
|
|
className={
|
|
currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"
|
|
}
|
|
/>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
</Pagination>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|