Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-01 14:32:25 +02:00
commit 9e362b5383
17 changed files with 244 additions and 155 deletions

View file

@ -48,15 +48,9 @@ SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM= SMTP_FROM=
# --- Deployment role --- # --- Scheduler ---
# DATABASE_URL is set automatically by docker-compose.yml / docker-compose.server.yml to the # The background scheduler (subscription sync, backfill, enrichment) runs inside the app.
# bundled `db` service. Override it only for local dev against the central server DB, e.g.: # DATABASE_URL is set for you by docker-compose to the bundled `db` service. If you ever run
# DATABASE_URL=postgresql+psycopg://siftlode:<password>@your-db-host:5432/siftlode # more than one instance against the same database, keep SCHEDULER_ENABLED=true on exactly one
# Exactly one instance may run the background scheduler. The central server keeps it on; every # of them and false on the rest, to avoid double quota use and write races.
# other instance (local dev, etc.) must set SCHEDULER_ENABLED=false. The compose files set this
# for you (server=true via docker-compose.server.yml, local=false via docker-compose.localdev.yml).
SCHEDULER_ENABLED=true SCHEDULER_ENABLED=true
# On the central server, set this so backup.sh / restore.sh and plain `docker compose` commands
# target the server stack automatically:
# COMPOSE_FILE=docker-compose.server.yml

165
README.md
View file

