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

@ -27,6 +27,10 @@ import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster";
import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner";
import { CURRENT_VERSION } from "./lib/releaseNotes";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
@ -65,6 +69,14 @@ export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
setNotesOpen(true);
}
function setFilters(next: FeedFilters) {
setFiltersState(next);
@ -158,11 +170,13 @@ export default function App() {
page={page}
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
onOpenAbout={() => setAboutOpen(true)}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setPage("channels");
}}
/>
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar
@ -213,6 +227,18 @@ export default function App() {
{wizardOpen && meQuery.data && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
{aboutOpen && (
<About
onClose={() => setAboutOpen(false)}
onOpenReleaseNotes={() => {
setAboutOpen(false);
openReleaseNotes();
}}
/>
)}
{notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)}
<Toaster />
</div>
);

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>
);
}

View file

@ -86,6 +86,13 @@ export interface FeedFilters {
dateTo?: string;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
build_date: string | null;
db_revision: string | null;
}
class HttpError extends Error {
status: number;
constructor(status: number) {
@ -221,6 +228,7 @@ export interface ManagedChannel {
export const api = {
me: (): Promise<Me> => req("/api/me"),
version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>

View file

@ -0,0 +1,45 @@
// User-facing release notes. One entry per released version, newest first. The end-of-line
// `sha` is the repo commit the release was cut from (our stand-in for an issue/ticket ref);
// it's attached when the version is tagged. `chores` are internal and hidden from the public
// Release Notes dialog by default.
export interface ReleaseEntry {
version: string;
date: string; // ISO date (YYYY-MM-DD)
sha?: string; // short commit the release was tagged from
summary?: string;
features?: string[];
fixes?: string[];
chores?: string[];
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.1.0",
date: "2026-06-15",
// sha filled in when v0.1.0 is tagged at release time.
summary:
"First release: a self-hosted, multi-user reader for your own YouTube subscriptions — " +
"fast local-first feed with rich filtering, in-app playback with resume, and channel management.",
features: [
"Multi-user feed of your own YouTube subscriptions: invite-only Google sign-in, per-user watch/save/hide state, encrypted token storage.",
"Ingest: subscription import, free RSS new-video detection, recent-first and full-history backfill, metadata enrichment (duration, views, category, Shorts & livestream classification), a shared daily API-quota guard, and a background scheduler.",
"Automatic tagging: system tags for channel language and topic (your own tags are never overwritten).",
"Reader UI: grid/list feed scoped to your subscriptions; faceted tag filters, content-type toggles (Normal / Shorts / Live), date range, search and sort; four colour schemes (dark/light) with adjustable text size.",
"In-app player: a modal YouTube player that resumes where you left off, auto-marks watched near the end, and turns description timestamps and links into clickable seeks.",
"Watch progress: a resume progress bar on video cards, Continue / Restart buttons, and an “In progress” feed filter — your position is saved server-side, so it follows you across devices.",
"Channel manager: per-channel priority, hide, personal tags, sync badges, and per-channel full-history opt-in.",
"Header status: your video count vs. the whole catalogue, sync progress, and (for admins) a pause control; onboarding wizard; notification centre; admin usage & quota stats.",
"About dialog and this Release Notes view, with a heads-up banner when a new version ships.",
],
fixes: [
"Backfill no longer skips the band of videos sitting on the 365-day boundary page.",
"A channel whose full catalogue is already stored is no longer stuck showing “needs full history”.",
],
chores: [
"Public-readiness repo hygiene: internal planning/infra details removed, build stamped with version/commit.",
],
},
];
export const CURRENT_VERSION = RELEASE_NOTES[0]?.version ?? "dev";

View file

@ -0,0 +1,10 @@
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
const env = (import.meta as unknown as { env: Record<string, string | undefined> }).env;
export const FRONTEND_VERSION = env.VITE_APP_VERSION || "dev";
export const FRONTEND_SHA = env.VITE_GIT_SHA || "unknown";
export const FRONTEND_BUILD_DATE = env.VITE_BUILD_DATE || "";
// Key for the "last version the user has seen release notes for" (drives the banner).
export const SEEN_VERSION_KEY = "siftlode.seenVersion";