~james/core

core/web/src/lib/components/ui/Turnstile.svelte -rw-r--r-- 2.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<!-- One explicit-render script per session; mounts share its load promise so
     concurrent instances can't clobber each other's onload. -->
<script module lang="ts">
	// turnstile ships no typings; this is the surface we use
	declare global {
		interface Window {
			turnstile?: {
				render: (
					container: HTMLElement,
					options: {
						sitekey: string;
						size?: "normal" | "compact" | "flexible";
						theme?: "light" | "dark" | "auto";
						callback: (token: string) => void;
						"expired-callback"?: () => void;
						"error-callback"?: () => void;
					}
				) => string;
				reset: (widgetId: string) => void;
				remove: (widgetId: string) => void;
			};
		}
	}

	let script: HTMLScriptElement | undefined;
	let loadPromise: Promise<void> | undefined;

	const ensureLoaded = (): Promise<void> => {
		if (window.turnstile) return Promise.resolve();
		if (loadPromise) return loadPromise;
		script = document.createElement("script");
		script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
		script.async = true;
		loadPromise = new Promise<void>((resolve, reject) => {
			script!.addEventListener("load", () => resolve());
			script!.addEventListener("error", () =>
				reject(new Error("turnstile script failed to load"))
			);
		});
		document.head.appendChild(script);
		return loadPromise;
	};
</script>

<script lang="ts">
	import { onMount } from "svelte";

	interface Props {
		siteKey: string;
		onToken: (token: string) => void;
		/** called when an issued token expires or the challenge errors — the holder must drop it */
		onExpire?: () => void;
	}

	let { siteKey, onToken, onExpire }: Props = $props();

	let container = $state<HTMLDivElement>();
	let widgetId: string | undefined;
	let failed = $state(false);

	export const reset = () => {
		if (widgetId) window.turnstile?.reset(widgetId);
	};

	onMount(() => {
		let cancelled = false;
		ensureLoaded()
			.then(() => {
				if (cancelled) return;
				const el = container;
				const api = window.turnstile;
				if (!el || !api) return;
				const dark = getComputedStyle(el).colorScheme === "dark";
				try {
					widgetId = api.render(el, {
						sitekey: siteKey,
						size: "flexible",
						theme: dark ? "dark" : "light",
						callback: onToken,
						"expired-callback": () => onExpire?.(),
						"error-callback": () => onExpire?.()
					});
				} catch {
					failed = true;
				}
			})
			.catch(() => {
				if (!cancelled) failed = true;
			});

		return () => {
			cancelled = true;
			if (widgetId) window.turnstile?.remove(widgetId);
		};
	});
</script>

{#if failed}
	<p class="typography-paragraph-small text-foreground-danger" role="alert">
		Unable to load the verification check. Check your connection and reload the page.
	</p>
{:else}
	<div bind:this={container} class="min-h-[65px] w-full leading-none"></div>
{/if}