@ -1,106 +1,109 @@
# Siftlode # Siftlode
Self-hosted, multi-user web app for browsing **your own YouTube subscriptions** the way you **Your YouTube subscriptions, the way a feed should work.** Siftlode pulls every upload from the
actually want: precise filtering and sorting (by language, topic, length, age, watch state…), channels you follow into one clean, filterable feed — no algorithm deciding what you see, and no
a fast local-first feed, and one-click playback that opens the real youtube.com — so your Shorts or livestream noise unless you want it. Self-hosted, multi-user, and private: your data
browser's ad blocking and SponsorBlock keep working exactly as before. stays on your own server.
Each user signs in with their own Google account (invite-only) and sees only their own ![Siftlode feed](frontend/public/welcome/feed.png)
subscriptions. All the expensive data (channels, videos, metadata) is fetched from YouTube
once and stored locally, so filtering/searching/sorting are instant and don't burn API quota.
> Status: early development. Everything expensive (channels, videos, metadata) is fetched from YouTube once and stored locally,
> - **M1** (foundation): docker-compose stack, FastAPI backend, Google OAuth login with an so filtering, searching and sorting are instant and don't burn API quota. Click a video to watch it
> email invite-list, encrypted token storage. in an in-app player that resumes where you left off — or open it on youtube.com so your own ad
> - **M2** (ingest core): subscription import, free RSS detection, recent-first + deep backfill blocker and SponsorBlock keep working.
> from the uploads playlist, enrichment (duration/stats/category, Shorts & livestream
> classification), a shared daily quota guard, and a background scheduler.
> - **M3** (auto-tagging): system tags for channel language (offline detection) and topic
> (from YouTube topics + dominant category), regenerated automatically; user tags are
> never overwritten.
> - **M4** (reader UI): React + Vite SPA with four color schemes (dark/light) and adjustable
> text size; grid/list feed scoped to your subscriptions, with faceted tag filters,
> content-type toggles (Normal/Shorts/Live), date range, search, sort, watch/save/hide
> state and per-channel filtering; clicking a video opens youtube.com. Accurate Shorts
> detection via the /shorts probe, a sync-status indicator with admin pause/resume, a
> filtered video count, and structured timestamped logging.
## Requirements ## Features
- Docker + Docker Compose - **A readable subscription feed** — sort and filter by channel, tag, language, topic, length,
- A Google Cloud project with an OAuth client (see below) upload date or watch state; hide channels without unsubscribing; save filter setups as named views.
- **Search all of YouTube** from the feed — results play, save and add to playlists like any other
video, and are materialised into your catalog.
- **Channel pages & a channel manager** — per-channel stats and uploads, priorities, and your own
tags to slice the feed by.
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
- **In-app player** with resume, plus keyboard/scroll controls.
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English,
Hungarian and German**.
## Quick start ## Quick start (self-hosting)
1. Copy the env template and generate secrets: You don't need to build anything — Siftlode runs from a prebuilt public image, and all
configuration (your admin account, Google sign-in, email) happens in a **first-run web wizard**.
You need [Docker](https://docs.docker.com/get-docker/) with the Compose plugin.
```sh **1. Get the files and run the installer:**
cp .env.example .env
python -c "import secrets;print('SECRET_KEY=',secrets.token_urlsafe(48))"
python -c "import base64,os;print('TOKEN_ENCRYPTION_KEY=',base64.urlsafe_b64encode(os.urandom(32)).decode())"
```
Paste the generated values into `.env`.
2. Create a Google OAuth client (Google Cloud Console → APIs & Services):
- Enable the **YouTube Data API v3**.
- OAuth consent screen: **External**, publishing status **Testing**, and add every invited
Google account as a **Test user** (up to 100 — no app verification needed at this scale).
- Create credentials → **OAuth client ID** → type **Web application**.
- Authorized redirect URI: `http://localhost:8080/auth/callback`
(must match `OAUTH_REDIRECT_URL` in `.env`).
- Put the client ID/secret into `.env`, and list invited emails in `ALLOWED_EMAILS`.
3. Start it:
```sh
docker compose up --build
```
Open http://localhost:8080 and sign in. Database migrations run automatically on startup.
## Backup & moving to another machine
All data lives in the Postgres database — moving to another host (e.g. a Proxmox Linux
server) does **not** require re-fetching from YouTube. Copy your `.env` (keep the same
`TOKEN_ENCRYPTION_KEY` and Google client so stored tokens stay valid), then:
```sh ```sh
./scripts/backup.sh # -> backups/siftlode-<timestamp>.dump (Windows: scripts\backup.ps1) git clone https://forge.b1fr0st.eu/peter/siftlode.git
./scripts/restore.sh backups/<file> # on the new host after `docker compose up` cd siftlode
./install.sh # Windows (PowerShell): ./install.ps1
``` ```
## Central server + local dev (single shared database) The installer generates a private `.env` (secrets), pulls the image, starts the app + database, and
prints a one-time setup URL like `http://localhost:8080/setup?token=…`.
For always-on operation the recommended topology is a single **central Postgres** running on a **2. Finish in your browser.** Open that URL and follow the wizard:
24/7 host (e.g. a Proxmox LXC) that also runs the background scheduler, with every other instance
(your local dev machine, a future VPS) pointing at that same database. One source of truth, no
sync.
- **Server** (`docker-compose.server.yml`): full stack, Postgres published on the LAN, scheduler 1. **Admin account** — the email + password you'll sign in with.
on, DB files in a host-visible `./pgdata` bind mount. In the LXC, from the project root: 2. **Google sign-in** *(optional)* — paste a Google OAuth client to enable "Sign in with Google" and
pulling your YouTube subscriptions. Skip it to use email + password only.
3. **Email / SMTP** *(optional)* — for verification/notification emails. Skip it and you (the admin)
simply approve new accounts yourself.
```sh Then sign in with your admin account. That's it. See **[docs/self-hosting.md](docs/self-hosting.md)**
echo 'COMPOSE_FILE=docker-compose.server.yml' >> .env # so backup/restore target this stack for the full walkthrough.
docker compose up -d --build
./scripts/restore.sh backups/<file> # migrate existing data in
```
Set `YOUTUBE_API_KEY` in `.env` so unattended backfill/enrichment use the API key and don't > Just trying it out? Press Enter at the installer's URL prompt to run on `http://localhost:8080`.
depend on a refresh token (which expires after 7 days while the OAuth screen is in *Testing*).
- **Local dev** (`docker-compose.localdev.yml`): webapp only, no local DB, no scheduler. In `.env` ## Build from source (alternative)
set `DATABASE_URL` to the central DB (e.g. `…@your-db-host:5432/siftlode`), then
`docker compose -f docker-compose.localdev.yml up --build` and browse http://localhost:8080.
**Exactly one instance may run the scheduler.** The server keeps `SCHEDULER_ENABLED=true`; every Prefer to build the image yourself instead of pulling it:
other instance must be `false` (the compose files enforce this) to avoid double quota burn and
write races on the shared DB. ```sh
git clone https://forge.b1fr0st.eu/peter/siftlode.git
cd siftlode
cp .env.example .env
# generate the two secrets and paste them into .env:
python -c "import secrets;print('SECRET_KEY='+secrets.token_urlsafe(48))"
python -c "import base64,os;print('TOKEN_ENCRYPTION_KEY='+base64.urlsafe_b64encode(os.urandom(32)).decode())"
docker compose up --build -d # builds from the included Dockerfile
```
Open `http://localhost:8080` and finish in the setup wizard as above. (Set a `POSTGRES_PASSWORD` in
`.env` too.)
## HTTPS / public access
Port `8080` over plain HTTP is fine for a LAN or a quick trial. For public access put a reverse
proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the **public URL** (the installer
prompt, or `OAUTH_REDIRECT_URL` in `.env`) to your `https://…` address — this also marks the session
cookie secure. Add that same `…/auth/callback` URL to your Google OAuth client's authorized redirect
URIs.
## Updating & backups
```sh
docker compose -f docker-compose.selfhost.yml pull # or: docker compose pull
docker compose -f docker-compose.selfhost.yml up -d
```
Database migrations run automatically on startup. Your data (accounts, subscriptions, playlists, the
video catalog) lives in a Postgres volume — back it up with `scripts/backup.sh` (or `backup.ps1` on
Windows) and restore with `scripts/restore.sh`.
## How it works
- **Shared catalog, private state.** Channels and videos are stored once and shared; each user's
subscriptions, tags, playlists and watch/save/hide state are private.
- **Cheap by design.** Public reads are cached locally; a shared daily quota budget and a background
scheduler keep unattended syncing within YouTube's free API limits. An optional API key lets
backfill run without depending on a user's OAuth token.
## Tech ## Tech
FastAPI + PostgreSQL backend, React + Vite frontend (added in a later milestone), packaged with FastAPI + PostgreSQL (SQLAlchemy, Alembic) backend; React + Vite + Tailwind + TanStack Query
Docker Compose. frontend; packaged as a single Docker image with Docker Compose.
## Note ## Note

View file

@ -1 +1 @@
0.20.1 0.20.2

View file

@ -805,4 +805,6 @@ def auth_config(db: Session = Depends(get_db)) -> dict:
return { return {
"google_enabled": google_enabled(db), "google_enabled": google_enabled(db),
"allow_registration": sysconfig.get_bool(db, "allow_registration"), "allow_registration": sysconfig.get_bool(db, "allow_registration"),
# Contact shown on the public legal pages (the operator's admin email), if configured.
"operator_contact": operator_contact(),
} }

View file

@ -66,8 +66,8 @@ class Settings(BaseSettings):
# preferred for shared backfill/enrichment so it doesn't depend on a specific user. # preferred for shared backfill/enrichment so it doesn't depend on a specific user.
youtube_api_key: str = "" youtube_api_key: str = ""
# Optional HTTP(S) proxy for outbound YouTube Data API calls. Set this to send the # Optional HTTP(S) proxy for outbound YouTube Data API calls. Set this to send the
# scheduler's googleapis traffic through a fixed-IP host (e.g. the VPS over WireGuard) so an # scheduler's googleapis traffic through a fixed-IP host so an IP-restricted API key
# IP-restricted API key keeps working even when this host's public IP is dynamic. # keeps working even when this host's public IP is dynamic.
youtube_api_proxy: str = "" youtube_api_proxy: str = ""
# Daily quota budget (units). Default leaves headroom under the 10,000/day free limit. # Daily quota budget (units). Default leaves headroom under the 10,000/day free limit.
quota_daily_budget: int = 9000 quota_daily_budget: int = 9000

View file

@ -44,8 +44,8 @@ class YouTubeClient:
def __init__(self, db, user: User): def __init__(self, db, user: User):
self.db = db self.db = db
self.user = user self.user = user
# Optionally route all YouTube traffic through a fixed-IP egress proxy (e.g. the VPS # Optionally route all YouTube traffic through a fixed-IP egress proxy so an
# over WireGuard) so an IP-restricted API key keeps working from a dynamic-IP host. # IP-restricted API key keeps working from a host with a dynamic public IP.
proxy = sysconfig.get_str(db, "youtube_api_proxy") or None proxy = sysconfig.get_str(db, "youtube_api_proxy") or None
self._http = httpx.Client(timeout=30.0, proxy=proxy) self._http = httpx.Client(timeout=30.0, proxy=proxy)

View file

@ -46,7 +46,7 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
# Harmless on Docker Desktop; needed if ever run under Docker-in-LXC. # Harmless on Docker Desktop; needed on some nested/hardened Docker hosts.
security_opt: security_opt:
- apparmor:unconfined - apparmor:unconfined
ports: ports:

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Code2,
Expand, Expand,
Filter, Filter,
Inbox, Inbox,
@ -11,6 +12,9 @@ import {
X, X,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
// Siftlode is open source; the landing footer links to the public repository.
const REPO_URL = "https://forge.b1fr0st.eu/peter/siftlode";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { HttpError } from "../lib/api"; import { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n"; import { setLanguage, type LangCode } from "../i18n";
@ -102,6 +106,15 @@ export default function Welcome() {
<a href="/terms" className="hover:text-fg transition"> <a href="/terms" className="hover:text-fg transition">
{t("common.termsOfService")} {t("common.termsOfService")}
</a> </a>
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 hover:text-fg transition"
>
<Code2 className="w-3.5 h-3.5" />
{t("common.sourceCode")}
</a>
</footer> </footer>
</div> </div>
); );

View file

@ -1,10 +1,47 @@
// Shared shell for the public, login-free legal pages (/privacy, /terms). These must be // Shared shell for the public, login-free legal pages (/privacy, /terms). These must be
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers // reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
// fetch them directly — so they render outside the authenticated App tree (see main.tsx). // fetch them directly — so they render outside the authenticated App tree (see main.tsx),
// with no react-query provider: the operator contact is fetched with a plain fetch below.
import { useEffect, useState } from "react";
export const CONTACT_EMAIL = "b1fr0stmailops@gmail.com";
export const LAST_UPDATED = "June 14, 2026"; export const LAST_UPDATED = "June 14, 2026";
// Contact shown on the legal pages: the instance operator's configured admin email, from the
// public /auth/config. One shared fetch across the page; null when the operator set none.
let contactPromise: Promise<string | null> | null = null;
function fetchOperatorContact(): Promise<string | null> {
if (!contactPromise) {
contactPromise = fetch("/auth/config")
.then((r) => (r.ok ? r.json() : null))
.then((c) => (c?.operator_contact as string | undefined) ?? null)
.catch(() => null);
}
return contactPromise;
}
export function useOperatorContact(): string | null {
const [contact, setContact] = useState<string | null>(null);
useEffect(() => {
let alive = true;
fetchOperatorContact().then((c) => alive && setContact(c));
return () => {
alive = false;
};
}, []);
return contact;
}
// The operator's contact rendered as a mailto link, or a neutral fallback phrase when unset.
export function ContactEmail() {
const email = useOperatorContact();
if (!email) return <>your instance&apos;s administrator</>;
return (
<a href={`mailto:${email}`} className="text-accent hover:underline">
{email}
</a>
);
}
export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) { export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
return ( return (
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline"> <a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
@ -20,6 +57,7 @@ export default function LegalLayout({
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const contact = useOperatorContact();
return ( return (
<div className="min-h-screen bg-bg text-fg"> <div className="min-h-screen bg-bg text-fg">
<div className="mx-auto w-[min(92vw,720px)] py-12"> <div className="mx-auto w-[min(92vw,720px)] py-12">
@ -35,7 +73,9 @@ export default function LegalLayout({
<a href="/" className="hover:text-fg transition">Home</a> <a href="/" className="hover:text-fg transition">Home</a>
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a> <a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
<a href="/terms" className="hover:text-fg transition">Terms of Service</a> <a href="/terms" className="hover:text-fg transition">Terms of Service</a>
<a href={`mailto:${CONTACT_EMAIL}`} className="hover:text-fg transition">Contact</a> {contact && (
<a href={`mailto:${contact}`} className="hover:text-fg transition">Contact</a>
)}
</footer> </footer>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout"; import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
return ( return (
@ -71,11 +71,7 @@ export default function PrivacyPolicy() {
myaccount.google.com/permissions myaccount.google.com/permissions
</LegalLink> </LegalLink>
; once revoked, the stored tokens can no longer be used. To have your account and ; once revoked, the stored tokens can no longer be used. To have your account and
associated data deleted, email{" "} associated data deleted, contact <ContactEmail />.
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
{CONTACT_EMAIL}
</a>
.
</p> </p>
<H2>Cookies</H2> <H2>Cookies</H2>
@ -100,11 +96,7 @@ export default function PrivacyPolicy() {
<H2>Contact</H2> <H2>Contact</H2>
<p> <p>
Questions about this policy or your data:{" "} Questions about this policy or your data: contact <ContactEmail />.
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
{CONTACT_EMAIL}
</a>
.
</p> </p>
</LegalLayout> </LegalLayout>
); );

View file

@ -1,4 +1,4 @@
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout"; import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
export default function Terms() { export default function Terms() {
return ( return (
@ -56,11 +56,7 @@ export default function Terms() {
<H2>Contact</H2> <H2>Contact</H2>
<p> <p>
Questions:{" "} Questions: contact <ContactEmail />.
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
{CONTACT_EMAIL}
</a>
.
</p> </p>
</LegalLayout> </LegalLayout>
); );

View file

@ -1,6 +1,7 @@
{ {
"privacyPolicy": "Datenschutzerklärung", "privacyPolicy": "Datenschutzerklärung",
"termsOfService": "Nutzungsbedingungen", "termsOfService": "Nutzungsbedingungen",
"sourceCode": "Quellcode",
"close": "Schließen", "close": "Schließen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen", "confirm": "Bestätigen",

View file

@ -1,6 +1,7 @@
{ {
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service", "termsOfService": "Terms of Service",
"sourceCode": "Source code",
"close": "Close", "close": "Close",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",

View file

@ -1,6 +1,7 @@
{ {
"privacyPolicy": "Adatvédelmi irányelvek", "privacyPolicy": "Adatvédelmi irányelvek",
"termsOfService": "Felhasználási feltételek", "termsOfService": "Felhasználási feltételek",
"sourceCode": "Forráskód",
"close": "Bezárás", "close": "Bezárás",
"cancel": "Mégse", "cancel": "Mégse",
"confirm": "Megerősítés", "confirm": "Megerősítés",

View file

@ -773,8 +773,11 @@ export const api = {
}), }),
// Public: which sign-in options this instance offers (e.g. hide Google when not configured). // Public: which sign-in options this instance offers (e.g. hide Google when not configured).
authConfig: (): Promise<{ google_enabled: boolean; allow_registration: boolean }> => authConfig: (): Promise<{
req("/auth/config"), google_enabled: boolean;
allow_registration: boolean;
operator_contact: string | null;
}> => req("/auth/config"),
// --- first-run install wizard (token from the setup URL printed to the container logs) --- // --- first-run install wizard (token from the setup URL printed to the container logs) ---
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"), setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),

View file

@ -35,11 +35,65 @@ export function useHistorySubview<T>(root: T): {
} }
// --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- // --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
const overlayStack: number[] = []; // A SINGLE module-level popstate handler owns the whole overlay stack, and history is kept in
// sync LAZILY via a coalesced microtask rather than eagerly per mount/unmount. This matters for
// modal→modal handoffs (e.g. About → Release notes): the leaving modal unmounts and the entering
// one mounts in the SAME React commit, so within one tick the desired overlay-entry count drops
// by one then rises by one — a net zero. Reconciling once, after the tick settles, sees that net
// zero and touches history NOT AT ALL: the new modal simply reuses the old one's entry.
//
// The previous eager design pushed on mount and called history.back() on unmount independently.
// Interleaved during a handoff that produced (a) a flash-and-vanish where the new modal's popstate
// listener mistook the leaving modal's back() for a genuine user Back and closed itself, and
// (b) a mangled history where the pointer ended up BEHIND the surviving entry, so a later browser
// Back walked off the app entirely instead of closing the modal. Coalescing removes both.
type Overlay = { token: number; cb: () => void };
const overlayStack: Overlay[] = [];
let overlayCounter = 0; let overlayCounter = 0;
// Set while we pop our OWN entry programmatically (button/ESC close) so the other overlays' // Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
// popstate handlers don't mistake it for a user Back and close themselves too. let historyDepth = 0;
let suppressPop = false; // Programmatic history.back() calls still awaiting their popstate — each must be ignored.
let pendingProgrammatic = 0;
let reconcileScheduled = false;
let listenerInstalled = false;
// Push/pop history entries until their count matches the live overlay count. Runs once per tick.
function reconcileHistory(): void {
reconcileScheduled = false;
const want = overlayStack.length;
while (historyDepth < want) {
historyDepth++;
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
}
while (historyDepth > want) {
historyDepth--;
pendingProgrammatic++;
window.history.back();
}
}
function scheduleReconcile(): void {
if (reconcileScheduled) return;
reconcileScheduled = true;
queueMicrotask(reconcileHistory);
}
function ensureOverlayListener(): void {
if (listenerInstalled) return;
listenerInstalled = true;
window.addEventListener("popstate", () => {
if (pendingProgrammatic > 0) {
pendingProgrammatic--; // our own back(): consume and ignore
return;
}
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost
// overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs.
if (historyDepth > 0) historyDepth--;
const top = overlayStack.pop();
if (top) top.cb();
scheduleReconcile();
});
}
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via /** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */ * `onClose` instead of navigating away. Mount the component only while the overlay is open. */
@ -47,38 +101,16 @@ export function useBackToClose(onClose: () => void): void {
const cb = useRef(onClose); const cb = useRef(onClose);
cb.current = onClose; cb.current = onClose;
useEffect(() => { useEffect(() => {
const token = ++overlayCounter; ensureOverlayListener();
overlayStack.push(token); const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
window.history.pushState({ ...window.history.state, _ov: token }, ""); overlayStack.push(entry);
const onPop = () => { scheduleReconcile();
if (suppressPop) {
suppressPop = false;
return;
}
if (overlayStack[overlayStack.length - 1] === token) {
overlayStack.pop();
cb.current();
}
};
window.addEventListener("popstate", onPop);
return () => { return () => {
window.removeEventListener("popstate", onPop); const idx = overlayStack.indexOf(entry);
const idx = overlayStack.lastIndexOf(token); // idx === -1 means the shared handler already removed us on a real user Back; either way,
if (idx !== -1) { // just re-sync history to the (possibly unchanged) overlay count on the next tick.
// Closed programmatically (not via Back): drop our own history entry, and tell the if (idx !== -1) overlayStack.splice(idx, 1);
// remaining overlays' handlers to ignore the resulting popstate. A one-shot listener scheduleReconcile();
// clears the flag on that very popstate even when NO overlay remains underneath to
// consume it — otherwise suppressPop leaks `true` and silently swallows the NEXT
// overlay's first Back (reopen-after-button-close → first Back does nothing).
overlayStack.splice(idx, 1);
suppressPop = true;
const clearSuppress = () => {
suppressPop = false;
window.removeEventListener("popstate", clearSuppress);
};
window.addEventListener("popstate", clearSuppress);
window.history.back();
}
}; };
}, []); }, []);
} }

View file

@ -14,6 +14,17 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.20.2",
date: "2026-07-01",
summary: "A pop-up flash fix, and an open-source link on the sign-in page.",
features: [
"Siftlode is open source — the sign-in page now links to its public source code.",
],
fixes: [
"Opening one pop-up from inside another — for example Release Notes from the About box — no longer makes it flash and vanish instantly. It opens and stays put, and Back closes it as expected.",
],
},
{ {
version: "0.20.1", version: "0.20.1",
date: "2026-07-01", date: "2026-07-01",