perf(watch): a shared link loads the player, not the app

A /watch link is opened by whoever it was sent to — signed out, often on a phone on mobile data —
and it booted through main.tsx: every app provider, the app's i18n (every locale of every area),
the flag sprites, ~392 KB gzip before the player could start. It now has its own HTML entry,
watch.html → watchMain.tsx, which imports WatchPage and what it renders; the server hands every
/watch/ path that page (the OG block injected as before), and vite dev does the same.

Measured on the build: /watch JS 290 → 87 KB gzip, CSS 104 → 19 KB; the app's own bytes unchanged.
Two leaks had to go for that: lib/format imported the app's i18n for one "just now" string (the
relative-time formatters now take `t`, like the file's other t-formatters), and the boundary's
crash copy came from the same i18n (CrashBoundary takes its copy; ErrorBoundary is the app's
wrapper, the watch entry gives it the page's own strings). The page's popover tree went too: it
renders no popover.

Held by: a contract test walking the entry's runtime imports, naming the path to anything only the
app may load (red on a planted i18n import in lib/format), and holding watch.html's head equal to
index.html's; a DB-lane test that every /watch/ path gets the player's page with the link's OG tags
and app paths get the app (red on the old routing). Screenshots at 375 px, EN and HU, video / audio /
password / gone / wrong password / unlocked: identical to the old entry's, bar the video's buffered bar.
This commit is contained in:
2026-10-05 20:51:39 +02:00
parent 5561d9a379
commit e886890d6e
24 changed files with 483 additions and 184 deletions
+2 -1
View File
@@ -65,7 +65,8 @@ how it gets built. Concretely:
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, `downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** `security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
+5 -4
View File
@@ -147,13 +147,14 @@ def _watch_tags(token: str, db: Session) -> str | None:
return "\n ".join(tags) return "\n ".join(tags)
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None: def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
"""Return index.html with the OG block replaced for this watch token, or None to serve the """Return the watch page (watch.html) with the OG block replaced for this token, or None to
generic page unchanged (invalid/expired/not-ready link, or a template without the markers).""" serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
markers)."""
tags = _watch_tags(token, db) tags = _watch_tags(token, db)
if tags is None: if tags is None:
return None return None
text = index_html.read_text(encoding="utf-8") text = page_html.read_text(encoding="utf-8")
if "<!--OG:START-->" not in text: if "<!--OG:START-->" not in text:
return None return None
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`. # A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
+11 -5
View File
@@ -339,6 +339,8 @@ STATIC_DIR = Path(__file__).parent / "static_spa"
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The # stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
# hashed bundles under /assets can cache forever (a new build changes their filename). # hashed bundles under /assets can cache forever (a new build changes their filename).
INDEX_HTML = STATIC_DIR / "index.html" INDEX_HTML = STATIC_DIR / "index.html"
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
WATCH_HTML = STATIC_DIR / "watch.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"} INDEX_HEADERS = {"Cache-Control": "no-cache"}
app.mount( app.mount(
"/assets", "/assets",
@@ -354,16 +356,16 @@ async def index() -> FileResponse:
@app.get("/watch/{token}") @app.get("/watch/{token}")
async def watch_page(token: str): async def watch_page(token: str):
"""Serve the SPA for a public share link, but with per-video Open Graph tags injected so the """Serve the public player page for a share link, with per-video Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
back to the generic card.""" generic card."""
from app.downloads import og from app.downloads import og
with SessionLocal() as db: with SessionLocal() as db:
rendered = og.render_watch_html(token, INDEX_HTML, db) rendered = og.render_watch_html(token, WATCH_HTML, db)
if rendered is None: if rendered is None:
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return HTMLResponse(rendered, headers=INDEX_HEADERS) return HTMLResponse(rendered, headers=INDEX_HEADERS)
@@ -381,4 +383,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have # Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit. # stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"}) return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
# player's: WatchPage reads the token off the path and says when a link is gone.
if full_path.startswith("watch/"):
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
+67
View File
@@ -0,0 +1,67 @@
"""Which HTML page the server hands a path (main.py), DB-lane.
The frontend builds two entries: the app (index.html) and a shared link's player alone (watch.html), so
the friend a /watch link was sent to never downloads the app. Every /watch/ path must get the player's
page — with the link's Open Graph tags when the token names a playable link — and nothing else may.
"""
import pytest
from app import main
from app.config import settings
from app.downloads import probe as probemod
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
@pytest.fixture
def pages(tmp_path, monkeypatch):
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
monkeypatch.setattr(main, "INDEX_HTML", index)
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers):
monkeypatch.setattr(settings, "download_root", str(tmp_path / "media"))
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
audio = {"codec_type": "audio", "codec_name": "aac"}
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
owner = as_user(make_user())
job = owner.post(
"/api/downloads/upload",
content=b"fake-video",
headers=upload_headers("clip.mp4", display_name="Holiday clip"),
)
assert job.status_code == 200, job.text
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={})
assert link.status_code == 200, link.text
return link.json()["token"]
@pytest.mark.parametrize("path", ["/", "/feed", "/downloads/42"])
def test_an_app_path_gets_the_app(pages, client, path):
r = client.get(path)
assert r.status_code == 200
assert "/assets/app.js" in r.text
@pytest.mark.parametrize("path", ["/watch/no-such-token", "/watch/no-such-token/extra"])
def test_every_watch_path_gets_the_player_alone(pages, client, path):
r = client.get(path)
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
assert r.headers["cache-control"] == "no-cache"
def test_a_live_link_unfurls_from_the_players_page(pages, client, link_token):
r = client.get(f"/watch/{link_token}")
assert r.status_code == 200
assert "/assets/watch.js" in r.text
assert 'content="Holiday clip"' in r.text
assert '<meta property="og:title" content="Siftlode" />' not in r.text # replaced, not appended
@@ -91,7 +91,7 @@ describe("the last sign-in column", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy(); expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(1); expect(screen.getAllByText("—")).toHaveLength(1);
}); });
}); });
+1 -1
View File
@@ -316,7 +316,7 @@ function UsersRoles({ me }: { me: Me }) {
} }
> >
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums"> <span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"} {u.last_login_at ? relativeTime(u.last_login_at, lang, t) : "—"}
</span> </span>
</Tooltip> </Tooltip>
<MoreButton <MoreButton
+62
View File
@@ -0,0 +1,62 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { errorAttrs, log } from "../lib/log";
import { reloadingForNewBuild } from "../lib/staleBuild";
import { Button } from "./ui/Button";
export interface CrashCopy {
title: string;
subtitle: string;
reload: string;
}
interface Props {
children: ReactNode;
/** Read at crash time, so the screen speaks the language active then. */
copy: () => CrashCopy;
/** What the entry does beyond the log line (the app keeps a fatal notice in its center). */
onCrash?: () => void;
}
interface State {
crashed: boolean;
}
// Catches render-time crashes anywhere in the tree, logs them and offers a reload. Free of the app's
// i18n and notification store, so the public /watch entry wraps its page in it without loading
// either; `ErrorBoundary` is the app's wrapper around it.
export class CrashBoundary extends Component<Props, State> {
state: State = { crashed: false };
static getDerivedStateFromError(): State {
return { crashed: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
// load, not a crash — no fatal notice, which would outlive the reload.
if (reloadingForNewBuild()) return;
log.fatal("ui.crash", "the app crashed while rendering", {
...errorAttrs(error),
...(info.componentStack ? { component_stack: info.componentStack } : {}),
});
this.props.onCrash?.();
}
render() {
if (this.state.crashed) {
if (reloadingForNewBuild()) return null;
const copy = this.props.copy();
return (
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
<div>
<div className="text-lg font-semibold text-fg mb-1">{copy.title}</div>
<div className="text-sm mb-3">{copy.subtitle}</div>
<Button variant="link" onClick={() => location.reload()} className="text-sm">
{copy.reload}
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
+15 -46
View File
@@ -1,61 +1,30 @@
import { Component, type ErrorInfo, type ReactNode } from "react"; import type { ReactNode } from "react";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { errorAttrs, log } from "../lib/log"; import { CrashBoundary, type CrashCopy } from "./CrashBoundary";
import { reloadingForNewBuild } from "../lib/staleBuild";
import { Button } from "./ui/Button";
interface Props { const copy = (): CrashCopy => ({
children: ReactNode; title: i18n.t("errors.boundary.title"),
} subtitle: i18n.t("errors.boundary.subtitle"),
interface State { reload: i18n.t("errors.boundary.reload"),
crashed: boolean; });
}
// Catches render-time crashes anywhere in the tree, logs a fatal notification (kept // A fatal notification, kept in the center across the reload.
// in the center across the reload) and offers a recovery action. const onCrash = () =>
export default class ErrorBoundary extends Component<Props, State> {
state: State = { crashed: false };
static getDerivedStateFromError(): State {
return { crashed: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
// load, not a crash — no fatal notice, which would outlive the reload.
if (reloadingForNewBuild()) return;
log.fatal("ui.crash", "the app crashed while rendering", {
...errorAttrs(error),
...(info.componentStack ? { component_stack: info.componentStack } : {}),
});
notify({ notify({
level: "fatal", level: "fatal",
title: i18n.t("errors.boundary.notifTitle"), title: i18n.t("errors.boundary.notifTitle"),
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…") // Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
// is no message for the reader — the `ui.crash` log above carries it for diagnosis. // is no message for the reader — the `ui.crash` log carries it for diagnosis.
message: i18n.t("errors.boundary.notifMessage"), message: i18n.t("errors.boundary.notifMessage"),
requiresInteraction: true, requiresInteraction: true,
}); });
}
render() { // The app's crash boundary: `CrashBoundary` in the app's language, plus the notice in the center.
if (this.state.crashed) { export default function ErrorBoundary({ children }: { children: ReactNode }) {
if (reloadingForNewBuild()) return null;
return ( return (
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center"> <CrashBoundary copy={copy} onCrash={onCrash}>
<div> {children}
<div className="text-lg font-semibold text-fg mb-1"> </CrashBoundary>
{i18n.t("errors.boundary.title")}
</div>
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
<Button variant="link" onClick={() => location.reload()} className="text-sm">
{i18n.t("errors.boundary.reload")}
</Button>
</div>
</div>
); );
}
return this.props.children;
}
} }
+1 -1
View File
@@ -546,7 +546,7 @@ function Hero({
? // The channel is the subtitle already. ? // The channel is the subtitle already.
[ [
video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`, video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
relativeTime(video.published_at, lang), relativeTime(video.published_at, lang, t),
] ]
: []; : [];
const factLine = facts.filter(Boolean).join(" · "); const factLine = facts.filter(Boolean).join(" · ");
+2 -2
View File
@@ -977,7 +977,7 @@ export default function PlayerModal({
)} )}
<span> <span>
{metaSep} {metaSep}
{relativeTime(active.published_at, lang)} {relativeTime(active.published_at, lang, t)}
{active.published_at && ` · ${fullDate(active.published_at)}`} {active.published_at && ` · ${fullDate(active.published_at)}`}
</span> </span>
{active.duration_seconds != null && ( {active.duration_seconds != null && (
@@ -1009,7 +1009,7 @@ export default function PlayerModal({
{detail.data?.published_at && ( {detail.data?.published_at && (
<span> <span>
{metaSep} {metaSep}
{relativeTime(detail.data.published_at, lang)} ·{" "} {relativeTime(detail.data.published_at, lang, t)} ·{" "}
{fullDate(detail.data.published_at)} {fullDate(detail.data.published_at)}
</span> </span>
)} )}
+1 -1
View File
@@ -254,7 +254,7 @@ function JobRow({
{job.running {job.running
? t("scheduler.runningNow") ? t("scheduler.runningNow")
: job.last_finished : job.last_finished
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) }) ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang, t) })
: t("scheduler.neverRun")} : t("scheduler.neverRun")}
{job.last_error ? ( {job.last_error ? (
<span className="text-danger"> <span className="text-danger">
+1 -1
View File
@@ -420,7 +420,7 @@ function VideoCard({
); );
const published = ( const published = (
<> <>
{relativeTime(video.published_at, lang)} {relativeTime(video.published_at, lang, t)}
{video.published_at && ( {video.published_at && (
<> <>
{" · "} {" · "}
+4 -69
View File
@@ -9,10 +9,11 @@ import { send } from "../lib/http";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton"; import { LinkButton } from "./ui/LinkButton";
import { T, lang } from "./watchStrings";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app // Standalone, login-free video page for a shared /watch/<token> link. Its own entry (watchMain.tsx,
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny // watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (the public viewer has no app language preference). // self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center // Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
// metadata + play/pause handlers. This is also what lets a browser's own "background playback" // metadata + play/pause handlers. This is also what lets a browser's own "background playback"
@@ -75,72 +76,6 @@ function displayUrl(url: string): string {
} }
} }
const STR = {
en: {
loading: "Loading…",
gone: "This link is no longer available.",
goneHint: "It may have expired or been revoked by the owner.",
passwordTitle: "This video is password-protected",
passwordPlaceholder: "Enter password",
watch: "Watch",
wrong: "Wrong password.",
download: "Download",
file: "File",
links: "Links",
brand: "Shared via Siftlode",
repeat: "Repeat",
failed: "Couldn't load this link.",
failedHint: "Check your connection and try again.",
retry: "Try again",
tooMany: "Too many attempts. Try again in a few minutes.",
unlockFailed: "Couldn't check the password. Try again.",
},
hu: {
loading: "Betöltés…",
gone: "Ez a link már nem elérhető.",
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
passwordTitle: "Ez a videó jelszóval védett",
passwordPlaceholder: "Add meg a jelszót",
watch: "Megnézem",
wrong: "Hibás jelszó.",
download: "Letöltés",
file: "Fájl",
links: "Linkek",
brand: "Megosztva a Siftlode-dal",
repeat: "Ismétlés",
failed: "Nem sikerült betölteni a linket.",
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
retry: "Újrapróbálás",
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
},
de: {
loading: "Wird geladen…",
gone: "Dieser Link ist nicht mehr verfügbar.",
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
passwordTitle: "Dieses Video ist passwortgeschützt",
passwordPlaceholder: "Passwort eingeben",
watch: "Ansehen",
wrong: "Falsches Passwort.",
download: "Herunterladen",
file: "Datei",
links: "Links",
brand: "Geteilt über Siftlode",
repeat: "Wiederholen",
failed: "Der Link konnte nicht geladen werden.",
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
retry: "Erneut versuchen",
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.",
},
} as const;
const lang =
((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
: "en";
const T = STR[lang];
export default function WatchPage() { export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? ""; const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing // Seeded from the URL rather than corrected by the effect below: with no token there is nothing
+1 -1
View File
@@ -304,7 +304,7 @@ export function DownloadRow({
<span className="text-muted" title={formatDateTime(job.created_at, lang)}> <span className="text-muted" title={formatDateTime(job.created_at, lang)}>
{" · "} {" · "}
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "} <CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
{relativeTime(job.created_at, lang)} {relativeTime(job.created_at, lang, t)}
</span> </span>
) : null} ) : null}
{job.status === "error" ? ( {job.status === "error" ? (
@@ -4,7 +4,7 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format"; import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format";
import "../../i18n"; // the real instance, in English: the component names time in the UI language import i18n from "../../i18n"; // the real instance, in English: the component names time in the UI language
import { RelativeTime } from "./RelativeTime"; import { RelativeTime } from "./RelativeTime";
afterEach(cleanup); afterEach(cleanup);
@@ -15,7 +15,7 @@ describe("RelativeTime", () => {
it("says how long ago, and names the exact minute on hover", () => { it("says how long ago, and names the exact minute on hover", () => {
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />); const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
const el = container.querySelector("time")!; const el = container.querySelector("time")!;
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en")); expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en", i18n.t));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en")); expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en"));
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO); expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
}); });
+3 -2
View File
@@ -17,9 +17,10 @@ export function RelativeTime({
empty?: string; empty?: string;
className?: string; className?: string;
}) { }) {
const lang = useTranslation().i18n.language; const { t, i18n } = useTranslation();
const lang = i18n.language;
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN; const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN;
const text = Number.isFinite(ms) ? relativeFromMs(ms, lang) : ""; const text = Number.isFinite(ms) ? relativeFromMs(ms, lang, t) : "";
if (!text) return empty ? <span className={className}>{empty}</span> : null; if (!text) return empty ? <span className={className}>{empty}</span> : null;
const iso = new Date(ms).toISOString(); const iso = new Date(ms).toISOString();
return ( return (
+78
View File
@@ -0,0 +1,78 @@
// The public /watch page's own strings. The page is opened by whoever got the link, signed out and
// with no app language preference, so it reads the browser's language against this small dict
// instead of loading the app's i18n (every locale of every area — half of what the page weighed).
const STR = {
en: {
loading: "Loading…",
gone: "This link is no longer available.",
goneHint: "It may have expired or been revoked by the owner.",
passwordTitle: "This video is password-protected",
passwordPlaceholder: "Enter password",
watch: "Watch",
wrong: "Wrong password.",
download: "Download",
file: "File",
links: "Links",
brand: "Shared via Siftlode",
repeat: "Repeat",
failed: "Couldn't load this link.",
failedHint: "Check your connection and try again.",
retry: "Try again",
tooMany: "Too many attempts. Try again in a few minutes.",
unlockFailed: "Couldn't check the password. Try again.",
crashTitle: "Something went wrong.",
crashHint: "Reload the page to try again.",
reload: "Reload",
},
hu: {
loading: "Betöltés…",
gone: "Ez a link már nem elérhető.",
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
passwordTitle: "Ez a videó jelszóval védett",
passwordPlaceholder: "Add meg a jelszót",
watch: "Megnézem",
wrong: "Hibás jelszó.",
download: "Letöltés",
file: "Fájl",
links: "Linkek",
brand: "Megosztva a Siftlode-dal",
repeat: "Ismétlés",
failed: "Nem sikerült betölteni a linket.",
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
retry: "Újrapróbálás",
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
crashTitle: "Valami hiba történt.",
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
},
de: {
loading: "Wird geladen…",
gone: "Dieser Link ist nicht mehr verfügbar.",
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
passwordTitle: "Dieses Video ist passwortgeschützt",
passwordPlaceholder: "Passwort eingeben",
watch: "Ansehen",
wrong: "Falsches Passwort.",
download: "Herunterladen",
file: "Datei",
links: "Links",
brand: "Geteilt über Siftlode",
repeat: "Wiederholen",
failed: "Der Link konnte nicht geladen werden.",
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
retry: "Erneut versuchen",
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.",
crashTitle: "Etwas ist schiefgelaufen.",
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
},
} as const;
export const lang =
((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
: "en";
export const T = STR[lang];
+14 -13
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TFunction } from "i18next";
import { import {
RELATIVE_UNITS, RELATIVE_UNITS,
formatBytes, formatBytes,
@@ -18,12 +19,10 @@ import {
import en from "../i18n/locales/en/time.json"; import en from "../i18n/locales/en/time.json";
import hu from "../i18n/locales/hu/time.json"; import hu from "../i18n/locales/hu/time.json";
// i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it. // `language` is mutable because relativeFromMs takes it per call to pick the Intl locale — these
// `language` is mutable because relativeFromMs reads it per call to pick the Intl locale — these
// cases assert both languages by flipping it. `t` echoes the key: the only string still coming // cases assert both languages by flipping it. `t` echoes the key: the only string still coming
// from the locale files is justNow. // from the locale files is justNow.
const i18nStub = vi.hoisted(() => ({ language: "en", t: (key: string) => key })); const i18nStub = { language: "en", t: ((key: string) => key) as unknown as TFunction };
vi.mock("../i18n", () => ({ default: i18nStub }));
const MIN = 60; const MIN = 60;
const HOUR = 3600; const HOUR = 3600;
@@ -74,29 +73,31 @@ describe("relativeFromMs", () => {
for (const [secs, tier, expectedEn, expectedHu] of CASES) { for (const [secs, tier, expectedEn, expectedHu] of CASES) {
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => { it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn); expect(relativeFromMs(NOW - secs * 1000, i18nStub.language, i18nStub.t)).toBe(expectedEn);
i18nStub.language = "hu"; i18nStub.language = "hu";
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu); expect(relativeFromMs(NOW - secs * 1000, i18nStub.language, i18nStub.t)).toBe(expectedHu);
}); });
} }
it("follows a language switch at runtime", () => { it("follows a language switch at runtime", () => {
const twoHoursAgo = NOW - 2 * HOUR * 1000; const twoHoursAgo = NOW - 2 * HOUR * 1000;
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago"); expect(relativeFromMs(twoHoursAgo, i18nStub.language, i18nStub.t)).toBe("2h ago");
i18nStub.language = "hu"; i18nStub.language = "hu";
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája"); expect(relativeFromMs(twoHoursAgo, i18nStub.language, i18nStub.t)).toBe("2 órája");
i18nStub.language = "en"; i18nStub.language = "en";
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago"); expect(relativeFromMs(twoHoursAgo, i18nStub.language, i18nStub.t)).toBe("2h ago");
}); });
it("clamps a future timestamp instead of counting up", () => { it("clamps a future timestamp instead of counting up", () => {
expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow"); expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language, i18nStub.t)).toBe(
"time.justNow",
);
}); });
it("renders nothing for a timestamp it cannot read", () => { it("renders nothing for a timestamp it cannot read", () => {
expect(relativeTime("not-a-date", i18nStub.language)).toBe(""); expect(relativeTime("not-a-date", i18nStub.language, i18nStub.t)).toBe("");
expect(relativeTime(null, i18nStub.language)).toBe(""); expect(relativeTime(null, i18nStub.language, i18nStub.t)).toBe("");
expect(relativeFromMs(NaN, i18nStub.language)).toBe(""); expect(relativeFromMs(NaN, i18nStub.language, i18nStub.t)).toBe("");
}); });
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering // Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
+7 -6
View File
@@ -1,5 +1,4 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import i18n from "../i18n";
// The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) — // The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) —
// all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere). // all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere).
@@ -8,7 +7,9 @@ import i18n from "../i18n";
// the React Compiler memoizes a call on its arguments, so a formatter that read the language // the React Compiler memoizes a call on its arguments, so a formatter that read the language
// off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min" // off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min"
// beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a // beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a
// dependency the compiler can see; the signature makes forgetting it a type error. // dependency the compiler can see; the signature makes forgetting it a type error. It is also why
// this file never imports the app's i18n: the public /watch entry formats a file size through it, and
// that import alone carried every locale of every area into the page a shared link opens.
// Intl formatters are expensive to build and every grid formats one per card: one per language × // Intl formatters are expensive to build and every grid formats one per card: one per language ×
// options, built lazily. Keyed by language because the user can switch it at runtime. // options, built lazily. Keyed by language because the user can switch it at runtime.
@@ -23,9 +24,9 @@ function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.N
return f; return f;
} }
export function relativeTime(iso: string | null, lang: string): string { export function relativeTime(iso: string | null, lang: string, t: TFunction): string {
if (!iso) return ""; if (!iso) return "";
return relativeFromMs(new Date(iso).getTime(), lang); return relativeFromMs(new Date(iso).getTime(), lang, t);
} }
/** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB", /** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB",
@@ -90,11 +91,11 @@ function relativeFormatter(lang: string): Intl.RelativeTimeFormat {
* client-side notifications whose timestamps are already numbers). One implementation shared * client-side notifications whose timestamps are already numbers). One implementation shared
* instead of re-implemented per component. Empty string for a timestamp that can't be read, so a * instead of re-implemented per component. Empty string for a timestamp that can't be read, so a
* caller's `|| "—"` fallback covers it like a missing one. */ * caller's `|| "—"` fallback covers it like a missing one. */
export function relativeFromMs(ms: number, lang: string): string { export function relativeFromMs(ms: number, lang: string, t: TFunction): string {
const secs = Math.max(0, (Date.now() - ms) / 1000); const secs = Math.max(0, (Date.now() - ms) / 1000);
if (!Number.isFinite(secs)) return ""; if (!Number.isFinite(secs)) return "";
// Sub-minute is the one case Intl has no wording for — it would say "0m ago". // Sub-minute is the one case Intl has no wording for — it would say "0m ago".
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow", { lng: lang }); if (secs < RELATIVE_UNITS[0][0]) return t("time.justNow", { lng: lang });
let [div, unit] = RELATIVE_UNITS[0]; let [div, unit] = RELATIVE_UNITS[0];
for (const [unitSecs, unitName] of RELATIVE_UNITS) { for (const [unitSecs, unitName] of RELATIVE_UNITS) {
if (secs < unitSecs) break; if (secs < unitSecs) break;
+10 -16
View File
@@ -29,15 +29,15 @@ installClientLog(appCopyOf);
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
installStaleBuildReload(); installStaleBuildReload();
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a // Split by top-level route so each entry point is its own chunk: a legal page never downloads the
// legal page never downloads the authenticated app bundle, and the app never carries them either. // authenticated app bundle, and the app never carries it either. The legal pages live outside the
// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers // App tree (no /api/me) so Google's consent screen and reviewers can fetch them directly. Reached by
// can fetch them directly; the watch page is a login-free public player. Reached by full-page // full-page navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed. // A public /watch link is not here at all: it has its own HTML entry (watch.html → watchMain.tsx),
// so the friend it was sent to never loads this file's providers, i18n or CSS imports.
const App = lazyWithPreload(() => import("./App")); const App = lazyWithPreload(() => import("./App"));
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy")); const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms")); const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
// The design lab: the living style guide, rendered with the app's real CSS so a design decision // The design lab: the living style guide, rendered with the app's real CSS so a design decision
// is taken on what will ship. DEV only — Vite folds the condition to `false` in a production build, // is taken on what will ship. DEV only — Vite folds the condition to `false` in a production build,
// the import becomes dead code and the chunk is never emitted (devOnlyGuard fails the build if it is). // the import becomes dead code and the chunk is never emitted (devOnlyGuard fails the build if it is).
@@ -46,7 +46,7 @@ const DesignLab = import.meta.env.DEV ? lazy(() => import("./design-lab/DesignLa
const queryClient = createQueryClient(); const queryClient = createQueryClient();
// Baseline theme so the token CSS vars (--bg/--surface/--accent/…) resolve on first paint — including // Baseline theme so the token CSS vars (--bg/--surface/--accent/…) resolve on first paint — including
// the standalone public pages (/watch, /privacy, /terms) that render outside <App> and never call // the standalone public pages (/privacy, /terms) that render outside <App> and never call
// applyTheme. The app overrides this with the user's saved theme once it mounts. // applyTheme. The app overrides this with the user's saved theme once it mounts.
const de = document.documentElement.dataset; const de = document.documentElement.dataset;
de.theme ||= "dark"; de.theme ||= "dark";
@@ -54,8 +54,7 @@ de.scheme ||= "midnight";
const path = window.location.pathname; const path = window.location.pathname;
const isLab = DesignLab !== null && path === "/design-lab"; const isLab = DesignLab !== null && path === "/design-lab";
const isPublicPage = const isPublicPage = path === "/privacy" || path === "/terms" || isLab;
path === "/privacy" || path === "/terms" || path.startsWith("/watch/") || isLab;
// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens: // Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens:
// the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A // the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A
// failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary. // failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary.
@@ -69,11 +68,6 @@ const root =
<PrivacyPolicy /> <PrivacyPolicy />
) : path === "/terms" ? ( ) : path === "/terms" ? (
<Terms /> <Terms />
) : path.startsWith("/watch/") ? (
// FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only.
<FloatingTree>
<WatchPage />
</FloatingTree>
) : ( ) : (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{/* One tree for the whole app: every anchored popover registers as a node so Escape and {/* One tree for the whole app: every anchored popover registers as a node so Escape and
@@ -119,8 +113,8 @@ const root =
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
{/* Around EVERY entry, the public ones too: a crash on a shared /watch link, or its chunk gone {/* Around EVERY entry, the public ones too: a crash, or a chunk gone twice over after a release
twice over after a release (lib/staleBuild), left a blank white page with no way on. */} (lib/staleBuild), left a blank white page with no way on. */}
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<div style={{ minHeight: "100dvh" }} />}>{root}</Suspense> <Suspense fallback={<div style={{ minHeight: "100dvh" }} />}>{root}</Suspense>
</ErrorBoundary> </ErrorBoundary>
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import ts from "typescript";
// A shared /watch link has its own entry (watch.html → watchMain.tsx) so the friend it was sent to
// loads the player page and nothing of the app. That holds only while nothing the page imports
// reaches back into the app — and the first build of the entry showed how easily it does: one
// file-size formatter imported the app's i18n, and with it every locale of every area (≈ 120 KB gzip,
// half the page). This walks the entry's static imports, as the bundler does, and names the path to
// any of what the app alone may load.
const SRC = resolve(__dirname);
const FORBIDDEN: Record<string, string> = {
"src/i18n/index.ts":
"the app's i18n (every locale) — the page has its own strings (watchStrings)",
i18next: "the app's i18n runtime",
"react-i18next": "the app's i18n runtime",
"@tanstack/react-query": "the app's data layer",
"src/App.tsx": "the app shell",
"src/main.tsx": "the app's entry",
"src/lib/notifications.ts": "the app's notification center",
"src/lib/logHooks.ts": "the app's client-log wiring",
"flag-icons": "the app's flag sprites",
};
function resolveSpec(from: string, spec: string): string | null {
if (!spec.startsWith(".")) {
const parts = spec.split("/");
return parts.slice(0, spec.startsWith("@") ? 2 : 1).join("/");
}
const base = resolve(dirname(from), spec);
for (const ext of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) {
const p = base + ext;
if (existsSync(p) && statSync(p).isFile()) return p;
}
return null;
}
/** Specifiers a file loads at run time: value imports and re-exports, never `import type`. */
function runtimeImports(file: string): string[] {
const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
const specs: string[] = [];
for (const st of sf.statements) {
if (
ts.isImportDeclaration(st) &&
st.importClause?.phaseModifier !== ts.SyntaxKind.TypeKeyword
) {
specs.push((st.moduleSpecifier as ts.StringLiteral).text);
} else if (ts.isExportDeclaration(st) && st.moduleSpecifier && !st.isTypeOnly) {
specs.push((st.moduleSpecifier as ts.StringLiteral).text);
}
}
return specs;
}
const key = (p: string) =>
p.startsWith("/") || /^[A-Za-z]:/.test(p)
? relative(join(SRC, ".."), p).split("\\").join("/")
: p;
/** Every module the entry reaches, each with the importer that first reached it. */
function reach(entry: string): Map<string, string | null> {
const seen = new Map<string, string | null>([[key(entry), null]]);
const queue = [entry];
for (let file = queue.shift(); file; file = queue.shift()) {
if (!/\.tsx?$/.test(file)) continue;
for (const spec of runtimeImports(file)) {
const target = resolveSpec(file, spec);
if (target === null || seen.has(key(target))) continue;
seen.set(key(target), key(file));
if (!target.startsWith("@") && /\.tsx?$/.test(target)) queue.push(target);
}
}
return seen;
}
function chain(seen: Map<string, string | null>, mod: string): string {
const path = [mod];
for (let up = seen.get(mod); up; up = seen.get(up)) path.unshift(up);
return path.join(" → ");
}
describe("the /watch entry", () => {
it("loads nothing the app alone may load", () => {
const seen = reach(join(SRC, "watchMain.tsx"));
expect(seen.has("src/components/WatchPage.tsx")).toBe(true); // the walk is reading the entry
const leaks = Object.keys(FORBIDDEN)
.filter((mod) => seen.has(mod))
.map((mod) => `${FORBIDDEN[mod]}: ${chain(seen, mod)}`);
expect(leaks).toEqual([]);
});
it("catches a leak one import deep (the walk is not vacuous)", () => {
// main.tsx is the app: walking it must find the app's i18n.
expect(reach(join(SRC, "main.tsx")).has("src/i18n/index.ts")).toBe(true);
});
it("serves the same head as the app's page, so the server's OG block lands the same way", () => {
const page = (name: string) => readFileSync(join(SRC, "..", name), "utf8");
// Comments aside (the OG markers are kept: the server replaces between them), and the script.
const norm = (html: string) =>
html
.replace(/<!--(?!OG:)[\s\S]*?-->/g, "")
.replace(/\s+/g, " ")
.replace(/src="\/src\/[^"]+"/, 'src="ENTRY"');
expect(norm(page("watch.html"))).toBe(norm(page("index.html")));
expect(page("watch.html")).toContain('src="/src/watchMain.tsx"');
});
});
+26
View File
@@ -0,0 +1,26 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { CrashBoundary } from "./components/CrashBoundary";
import WatchPage from "./components/WatchPage";
import { T } from "./components/watchStrings";
import "./index.css";
import { installStaleBuildReload } from "./lib/staleBuild";
// The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a
// friend: signed out, often on a phone, often on mobile data — so it imports the player page and
// what that page renders, and none of main.tsx's app (no providers, no app i18n, no client-log
// wiring: an anonymous viewer has no diagnostics consent to send it under). A contract test walks
// this file's imports and fails on any of those (`watchEntry.contract.test.ts`).
// A tab left open across a release reloads onto the new build instead of failing on a chunk.
installStaleBuildReload();
const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload });
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<CrashBoundary copy={crashCopy}>
<WatchPage />
</CrashBoundary>
</React.StrictMode>,
);
+16 -3
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vite"; import { defineConfig, type Plugin } from "vite";
import { reactWithCompiler } from "./reactPlugin"; import { reactWithCompiler } from "./reactPlugin";
import { devOnlyGuard } from "./devOnlyGuard"; import { devOnlyGuard } from "./devOnlyGuard";
@@ -17,8 +17,21 @@ const proxy = {
"/healthz": proxyOpts, "/healthz": proxyOpts,
}; };
// Two HTML entries: the app (index.html) and a public /watch/<token> link (watch.html), which loads
// only the player page. In a build the backend picks the file by path (`main.py`); this does the
// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app.
const watchEntry = (): Plugin => ({
name: "siftlode-watch-entry",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith("/watch/")) req.url = "/watch.html";
next();
});
},
});
export default defineConfig({ export default defineConfig({
plugins: [reactWithCompiler(), devOnlyGuard()], plugins: [reactWithCompiler(), devOnlyGuard(), watchEntry()],
server: { port: 5173, proxy }, server: { port: 5173, proxy },
build: { outDir: "dist" }, build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
}); });
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en" data-theme="dark" data-scheme="midnight">
<head>
<meta charset="utf-8" />
<!-- resizes-content: the on-screen keyboard shrinks the layout viewport, not only the visual one,
so a dialog's pinned footer (DESIGN C4) sits above the keyboard instead of under it. Chrome's
default since 108 only shrinks the visual viewport; Safari ignores the key. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- The public /watch/{token} page's own entry (src/watchMain.tsx), so a shared link does not
download the app. Its head mirrors index.html (a contract test holds them equal) — OG:START:
the server replaces this block per link so it unfurls with the video's title/thumbnail; an
invalid link keeps these generic defaults. -->
<!--OG:START-->
<title>Siftlode</title>
<meta
name="description"
content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/>
<meta property="og:title" content="Siftlode" />
<meta
property="og:description"
content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/>
<meta property="og:type" content="website" />
<!--OG:END-->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/watchMain.tsx"></script>
</body>
</html>