From a93ab30fb2edcbe2cc405aaa47917ca2ebf06cad Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 00:06:57 +0200 Subject: [PATCH 1/2] feat(version): /api/version + build-time version/commit stamping Add a VERSION file (0.1.0) and inject APP_VERSION/GIT_SHA/BUILD_DATE as Docker build-args (both stages; Vite inlines them into the SPA). New public GET /api/version returns app_version, git_sha, build_date and the Alembic head as the database revision. deploy.sh and the localdev build pass the args. --- Dockerfile | 18 +++++++++++++++++- VERSION | 1 + backend/app/config.py | 5 +++++ backend/app/main.py | 3 ++- backend/app/routes/version.py | 23 +++++++++++++++++++++++ docker-compose.localdev.yml | 4 ++++ docker-compose.yml | 4 ++++ 7 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 VERSION create mode 100644 backend/app/routes/version.py diff --git a/Dockerfile b/Dockerfile index 6f98c9b..806b8c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,15 @@ +# Build/version info, injected via --build-arg (defaults keep un-built/dev runs working). +ARG APP_VERSION=dev +ARG GIT_SHA=unknown +ARG BUILD_DATE= + # Stage 1: build the frontend SPA FROM node:20-alpine AS frontend +ARG APP_VERSION +ARG GIT_SHA +ARG BUILD_DATE +# Vite inlines VITE_*-prefixed env into the bundle at build time. +ENV VITE_APP_VERSION=$APP_VERSION VITE_GIT_SHA=$GIT_SHA VITE_BUILD_DATE=$BUILD_DATE WORKDIR /fe COPY frontend/package.json ./ RUN npm install @@ -8,10 +18,16 @@ RUN npm run build # Stage 2: backend runtime, serving the built SPA FROM python:3.12-slim +ARG APP_VERSION +ARG GIT_SHA +ARG BUILD_DATE ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + APP_VERSION=$APP_VERSION \ + GIT_SHA=$GIT_SHA \ + BUILD_DATE=$BUILD_DATE WORKDIR /app diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/backend/app/config.py b/backend/app/config.py index 808dcf6..0f796ab 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,6 +9,11 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") app_name: str = "Siftlode" + # Build/version info, injected at image build time (Docker build-args -> env). Defaults + # apply for un-built/dev runs; the About dialog and version banner surface these. + app_version: str = "dev" + git_sha: str = "unknown" + build_date: str = "" database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed" diff --git a/backend/app/main.py b/backend/app/main.py index 7b1eca8..cb07a68 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth from app.config import settings -from app.routes import admin, channels, feed, health, me, quota, sync, tags +from app.routes import admin, channels, feed, health, me, quota, sync, tags, version from app.scheduler import shutdown_scheduler, start_scheduler @@ -68,6 +68,7 @@ app.include_router(me.router) app.include_router(channels.router) app.include_router(admin.router) app.include_router(quota.router) +app.include_router(version.router) # The built SPA (populated by the Docker frontend build stage). STATIC_DIR = Path(__file__).parent / "static_spa" diff --git a/backend/app/routes/version.py b/backend/app/routes/version.py new file mode 100644 index 0000000..ccaa87a --- /dev/null +++ b/backend/app/routes/version.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.config import settings +from app.db import get_db + +router = APIRouter(prefix="/api", tags=["version"]) + + +@router.get("/version") +def get_version(db: Session = Depends(get_db)) -> dict: + """Build + schema version, for the About dialog and the 'new version' banner. + Intentionally public (no auth) and cheap: just env-baked build info plus the current + Alembic migration head (the 'database version'). app_version/git_sha/build_date are + injected at image build time (see the Dockerfile build-args).""" + db_revision = db.execute(text("SELECT version_num FROM alembic_version")).scalar() + return { + "app_version": settings.app_version, + "git_sha": settings.git_sha, + "build_date": settings.build_date or None, + "db_revision": db_revision, + } diff --git a/docker-compose.localdev.yml b/docker-compose.localdev.yml index 9835770..1ca87f1 100644 --- a/docker-compose.localdev.yml +++ b/docker-compose.localdev.yml @@ -21,6 +21,10 @@ services: build: context: . dockerfile: Dockerfile + args: + APP_VERSION: ${APP_VERSION:-dev} + GIT_SHA: ${GIT_SHA:-unknown} + BUILD_DATE: ${BUILD_DATE:-} env_file: .env environment: # Exactly one scheduler may write to the shared DB; the server owns it. diff --git a/docker-compose.yml b/docker-compose.yml index 5ead8e5..cf9c976 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,10 @@ services: build: context: . dockerfile: Dockerfile + args: + APP_VERSION: ${APP_VERSION:-dev} + GIT_SHA: ${GIT_SHA:-unknown} + BUILD_DATE: ${BUILD_DATE:-} env_file: .env environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed} From 82f0936ca71a86c51b1328616eb0db42151db599 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 00:06:57 +0200 Subject: [PATCH 2/2] 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). --- frontend/src/App.tsx | 26 ++++++++ frontend/src/components/About.tsx | 73 ++++++++++++++++++++++ frontend/src/components/Header.tsx | 11 +++- frontend/src/components/Modal.tsx | 56 +++++++++++++++++ frontend/src/components/ReleaseNotes.tsx | 76 +++++++++++++++++++++++ frontend/src/components/VersionBanner.tsx | 43 +++++++++++++ frontend/src/lib/api.ts | 8 +++ frontend/src/lib/releaseNotes.ts | 45 ++++++++++++++ frontend/src/lib/version.ts | 10 +++ 9 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/About.tsx create mode 100644 frontend/src/components/Modal.tsx create mode 100644 frontend/src/components/ReleaseNotes.tsx create mode 100644 frontend/src/components/VersionBanner.tsx create mode 100644 frontend/src/lib/releaseNotes.ts create mode 100644 frontend/src/lib/version.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d157e0..d517734 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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("all"); + const [aboutOpen, setAboutOpen] = useState(false); + const [notesOpen, setNotesOpen] = useState(false); + const [notesHighlight, setNotesHighlight] = useState(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"); }} /> + openReleaseNotes(CURRENT_VERSION)} />
{page === "feed" && ( setWizardOpen(false)} /> )} + {aboutOpen && ( + setAboutOpen(false)} + onOpenReleaseNotes={() => { + setAboutOpen(false); + openReleaseNotes(); + }} + /> + )} + {notesOpen && ( + setNotesOpen(false)} highlight={notesHighlight} /> + )}
); diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx new file mode 100644 index 0000000..2e0a379 --- /dev/null +++ b/frontend/src/components/About.tsx @@ -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 ( + + About + + } + onClose={onClose} + > +
+
+ Siftlode +
+
v{FRONTEND_VERSION}
+
+

