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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(" · ");
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
{" · "}
|
{" · "}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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"] } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user