Upload src/pages/Gate.tsx with huggingface_hub
Browse files- src/pages/Gate.tsx +262 -0
src/pages/Gate.tsx
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import { useNavigate } from "react-router-dom";
|
| 3 |
+
import { useApiKey } from "../context/ApiKeyContext";
|
| 4 |
+
import "./gate.css";
|
| 5 |
+
|
| 6 |
+
type Phase = "idle" | "handshake" | "cleared";
|
| 7 |
+
|
| 8 |
+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
| 9 |
+
|
| 10 |
+
function delay(ms: number) {
|
| 11 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function maskKey(key: string): string {
|
| 15 |
+
const trimmed = key.trim();
|
| 16 |
+
if (trimmed.length <= 8) return "••••";
|
| 17 |
+
return `${trimmed.slice(0, 4)}••••${trimmed.slice(-4)}`;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function stampDate(): string {
|
| 21 |
+
return new Date()
|
| 22 |
+
.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
|
| 23 |
+
.toUpperCase();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export default function Gate() {
|
| 27 |
+
const { apiKeys, activeKeyId, addApiKey, setActiveKeyId } = useApiKey();
|
| 28 |
+
const navigate = useNavigate();
|
| 29 |
+
|
| 30 |
+
const prefersReducedMotion = useMemo(
|
| 31 |
+
() => window.matchMedia(REDUCED_MOTION_QUERY).matches,
|
| 32 |
+
[]
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
const hasStoredKeys = apiKeys.length > 0;
|
| 36 |
+
const [enterMode, setEnterMode] = useState<"resume" | "fresh">(
|
| 37 |
+
hasStoredKeys ? "resume" : "fresh"
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
const [workspaceName, setWorkspaceName] = useState("");
|
| 41 |
+
const [apiKey, setApiKey] = useState("");
|
| 42 |
+
const [showKey, setShowKey] = useState(false);
|
| 43 |
+
const [resumeKeyId, setResumeKeyId] = useState(activeKeyId ?? apiKeys[0]?.id ?? "");
|
| 44 |
+
const [error, setError] = useState<string | null>(null);
|
| 45 |
+
const [phase, setPhase] = useState<Phase>("idle");
|
| 46 |
+
|
| 47 |
+
const resumeKey = apiKeys.find((k) => k.id === resumeKeyId);
|
| 48 |
+
|
| 49 |
+
const aliveRef = useRef(true);
|
| 50 |
+
const timersRef = useRef<number[]>([]);
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
aliveRef.current = true;
|
| 53 |
+
return () => {
|
| 54 |
+
aliveRef.current = false;
|
| 55 |
+
timersRef.current.forEach((t) => clearTimeout(t));
|
| 56 |
+
};
|
| 57 |
+
}, []);
|
| 58 |
+
|
| 59 |
+
function finish(opts: { name: string; key: string; resumeId?: string }) {
|
| 60 |
+
if (!aliveRef.current) return;
|
| 61 |
+
if (opts.resumeId) {
|
| 62 |
+
setActiveKeyId(opts.resumeId);
|
| 63 |
+
} else {
|
| 64 |
+
addApiKey(opts.name, opts.key, true);
|
| 65 |
+
}
|
| 66 |
+
navigate("/", { replace: true });
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
async function runHandshake(opts: { name: string; key: string; resumeId?: string }) {
|
| 70 |
+
setPhase("handshake");
|
| 71 |
+
setError(null);
|
| 72 |
+
|
| 73 |
+
if (prefersReducedMotion) {
|
| 74 |
+
finish(opts);
|
| 75 |
+
return;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
await delay(620);
|
| 79 |
+
if (!aliveRef.current) return;
|
| 80 |
+
setPhase("cleared");
|
| 81 |
+
const t = window.setTimeout(() => {
|
| 82 |
+
finish(opts);
|
| 83 |
+
}, 900);
|
| 84 |
+
timersRef.current.push(t);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
| 88 |
+
event.preventDefault();
|
| 89 |
+
if (phase !== "idle") return;
|
| 90 |
+
|
| 91 |
+
if (enterMode === "resume" && resumeKey) {
|
| 92 |
+
void runHandshake({
|
| 93 |
+
name: resumeKey.name,
|
| 94 |
+
key: resumeKey.key,
|
| 95 |
+
resumeId: resumeKey.id,
|
| 96 |
+
});
|
| 97 |
+
return;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const name = workspaceName.trim();
|
| 101 |
+
const key = apiKey.trim();
|
| 102 |
+
if (!name) {
|
| 103 |
+
setError("Name your workspace — anything you will recognize later.");
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
if (!key) {
|
| 107 |
+
setError("Paste your Smartlead API key to open the module.");
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
void runHandshake({ name, key });
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function enterDemo() {
|
| 114 |
+
if (phase !== "idle") return;
|
| 115 |
+
setEnterMode("fresh");
|
| 116 |
+
setWorkspaceName("Demo Workspace");
|
| 117 |
+
setApiKey("demo_bridge_e");
|
| 118 |
+
setError(null);
|
| 119 |
+
void runHandshake({ name: "Demo Workspace", key: "demo_bridge_e" });
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const busy = phase !== "idle";
|
| 123 |
+
const buttonLabel =
|
| 124 |
+
phase === "idle"
|
| 125 |
+
? "OPEN MODULE"
|
| 126 |
+
: phase === "handshake"
|
| 127 |
+
? "OPENING…"
|
| 128 |
+
: "CLEARED";
|
| 129 |
+
|
| 130 |
+
return (
|
| 131 |
+
<div className="gate">
|
| 132 |
+
<h1 className="gate-sr">BRIDGE Modul E — connect a Smartlead workspace</h1>
|
| 133 |
+
|
| 134 |
+
<span className="gate-cropmark tl" aria-hidden="true" />
|
| 135 |
+
<span className="gate-cropmark tr" aria-hidden="true" />
|
| 136 |
+
<span className="gate-cropmark bl" aria-hidden="true" />
|
| 137 |
+
<span className="gate-cropmark br" aria-hidden="true" />
|
| 138 |
+
|
| 139 |
+
<div className="gate-formwrap">
|
| 140 |
+
{phase === "cleared" && (
|
| 141 |
+
<svg className="gate-stamp" viewBox="0 0 120 120" aria-hidden="true">
|
| 142 |
+
<circle cx="60" cy="60" r="56" fill="none" stroke="currentColor" strokeWidth="2.5" />
|
| 143 |
+
<circle cx="60" cy="60" r="47" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
| 144 |
+
<path id="gate-stamp-arc" d="M 24 60 A 36 36 0 0 1 96 60" fill="none" />
|
| 145 |
+
<text fontSize="10.5" fontWeight="700" letterSpacing="2.5" fill="currentColor">
|
| 146 |
+
<textPath href="#gate-stamp-arc" startOffset="50%" textAnchor="middle">
|
| 147 |
+
CLEARED FOR DISPATCH
|
| 148 |
+
</textPath>
|
| 149 |
+
</text>
|
| 150 |
+
<text x="60" y="63" textAnchor="middle" fontSize="15" fontWeight="800" letterSpacing="1.5" fill="currentColor">
|
| 151 |
+
MODUL·E
|
| 152 |
+
</text>
|
| 153 |
+
<text x="60" y="78" textAnchor="middle" fontSize="8.5" fontWeight="600" letterSpacing="2" fill="currentColor">
|
| 154 |
+
{stampDate()}
|
| 155 |
+
</text>
|
| 156 |
+
</svg>
|
| 157 |
+
)}
|
| 158 |
+
|
| 159 |
+
<div className="gate-card">
|
| 160 |
+
<form onSubmit={handleSubmit} noValidate>
|
| 161 |
+
{enterMode === "resume" && hasStoredKeys ? (
|
| 162 |
+
<div className="gate-field">
|
| 163 |
+
<label htmlFor="gate-resume">Stored workspace</label>
|
| 164 |
+
<select
|
| 165 |
+
id="gate-resume"
|
| 166 |
+
value={resumeKeyId}
|
| 167 |
+
onChange={(e) => setResumeKeyId(e.target.value)}
|
| 168 |
+
disabled={busy}
|
| 169 |
+
>
|
| 170 |
+
{apiKeys.map((k) => (
|
| 171 |
+
<option key={k.id} value={k.id}>
|
| 172 |
+
{k.name} — {maskKey(k.key)}
|
| 173 |
+
</option>
|
| 174 |
+
))}
|
| 175 |
+
</select>
|
| 176 |
+
</div>
|
| 177 |
+
) : (
|
| 178 |
+
<>
|
| 179 |
+
<div className="gate-field">
|
| 180 |
+
<label htmlFor="gate-name">Workspace name</label>
|
| 181 |
+
<input
|
| 182 |
+
id="gate-name"
|
| 183 |
+
type="text"
|
| 184 |
+
value={workspaceName}
|
| 185 |
+
onChange={(e) => setWorkspaceName(e.target.value)}
|
| 186 |
+
placeholder="e.g. Acme growth pod"
|
| 187 |
+
autoComplete="off"
|
| 188 |
+
disabled={busy}
|
| 189 |
+
/>
|
| 190 |
+
</div>
|
| 191 |
+
<div className="gate-field">
|
| 192 |
+
<label htmlFor="gate-key">Smartlead API key</label>
|
| 193 |
+
<div className="gate-key-row">
|
| 194 |
+
<input
|
| 195 |
+
id="gate-key"
|
| 196 |
+
type={showKey ? "text" : "password"}
|
| 197 |
+
value={apiKey}
|
| 198 |
+
onChange={(e) => setApiKey(e.target.value)}
|
| 199 |
+
placeholder="Paste your Smartlead key"
|
| 200 |
+
autoComplete="off"
|
| 201 |
+
spellCheck={false}
|
| 202 |
+
disabled={busy}
|
| 203 |
+
className="gate-key-input"
|
| 204 |
+
/>
|
| 205 |
+
<button
|
| 206 |
+
type="button"
|
| 207 |
+
className="gate-peek"
|
| 208 |
+
onClick={() => setShowKey((v) => !v)}
|
| 209 |
+
disabled={busy || apiKey.length === 0}
|
| 210 |
+
>
|
| 211 |
+
{showKey ? "Hide" : "Show"}
|
| 212 |
+
</button>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</>
|
| 216 |
+
)}
|
| 217 |
+
|
| 218 |
+
{error && (
|
| 219 |
+
<p className="gate-error" role="alert">
|
| 220 |
+
{error}
|
| 221 |
+
</p>
|
| 222 |
+
)}
|
| 223 |
+
|
| 224 |
+
<button
|
| 225 |
+
type="submit"
|
| 226 |
+
className="gate-cta"
|
| 227 |
+
data-phase={phase}
|
| 228 |
+
disabled={busy}
|
| 229 |
+
>
|
| 230 |
+
{buttonLabel}
|
| 231 |
+
</button>
|
| 232 |
+
|
| 233 |
+
{enterMode === "resume" ? (
|
| 234 |
+
<button
|
| 235 |
+
type="button"
|
| 236 |
+
className="gate-switch"
|
| 237 |
+
onClick={() => {
|
| 238 |
+
setEnterMode("fresh");
|
| 239 |
+
setError(null);
|
| 240 |
+
}}
|
| 241 |
+
disabled={busy}
|
| 242 |
+
>
|
| 243 |
+
Use a different key
|
| 244 |
+
</button>
|
| 245 |
+
) : (
|
| 246 |
+
<>
|
| 247 |
+
<div className="gate-or">
|
| 248 |
+
<span aria-hidden="true" />
|
| 249 |
+
OR
|
| 250 |
+
<span aria-hidden="true" />
|
| 251 |
+
</div>
|
| 252 |
+
<button type="button" className="gate-demo" onClick={enterDemo} disabled={busy}>
|
| 253 |
+
Enter with demo data
|
| 254 |
+
</button>
|
| 255 |
+
</>
|
| 256 |
+
)}
|
| 257 |
+
</form>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
);
|
| 262 |
+
}
|