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
882429d6af
commit
31591d8ff1
9 changed files with 347 additions and 1 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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> =>
|
||||
|
|
|
|||
45
frontend/src/lib/releaseNotes.ts
Normal file
45
frontend/src/lib/releaseNotes.ts
Normal 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";
|
||||
10
frontend/src/lib/version.ts
Normal file
10
frontend/src/lib/version.ts
Normal 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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue