Files
siftlode/frontend/src/components/ReleaseNotes.tsx
T
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00

121 lines
4.2 KiB
TypeScript

import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Bug, Sparkles } from "lucide-react";
import { loadReleaseNotes, notesLang, visibleNotes } from "../lib/releaseNotes";
import { qk } from "../lib/queryKeys";
import { formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The release date in the UI language — `toLocaleDateString(undefined, …)` formats in the BROWSER's
// locale, which put Hungarian-shaped dates in an English release-notes list. Unparseable input falls
// back to the raw string (a hand-written note could carry anything).
function fmtDate(d: string, lang: string): string {
return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang);
}
// The newest releases are what a user opens this for; the whole history (150+ versions) rendered on
// every open was what made the dialog slow to appear. The rest are one click away.
const RECENT = 10;
// Public release notes in the UI language. `highlight` rings the version the user just updated to
// when opened from the new-version banner.
export default function ReleaseNotes({
onClose,
highlight,
}: {
onClose: () => void;
highlight?: string;
}) {
const { t, i18n } = useTranslation();
const lang = notesLang(i18n.language);
const q = useQuery({
queryKey: qk.releaseNotes(lang),
queryFn: () => loadReleaseNotes(lang),
staleTime: Infinity,
});
const [showAll, setShowAll] = useState(false);
const notes = visibleNotes(q.data ?? []);
// Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build
// where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet —
// so the banner always rings *something* current instead of nothing.
const at = notes.findIndex((r) => r.version === highlight);
const active = at >= 0 ? highlight : notes[0]?.version;
const shown = showAll || at >= RECENT ? notes : notes.slice(0, RECENT);
return (
<Modal
initialFocus="close"
title={t("about.releaseNotes")}
onClose={onClose}
maxWidth="max-w-2xl"
>
{q.isError ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("about.notesError")}
</StateMessage>
) : !q.data ? (
<LoadingState />
) : (
<div className="flex flex-col gap-5">
{shown.map((r) => (
<section
key={r.version}
className={active === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""}
>
<div className="flex items-baseline gap-2">
<h3 className="text-base font-semibold">v{r.version}</h3>
<span className="text-xs text-muted">{fmtDate(r.date, i18n.language)}</span>
</div>
<Items icon={<Sparkles className="w-3.5 h-3.5" />} title={t("about.new")} accent>
{r.new}
</Items>
<Items icon={<Bug className="w-3.5 h-3.5" />} title={t("about.fixes")}>
{r.fixed}
</Items>
</section>
))}
{shown.length < notes.length && (
<Button variant="outline" className="self-center" onClick={() => setShowAll(true)}>
{t("about.olderVersions", { count: notes.length - shown.length })}
</Button>
)}
</div>
)}
</Modal>
);
}
function Items({
icon,
title,
accent,
children,
}: {
icon: ReactNode;
title: string;
accent?: boolean;
children: string[];
}) {
if (children.length === 0) return null;
const tone = accent ? "text-accent" : "text-muted";
return (
<div className="mt-2.5">
<div
className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${tone}`}
>
{icon} {title}
</div>
<ul className="mt-1 space-y-1 text-sm">
{children.map((line, i) => (
<li key={i} className="flex gap-2">
<span className={tone}>•</span>
<span>{line}</span>
</li>
))}
</ul>
</div>
);
}