diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index b961722a..3cc83d5b 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -17,8 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed mixed tab/space indentation in Caddy documentation code block
- Improve error messages and fix broken REDIRECT_DOMAINS link in docs ([#1193](https://github.com/TecharoHQ/anubis/issues/1193))
- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
+- Rewrite main proof of work challenge to use Preact instead of Vanilla.js ([#1149](https://github.com/TecharoHQ/anubis/issues/1149)).
+
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
## v1.25.0: Necron
diff --git a/lib/anubis.go b/lib/anubis.go
index 2ff5ec92..1e2f7f4e 100644
--- a/lib/anubis.go
+++ b/lib/anubis.go
@@ -141,7 +141,7 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
return nil, err
}
- lg.Info("new challenge issued", "challenge", id.String())
+ lg.Info("new challenge issued", "challenge", id.String(), "method", chall.Method)
return &chall, err
}
diff --git a/lib/challenge/proofofwork/proofofwork.templ b/lib/challenge/proofofwork/proofofwork.templ
index d08085ab..94b841ff 100644
--- a/lib/challenge/proofofwork/proofofwork.templ
+++ b/lib/challenge/proofofwork/proofofwork.templ
@@ -7,13 +7,12 @@ import (
templ page(localizer *localization.SimpleLocalizer) {
-

-
{ localizer.T("loading") }
-
-
-
+
+

+
{ localizer.T("loading") }
+
if anubis.UseSimplifiedExplanation {
diff --git a/lib/challenge/proofofwork/proofofwork_templ.go b/lib/challenge/proofofwork/proofofwork_templ.go
index bbae45c0..48d2df2d 100644
--- a/lib/challenge/proofofwork/proofofwork_templ.go
+++ b/lib/challenge/proofofwork/proofofwork_templ.go
@@ -34,27 +34,27 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
)
)
)
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -98,7 +98,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, Col: 44}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 19, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -116,7 +116,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, Col: 46}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 23, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -129,7 +129,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 39}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 26, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -142,7 +142,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 34}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 29, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -155,7 +155,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 35}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 32, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -173,7 +173,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("javascript_required"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 39, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 38, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
diff --git a/web/build.sh b/web/build.sh
index d0818e0c..194236bb 100755
--- a/web/build.sh
+++ b/web/build.sh
@@ -41,15 +41,22 @@ cp ../lib/localization/locales/*.json static/locales/
shopt -s nullglob globstar
-for file in js/**/*.ts js/**/*.mjs; do
+for file in js/**/*.ts js/**/*.tsx js/**/*.mjs; do
out="static/${file}"
- if [[ "$file" == *.ts ]]; then
+ if [[ "$file" == *.tsx ]]; then
+ out="static/${file%.tsx}.mjs"
+ elif [[ "$file" == *.ts ]]; then
out="static/${file%.ts}.mjs"
fi
mkdir -p "$(dirname "$out")"
- esbuild "$file" --sourcemap --bundle --minify --outfile="$out" --banner:js="$LICENSE"
+ JSX_FLAGS=""
+ if [[ "$file" == *.tsx ]]; then
+ JSX_FLAGS="--jsx=automatic --jsx-import-source=preact"
+ fi
+
+ esbuild "$file" --sourcemap --bundle --minify --outfile="$out" $JSX_FLAGS --banner:js="$LICENSE"
gzip -f -k -n "$out"
zstd -f -k --ultra -22 "$out"
brotli -fZk "$out"
diff --git a/web/js/main.ts b/web/js/main.ts
deleted file mode 100644
index b07d52d9..00000000
--- a/web/js/main.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-import algorithms from "./algorithms";
-
-// from Xeact
-const u = (url: string = "", params: Record = {}) => {
- let result = new URL(url, window.location.href);
- Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v));
- return result.toString();
-};
-
-const j = (id: string): any | null => {
- const elem = document.getElementById(id);
- if (elem === null) {
- return null;
- }
-
- return JSON.parse(elem.textContent);
-};
-
-const imageURL = (mood, cacheBuster, basePrefix) =>
- u(`${basePrefix}/.within.website/x/cmd/anubis/static/img/${mood}.webp`, {
- cacheBuster,
- });
-
-// Detect available languages by loading the manifest
-const getAvailableLanguages = async () => {
- const basePrefix = j("anubis_base_prefix");
- if (basePrefix === null) {
- return;
- }
-
- try {
- const response = await fetch(
- `${basePrefix}/.within.website/x/cmd/anubis/static/locales/manifest.json`,
- );
- if (response.ok) {
- const manifest = await response.json();
- return manifest.supportedLanguages || ["en"];
- }
- } catch (error) {
- console.warn(
- "Failed to load language manifest, falling back to default languages",
- );
- }
-
- // Fallback to default languages if manifest loading fails
- return ["en"];
-};
-
-// Use the browser language from the HTML lang attribute which is set by the server settings or request headers
-const getBrowserLanguage = async () => document.documentElement.lang;
-
-// Load translations from JSON files
-const loadTranslations = async (lang) => {
- const basePrefix = j("anubis_base_prefix");
- if (basePrefix === null) {
- return;
- }
-
- try {
- const response = await fetch(
- `${basePrefix}/.within.website/x/cmd/anubis/static/locales/${lang}.json`,
- );
- return await response.json();
- } catch (error) {
- console.warn(
- `Failed to load translations for ${lang}, falling back to English`,
- );
- if (lang !== "en") {
- return await loadTranslations("en");
- }
- throw error;
- }
-};
-
-const getRedirectUrl = () => {
- const publicUrl = j("anubis_public_url");
- if (publicUrl === null) {
- return;
- }
- if (publicUrl && window.location.href.startsWith(publicUrl)) {
- const urlParams = new URLSearchParams(window.location.search);
- return urlParams.get("redir");
- }
- return window.location.href;
-};
-
-let translations = {};
-let currentLang;
-
-// Initialize translations
-const initTranslations = async () => {
- currentLang = await getBrowserLanguage();
- translations = await loadTranslations(currentLang);
-};
-
-const t = (key) => translations[`js_${key}`] || translations[key] || key;
-
-(async () => {
- // Initialize translations first
- await initTranslations();
-
- const dependencies = [
- {
- name: "Web Workers",
- msg: t("web_workers_error"),
- value: window.Worker,
- },
- {
- name: "Cookies",
- msg: t("cookies_error"),
- value: navigator.cookieEnabled,
- },
- ];
-
- const status: HTMLParagraphElement = document.getElementById(
- "status",
- ) as HTMLParagraphElement;
- const image: HTMLImageElement = document.getElementById(
- "image",
- ) as HTMLImageElement;
- const title: HTMLHeadingElement = document.getElementById(
- "title",
- ) as HTMLHeadingElement;
- const progress: HTMLDivElement = document.getElementById(
- "progress",
- ) as HTMLDivElement;
-
- const anubisVersion = j("anubis_version");
- const basePrefix = j("anubis_base_prefix");
- const details = document.querySelector("details");
- let userReadDetails = false;
-
- if (details) {
- details.addEventListener("toggle", () => {
- if (details.open) {
- userReadDetails = true;
- }
- });
- }
-
- const ohNoes = ({ titleMsg, statusMsg, imageSrc }) => {
- title.innerHTML = titleMsg;
- status.innerHTML = statusMsg;
- image.src = imageSrc;
- progress.style.display = "none";
- };
-
- status.innerHTML = t("calculating");
-
- for (const { value, name, msg } of dependencies) {
- if (!value) {
- ohNoes({
- titleMsg: `${t("missing_feature")} ${name}`,
- statusMsg: msg,
- imageSrc: imageURL("reject", anubisVersion, basePrefix),
- });
- return;
- }
- }
-
- const { challenge, rules } = j("anubis_challenge");
-
- const process = algorithms[rules.algorithm];
- if (!process) {
- ohNoes({
- titleMsg: t("challenge_error"),
- statusMsg: t("challenge_error_msg"),
- imageSrc: imageURL("reject", anubisVersion, basePrefix),
- });
- return;
- }
-
- status.innerHTML = `${t("calculating_difficulty")} ${rules.difficulty}, `;
- progress.style.display = "inline-block";
-
- // the whole text, including "Speed:", as a single node, because some browsers
- // (Firefox mobile) present screen readers with each node as a separate piece
- // of text.
- const rateText = document.createTextNode(`${t("speed")} 0kH/s`);
- status.appendChild(rateText);
-
- let lastSpeedUpdate = 0;
- let showingApology = false;
- const likelihood = Math.pow(16, -rules.difficulty);
-
- try {
- const t0 = Date.now();
- const { hash, nonce } = await process(
- { basePrefix, version: anubisVersion },
- challenge.randomData,
- rules.difficulty,
- null,
- (iters) => {
- const delta = Date.now() - t0;
- // only update the speed every second so it's less visually distracting
- if (delta - lastSpeedUpdate > 1000) {
- lastSpeedUpdate = delta;
- rateText.data = `${t("speed")} ${(iters / delta).toFixed(3)}kH/s`;
- }
- // the probability of still being on the page is (1 - likelihood) ^ iters.
- // by definition, half of the time the progress bar only gets to half, so
- // apply a polynomial ease-out function to move faster in the beginning
- // and then slow down as things get increasingly unlikely. quadratic felt
- // the best in testing, but this may need adjustment in the future.
-
- const probability = Math.pow(1 - likelihood, iters);
- const distance = (1 - Math.pow(probability, 2)) * 100;
- progress["aria-valuenow"] = distance;
- if (progress.firstElementChild !== null) {
- (progress.firstElementChild as HTMLElement).style.width =
- `${distance}%`;
- }
-
- if (probability < 0.1 && !showingApology) {
- status.append(
- document.createElement("br"),
- document.createTextNode(t("verification_longer")),
- );
- showingApology = true;
- }
- },
- );
- const t1 = Date.now();
- console.log({ hash, nonce });
-
- if (userReadDetails) {
- const container: HTMLDivElement = document.getElementById(
- "progress",
- ) as HTMLDivElement;
-
- // Style progress bar as a continue button
- container.style.display = "flex";
- container.style.alignItems = "center";
- container.style.justifyContent = "center";
- container.style.height = "2rem";
- container.style.borderRadius = "1rem";
- container.style.cursor = "pointer";
- container.style.background = "#b16286";
- container.style.color = "white";
- container.style.fontWeight = "bold";
- container.style.outline = "4px solid #b16286";
- container.style.outlineOffset = "2px";
- container.style.width = "min(20rem, 90%)";
- container.style.margin = "1rem auto 2rem";
- container.innerHTML = t("finished_reading");
-
- function onDetailsExpand() {
- const redir = getRedirectUrl();
- window.location.replace(
- u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
- id: challenge.id,
- response: hash,
- nonce,
- redir,
- elapsedTime: t1 - t0,
- }),
- );
- }
-
- container.onclick = onDetailsExpand;
- setTimeout(onDetailsExpand, 30000);
- } else {
- const redir = getRedirectUrl();
- window.location.replace(
- u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
- id: challenge.id,
- response: hash,
- nonce,
- redir,
- elapsedTime: t1 - t0,
- }),
- );
- }
- } catch (err) {
- ohNoes({
- titleMsg: t("calculation_error"),
- statusMsg: `${t("calculation_error_msg")} ${err.message}`,
- imageSrc: imageURL("reject", anubisVersion, basePrefix),
- });
- }
-})();
diff --git a/web/js/main.tsx b/web/js/main.tsx
new file mode 100644
index 00000000..a9efd770
--- /dev/null
+++ b/web/js/main.tsx
@@ -0,0 +1,345 @@
+import { render } from "preact";
+import { useState, useEffect, useRef } from "preact/hooks";
+import algorithms from "./algorithms";
+
+// from Xeact
+const u = (url: string = "", params: Record = {}) => {
+ let result = new URL(url, window.location.href);
+ Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v));
+ return result.toString();
+};
+
+const j = (id: string): any | null => {
+ const elem = document.getElementById(id);
+ if (elem === null) {
+ return null;
+ }
+
+ return JSON.parse(elem.textContent);
+};
+
+const imageURL = (
+ mood: string,
+ cacheBuster: string,
+ basePrefix: string,
+): string =>
+ u(`${basePrefix}/.within.website/x/cmd/anubis/static/img/${mood}.webp`, {
+ cacheBuster,
+ });
+
+// Detect available languages by loading the manifest
+const getAvailableLanguages = async () => {
+ const basePrefix = j("anubis_base_prefix");
+ if (basePrefix === null) {
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `${basePrefix}/.within.website/x/cmd/anubis/static/locales/manifest.json`,
+ );
+ if (response.ok) {
+ const manifest = await response.json();
+ return manifest.supportedLanguages || ["en"];
+ }
+ } catch (error) {
+ console.warn(
+ "Failed to load language manifest, falling back to default languages",
+ );
+ }
+
+ // Fallback to default languages if manifest loading fails
+ return ["en"];
+};
+
+// Use the browser language from the HTML lang attribute which is set by the server settings or request headers
+const getBrowserLanguage = async () => document.documentElement.lang;
+
+// Load translations from JSON files
+const loadTranslations = async (lang: string) => {
+ const basePrefix = j("anubis_base_prefix");
+ if (basePrefix === null) {
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `${basePrefix}/.within.website/x/cmd/anubis/static/locales/${lang}.json`,
+ );
+ return await response.json();
+ } catch (error) {
+ console.warn(
+ `Failed to load translations for ${lang}, falling back to English`,
+ );
+ if (lang !== "en") {
+ return await loadTranslations("en");
+ }
+ throw error;
+ }
+};
+
+const getRedirectUrl = () => {
+ const publicUrl = j("anubis_public_url");
+ if (publicUrl === null) {
+ return;
+ }
+ if (publicUrl && window.location.href.startsWith(publicUrl)) {
+ const urlParams = new URLSearchParams(window.location.search);
+ return urlParams.get("redir");
+ }
+ return window.location.href;
+};
+
+let translations: Record = {};
+let currentLang;
+
+// Initialize translations
+const initTranslations = async () => {
+ currentLang = await getBrowserLanguage();
+ translations = await loadTranslations(currentLang);
+};
+
+const t = (key: string): string =>
+ translations[`js_${key}`] || translations[key] || key;
+
+interface AppProps {
+ anubisVersion: string;
+ basePrefix: string;
+}
+
+function App({ anubisVersion, basePrefix }: AppProps) {
+ const [phase, setPhase] = useState<
+ "loading" | "computing" | "reading" | "error"
+ >("loading");
+
+ // Error info
+ const [errorTitle, setErrorTitle] = useState("");
+ const [errorMessage, setErrorMessage] = useState("");
+ const [errorImage, setErrorImage] = useState("");
+
+ // Computing info
+ const [difficulty, setDifficulty] = useState(0);
+ const [speed, setSpeed] = useState("0kH/s");
+ const [progress, setProgress] = useState(0);
+ const [showApology, setShowApology] = useState(false);
+
+ // Reading redirect callback
+ const redirectFn = useRef<(() => void) | null>(null);
+ const detailsRead = useRef(false);
+
+ // Sync when entering error state (it's outside the Preact tree)
+ useEffect(() => {
+ if (phase === "error") {
+ const titleEl = document.getElementById("title");
+ if (titleEl) {
+ titleEl.textContent = errorTitle;
+ }
+ }
+ }, [phase, errorTitle]);
+
+ // Main initialization
+ useEffect(() => {
+ const details = document.querySelector("details");
+ if (details) {
+ details.addEventListener("toggle", () => {
+ if (details.open) {
+ detailsRead.current = true;
+ }
+ });
+ }
+
+ const showError = (title: string, message: string, imageSrc: string) => {
+ setErrorTitle(title);
+ setErrorMessage(message);
+ setErrorImage(imageSrc);
+ setPhase("error");
+ };
+
+ const dependencies = [
+ {
+ name: "Web Workers",
+ msg: t("web_workers_error"),
+ value: window.Worker,
+ },
+ {
+ name: "Cookies",
+ msg: t("cookies_error"),
+ value: navigator.cookieEnabled,
+ },
+ ];
+
+ for (const { value, name, msg } of dependencies) {
+ if (!value) {
+ showError(
+ `${t("missing_feature")} ${name}`,
+ msg,
+ imageURL("reject", anubisVersion, basePrefix),
+ );
+ return;
+ }
+ }
+
+ const { challenge, rules } = j("anubis_challenge");
+
+ const process = algorithms[rules.algorithm];
+ if (!process) {
+ showError(
+ t("challenge_error"),
+ t("challenge_error_msg"),
+ imageURL("reject", anubisVersion, basePrefix),
+ );
+ return;
+ }
+
+ setPhase("computing");
+ setDifficulty(rules.difficulty);
+
+ const likelihood = Math.pow(16, -rules.difficulty);
+ let lastSpeedUpdate = 0;
+ let apologyShown = false;
+ const t0 = Date.now();
+
+ process(
+ { basePrefix, version: anubisVersion },
+ challenge.randomData,
+ rules.difficulty,
+ null,
+ (iters: number) => {
+ const delta = Date.now() - t0;
+ // only update the speed every second so it's less visually distracting
+ if (delta - lastSpeedUpdate > 1000) {
+ lastSpeedUpdate = delta;
+ setSpeed(`${(iters / delta).toFixed(3)}kH/s`);
+ }
+ // the probability of still being on the page is (1 - likelihood) ^ iters.
+ // by definition, half of the time the progress bar only gets to half, so
+ // apply a polynomial ease-out function to move faster in the beginning
+ // and then slow down as things get increasingly unlikely. quadratic felt
+ // the best in testing, but this may need adjustment in the future.
+
+ const probability = Math.pow(1 - likelihood, iters);
+ const distance = (1 - Math.pow(probability, 2)) * 100;
+ setProgress(distance);
+
+ if (probability < 0.1 && !apologyShown) {
+ apologyShown = true;
+ setShowApology(true);
+ }
+ },
+ )
+ .then((result: any) => {
+ const t1 = Date.now();
+ const { hash, nonce } = result;
+ console.log({ hash, nonce });
+
+ const doRedirect = () => {
+ const redir = getRedirectUrl();
+ window.location.replace(
+ u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
+ id: challenge.id,
+ response: hash,
+ nonce,
+ redir,
+ elapsedTime: t1 - t0,
+ }),
+ );
+ };
+
+ if (detailsRead.current) {
+ redirectFn.current = doRedirect;
+ setPhase("reading");
+ setTimeout(doRedirect, 30000);
+ } else {
+ doRedirect();
+ }
+ })
+ .catch((err: Error) => {
+ showError(
+ t("calculation_error"),
+ `${t("calculation_error_msg")} ${err.message}`,
+ imageURL("reject", anubisVersion, basePrefix),
+ );
+ });
+ }, []);
+
+ const pensiveURL = imageURL("pensive", anubisVersion, basePrefix);
+
+ if (phase === "error") {
+ return (
+ <>
+
+
{errorMessage}
+ >
+ );
+ }
+
+ if (phase === "loading") {
+ return (
+ <>
+
+ {t("calculating")}
+ >
+ );
+ }
+
+ // computing or reading
+ return (
+ <>
+
+
+ {`${t("calculating_difficulty")} ${difficulty}, `}
+ {`${t("speed")} ${speed}`}
+ {showApology && (
+ <>
+
+ {t("verification_longer")}
+ >
+ )}
+
+ {phase === "reading" ? (
+ redirectFn.current?.()}
+ >
+ {t("finished_reading")}
+
+ ) : (
+
+ )}
+ >
+ );
+}
+
+// Bootstrap: init translations, then mount Preact
+(async () => {
+ await initTranslations();
+ const anubisVersion = j("anubis_version");
+ const basePrefix = j("anubis_base_prefix");
+ const root = document.getElementById("app");
+ if (root) {
+ render(, root);
+ }
+})();
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 00000000..3cdd56be
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "noEmit": true,
+ "skipLibCheck": true,
+ "strict": false
+ },
+ "include": ["js/**/*.ts", "js/**/*.tsx"]
+}