diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx
index abf494d..1c16729 100644
--- a/frontend/src/components/Welcome.tsx
+++ b/frontend/src/components/Welcome.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
+ Code2,
Expand,
Filter,
Inbox,
@@ -11,6 +12,9 @@ import {
X,
type LucideIcon,
} 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 { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
@@ -102,6 +106,15 @@ export default function Welcome() {
{t("common.termsOfService")}
+
+
+ {t("common.sourceCode")}
+
);
diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json
index f5ca1be..854bffd 100644
--- a/frontend/src/i18n/locales/de/common.json
+++ b/frontend/src/i18n/locales/de/common.json
@@ -1,6 +1,7 @@
{
"privacyPolicy": "Datenschutzerklärung",
"termsOfService": "Nutzungsbedingungen",
+ "sourceCode": "Quellcode",
"close": "Schließen",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json
index 3091ff2..3985a4e 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -1,6 +1,7 @@
{
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service",
+ "sourceCode": "Source code",
"close": "Close",
"cancel": "Cancel",
"confirm": "Confirm",
diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json
index b5b9efb..2243ad7 100644
--- a/frontend/src/i18n/locales/hu/common.json
+++ b/frontend/src/i18n/locales/hu/common.json
@@ -1,6 +1,7 @@
{
"privacyPolicy": "Adatvédelmi irányelvek",
"termsOfService": "Felhasználási feltételek",
+ "sourceCode": "Forráskód",
"close": "Bezárás",
"cancel": "Mégse",
"confirm": "Megerősítés",
diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts
index 270fff2..d921e59 100644
--- a/frontend/src/lib/history.ts
+++ b/frontend/src/lib/history.ts
@@ -35,11 +35,65 @@ export function useHistorySubview(root: T): {
}
// --- 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;
-// Set while we pop our OWN entry programmatically (button/ESC close) so the other overlays'
-// popstate handlers don't mistake it for a user Back and close themselves too.
-let suppressPop = false;
+// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
+let historyDepth = 0;
+// 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
* `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);
cb.current = onClose;
useEffect(() => {
- const token = ++overlayCounter;
- overlayStack.push(token);
- window.history.pushState({ ...window.history.state, _ov: token }, "");
- const onPop = () => {
- if (suppressPop) {
- suppressPop = false;
- return;
- }
- if (overlayStack[overlayStack.length - 1] === token) {
- overlayStack.pop();
- cb.current();
- }
- };
- window.addEventListener("popstate", onPop);
+ ensureOverlayListener();
+ const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
+ overlayStack.push(entry);
+ scheduleReconcile();
return () => {
- window.removeEventListener("popstate", onPop);
- const idx = overlayStack.lastIndexOf(token);
- if (idx !== -1) {
- // Closed programmatically (not via Back): drop our own history entry, and tell the
- // remaining overlays' handlers to ignore the resulting popstate. A one-shot listener
- // 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();
- }
+ const idx = overlayStack.indexOf(entry);
+ // idx === -1 means the shared handler already removed us on a real user Back; either way,
+ // just re-sync history to the (possibly unchanged) overlay count on the next tick.
+ if (idx !== -1) overlayStack.splice(idx, 1);
+ scheduleReconcile();
};
}, []);
}