+ Self-hosted, multi-user reader for your own YouTube subscriptions. +

+ +
+ {rows.map(([k, v], i) => ( +
+ {k} + {v} +
+ ))} +
+ + +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 70190dd..cdcb060 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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} /> @@ -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 | null>(null); @@ -161,6 +166,10 @@ function AccountMenu({ Settings + + +
{children}
+ + , + document.body + ); +} diff --git a/frontend/src/components/ReleaseNotes.tsx b/frontend/src/components/ReleaseNotes.tsx new file mode 100644 index 0000000..2e50063 --- /dev/null +++ b/frontend/src/components/ReleaseNotes.tsx @@ -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 ( + +
+ {RELEASE_NOTES.map((r) => ( +
+
+

v{r.version}

+ {fmtDate(r.date)} + {r.sha && ( + + {r.sha} + + )} +
+ {r.summary &&

{r.summary}

} + + {r.features?.length ? ( +
+
+ New +
+
    + {r.features.map((f, i) => ( +
  • + + {f} +
  • + ))} +
+
+ ) : null} + + {r.fixes?.length ? ( +
+
+ Fixes +
+
    + {r.fixes.map((f, i) => ( +
  • + + {f} +
  • + ))} +
+
+ ) : null} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/VersionBanner.tsx b/frontend/src/components/VersionBanner.tsx new file mode 100644 index 0000000..a0cd22f --- /dev/null +++ b/frontend/src/components/VersionBanner.tsx @@ -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 ( +
+ + + Updated to v{FRONTEND_VERSION} — see what's changed. + + + +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 618ab97..e5a088c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => req("/api/me"), + version: (): Promise => req("/api/version"), tags: (): Promise => req("/api/tags"), status: (): Promise => req("/api/sync/status"), feed: (f: FeedFilters, offset: number, limit: number): Promise => diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts new file mode 100644 index 0000000..3b20378 --- /dev/null +++ b/frontend/src/lib/releaseNotes.ts @@ -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"; diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts new file mode 100644 index 0000000..61231fb --- /dev/null +++ b/frontend/src/lib/version.ts @@ -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 }).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";