feat(ui): About dialog, Release Notes, and new-version banner

About (in the account menu) shows frontend/backend/database versions + build.
Release Notes renders per-version highlights with a commit-SHA reference; a
dismissible banner appears once after the running build's version changes and
links into the notes. Adds a reusable Modal shell and the release-notes data
(detailed v0.1.0).
This commit is contained in:
npeter83 2026-06-15 00:06:57 +02:00
parent 882429d6af
commit 31591d8ff1
9 changed files with 347 additions and 1 deletions

View file

@ -0,0 +1,73 @@
import { useQuery } from "@tanstack/react-query";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
import Modal from "./Modal";
function fmtDate(d?: string | null): string {
if (!d) return "—";
const t = new Date(d);
return isNaN(t.getTime()) ? d : t.toLocaleString();
}
// About dialog: app + build + schema versions (frontend/backend/database), plus a
// shortcut into the release notes.
export default function About({
onClose,
onOpenReleaseNotes,
}: {
onClose: () => void;
onOpenReleaseNotes: () => void;
}) {
const { data } = useQuery({ queryKey: ["version"], queryFn: api.version, staleTime: 5 * 60_000 });
const rows: [string, string][] = [
["Frontend", FRONTEND_VERSION + (FRONTEND_SHA !== "unknown" ? ` · ${FRONTEND_SHA}` : "")],
[
"Backend",
data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…",
],
["Database", data?.db_revision ?? "…"],
["Build date", fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE)],
];
return (
<Modal
title={
<span className="flex items-center gap-2">
<Info className="w-5 h-5 text-accent" /> About
</span>
}
onClose={onClose}
>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<div className="text-sm text-muted">v{FRONTEND_VERSION}</div>
</div>
<p className="text-sm text-muted mt-2">
Self-hosted, multi-user reader for your own YouTube subscriptions.
</p>
<div className="mt-4 rounded-xl border border-border overflow-hidden text-sm">
{rows.map(([k, v], i) => (
<div
key={k}
className={`flex justify-between gap-4 px-3 py-2 ${i % 2 ? "bg-surface/40" : ""}`}
>
<span className="text-muted">{k}</span>
<span className="font-medium text-right break-all">{v}</span>
</div>
))}
</div>
<button
onClick={onOpenReleaseNotes}
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<Sparkles className="w-4 h-4" /> What's new
</button>
</Modal>
);
}

View file

@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { BarChart3, Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import { BarChart3, Home, Info, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus";
@ -13,6 +13,7 @@ export default function Header({
page,
setPage,
onOpenSettings,
onOpenAbout,
onGoToFullHistory,
}: {
me: Me;
@ -21,6 +22,7 @@ export default function Header({
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
onOpenAbout: () => void;
onGoToFullHistory: () => void;
}) {
async function logout() {
@ -65,6 +67,7 @@ export default function Header({
page={page}
setPage={setPage}
onOpenSettings={onOpenSettings}
onOpenAbout={onOpenAbout}
/>
</div>
</div>
@ -88,12 +91,14 @@ function AccountMenu({
page,
setPage,
onOpenSettings,
onOpenAbout,
}: {
me: Me;
logout: () => void;
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
onOpenAbout: () => void;
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -161,6 +166,10 @@ function AccountMenu({
<Settings className="w-4 h-4" />
Settings
</button>
<button onClick={() => { onOpenAbout(); setOpen(false); }} className={itemClass}>
<Info className="w-4 h-4" />
About
</button>
<button onClick={logout} className={itemClass}>
<LogOut className="w-4 h-4" />
Sign out

View file

@ -0,0 +1,56 @@
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
export default function Modal({
title,
onClose,
children,
maxWidth = "max-w-lg",
}: {
title: ReactNode;
onClose: () => void;
children: ReactNode;
maxWidth?: string;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className={`glass-card relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
<button
onClick={onClose}
title="Close"
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 pb-5">{children}</div>
</div>
</div>,
document.body
);
}

View file

@ -0,0 +1,76 @@
import { Bug, Sparkles } from "lucide-react";
import { RELEASE_NOTES } from "../lib/releaseNotes";
import Modal from "./Modal";
function fmtDate(d: string): string {
const t = new Date(d);
return isNaN(t.getTime())
? d
: t.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
// Public release notes (chores are intentionally omitted here). `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;
}) {
return (
<Modal title="Release notes" onClose={onClose} maxWidth="max-w-2xl">
<div className="flex flex-col gap-6">
{RELEASE_NOTES.map((r) => (
<section
key={r.version}
className={highlight === 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)}</span>
{r.sha && (
<span className="text-[11px] font-mono text-muted bg-surface px-1.5 py-0.5 rounded">
{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" /> 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>
</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" /> 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>
</Modal>
);
}

View file

@ -0,0 +1,43 @@
import { useState } from "react";
import { Sparkles, X } from "lucide-react";
import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version";
// Eye-catching, dismissible bar shown once after the running build's version changes
// (compares the baked frontend version to the last one the user acknowledged).
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
const [dismissed, setDismissed] = useState(false);
const seen = localStorage.getItem(SEEN_VERSION_KEY);
// Don't nag in un-stamped dev builds, or once this version has been seen.
const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION && !dismissed;
if (!show) return null;
function markSeen() {
localStorage.setItem(SEEN_VERSION_KEY, FRONTEND_VERSION);
setDismissed(true);
}
return (
<div className="shrink-0 flex items-center gap-2 px-4 py-2 text-sm bg-accent/15 border-b border-accent/30 text-fg">
<Sparkles className="w-4 h-4 text-accent shrink-0" />
<span className="min-w-0">
Updated to <span className="font-semibold">v{FRONTEND_VERSION}</span> see what's changed.
</span>
<button
onClick={() => {
onOpen();
markSeen();
}}
className="ml-1 px-2.5 py-1 rounded-lg font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
Release notes
</button>
<button
onClick={markSeen}
title="Dismiss"
className="ml-auto p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
);
}