Merge: release notes, About dialog, and version stamping
This commit is contained in:
commit
844fed7d2f
16 changed files with 403 additions and 3 deletions
18
Dockerfile
18
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
|
# Stage 1: build the frontend SPA
|
||||||
FROM node:20-alpine AS frontend
|
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
|
WORKDIR /fe
|
||||||
COPY frontend/package.json ./
|
COPY frontend/package.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
@ -8,10 +18,16 @@ RUN npm run build
|
||||||
|
|
||||||
# Stage 2: backend runtime, serving the built SPA
|
# Stage 2: backend runtime, serving the built SPA
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
ARG APP_VERSION
|
||||||
|
ARG GIT_SHA
|
||||||
|
ARG BUILD_DATE
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
|
||||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.1.0
|
||||||
|
|
@ -9,6 +9,11 @@ class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
app_name: str = "Siftlode"
|
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"
|
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app import auth
|
from app import auth
|
||||||
from app.config import settings
|
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
|
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(channels.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
|
app.include_router(version.router)
|
||||||
|
|
||||||
# The built SPA (populated by the Docker frontend build stage).
|
# The built SPA (populated by the Docker frontend build stage).
|
||||||
STATIC_DIR = Path(__file__).parent / "static_spa"
|
STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||||
|
|
|
||||||
23
backend/app/routes/version.py
Normal file
23
backend/app/routes/version.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,10 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
|
GIT_SHA: ${GIT_SHA:-unknown}
|
||||||
|
BUILD_DATE: ${BUILD_DATE:-}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
# Exactly one scheduler may write to the shared DB; the server owns it.
|
# Exactly one scheduler may write to the shared DB; the server owns it.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
|
GIT_SHA: ${GIT_SHA:-unknown}
|
||||||
|
BUILD_DATE: ${BUILD_DATE:-}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ import SettingsPanel from "./components/SettingsPanel";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||||
import Toaster from "./components/Toaster";
|
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 = {
|
const DEFAULT_FILTERS: FeedFilters = {
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
@ -65,6 +69,14 @@ export default function App() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
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) {
|
function setFilters(next: FeedFilters) {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
|
|
@ -158,11 +170,13 @@ export default function App() {
|
||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
onOpenAbout={() => setAboutOpen(true)}
|
||||||
onGoToFullHistory={() => {
|
onGoToFullHistory={() => {
|
||||||
setChannelFilter("needs_full");
|
setChannelFilter("needs_full");
|
||||||
setPage("channels");
|
setPage("channels");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{page === "feed" && (
|
{page === "feed" && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
@ -213,6 +227,18 @@ export default function App() {
|
||||||
{wizardOpen && meQuery.data && (
|
{wizardOpen && meQuery.data && (
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
{aboutOpen && (
|
||||||
|
<About
|
||||||
|
onClose={() => setAboutOpen(false)}
|
||||||
|
onOpenReleaseNotes={() => {
|
||||||
|
setAboutOpen(false);
|
||||||
|
openReleaseNotes();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{notesOpen && (
|
||||||
|
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
||||||
|
)}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</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 { 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 { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
|
|
@ -13,6 +13,7 @@ export default function Header({
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
onOpenAbout,
|
||||||
onGoToFullHistory,
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
|
|
@ -21,6 +22,7 @@ export default function Header({
|
||||||
page: Page;
|
page: Page;
|
||||||
setPage: (p: Page) => void;
|
setPage: (p: Page) => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
|
onOpenAbout: () => void;
|
||||||
onGoToFullHistory: () => void;
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
|
@ -65,6 +67,7 @@ export default function Header({
|
||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
|
onOpenAbout={onOpenAbout}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -88,12 +91,14 @@ function AccountMenu({
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
onOpenAbout,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
page: Page;
|
page: Page;
|
||||||
setPage: (p: Page) => void;
|
setPage: (p: Page) => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
|
onOpenAbout: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
@ -161,6 +166,10 @@ function AccountMenu({
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => { onOpenAbout(); setOpen(false); }} className={itemClass}>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
About
|
||||||
|
</button>
|
||||||
<button onClick={logout} className={itemClass}>
|
<button onClick={logout} className={itemClass}>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
Sign out
|
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;
|
dateTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
app_version: string;
|
||||||
|
git_sha: string;
|
||||||
|
build_date: string | null;
|
||||||
|
db_revision: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
constructor(status: number) {
|
constructor(status: number) {
|
||||||
|
|
@ -221,6 +228,7 @@ export interface ManagedChannel {
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: (): Promise<Me> => req("/api/me"),
|
me: (): Promise<Me> => req("/api/me"),
|
||||||
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||||
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
||||||
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
|
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