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:
parent
a93ab30fb2
commit
82f0936ca7
9 changed files with 347 additions and 1 deletions
73
frontend/src/components/About.tsx
Normal file
73
frontend/src/components/About.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
56
frontend/src/components/Modal.tsx
Normal file
56
frontend/src/components/Modal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/ReleaseNotes.tsx
Normal file
76
frontend/src/components/ReleaseNotes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/VersionBanner.tsx
Normal file
43
frontend/src/components/VersionBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue