feat(release-notes): terse bilingual notes as data files — new/fixed only, the dialog lists the latest ten (E28)
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bug, Sparkles } from "lucide-react";
|
||||
import { RELEASE_NOTES } from "../lib/releaseNotes";
|
||||
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
|
||||
@@ -11,8 +16,12 @@ function fmtDate(d: string, lang: string): string {
|
||||
return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang);
|
||||
}
|
||||
|
||||
// Public release notes (chores are intentionally omitted here). `highlight` rings the
|
||||
// version the user just updated to when opened from the new-version banner.
|
||||
// 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,
|
||||
@@ -21,65 +30,86 @@ export default function ReleaseNotes({
|
||||
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 active = RELEASE_NOTES.some((r) => r.version === highlight)
|
||||
? highlight
|
||||
: RELEASE_NOTES[0]?.version;
|
||||
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 title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl">
|
||||
<div className="flex flex-col gap-6">
|
||||
{RELEASE_NOTES.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 flex-wrap">
|
||||
<h3 className="text-base font-semibold">v{r.version}</h3>
|
||||
<span className="text-xs text-muted">{fmtDate(r.date, i18n.language)}</span>
|
||||
{r.sha && (
|
||||
<span className="text-2xs font-mono text-muted bg-surface px-1.5 py-0.5 rounded-sm">
|
||||
{r.sha}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.summary && <p className="text-sm text-muted mt-1">{r.summary}</p>}
|
||||
|
||||
{r.features?.length ? (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
|
||||
<Sparkles className="w-3.5 h-3.5" /> {t("about.new")}
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{r.features.map((f, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="text-accent">•</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{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>
|
||||
) : null}
|
||||
|
||||
{r.fixes?.length ? (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
<Bug className="w-3.5 h-3.5" /> {t("about.fixes")}
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{r.fixes.map((f, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="text-muted">•</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user