SyncPairing.tsx

11.7 KB · raw

1import { createSignal, onCleanup, Show } from "solid-js";2import { sendMessage } from "../App";3import type { PairedDevice, PairingStep } from "../../shared/sync-types";45interface SyncPairingProps {6  mode: "initiate" | "accept";7  onClose: () => void;8  onPaired: (device: PairedDevice) => void;9}1011function generatePairingCode(): string {12  const array = new Uint32Array(1);13  crypto.getRandomValues(array);14  return String(array[0] % 1000000).padStart(6, "0");15}1617function CodeDisplay(props: { code: string }) {18  return (19    <div class="peach-pairing-code-display">20      {props.code.split("").map((digit) => (21        <span class="peach-pairing-code-digit">{digit}</span>22      ))}23    </div>24  );25}2627function CountdownRing(props: { secondsLeft: number }) {28  const radius = 22;29  const circumference = 2 * Math.PI * radius;30  const progress = () => (props.secondsLeft / 30) * circumference;31  return (32    <div class="peach-pairing-countdown-ring">33      <svg width="56" height="56" viewBox="0 0 56 56">34        <circle35          cx="28"36          cy="28"37          r={radius}38          class="peach-pairing-ring-track"39        />40        <circle41          cx="28"42          cy="28"43          r={radius}44          class="peach-pairing-ring-progress"45          stroke-dasharray={String(circumference)}46          stroke-dashoffset={String(circumference - progress())}47        />48      </svg>49      <span class="peach-pairing-countdown-num">{props.secondsLeft}</span>50    </div>51  );52}5354function Spinner() {55  return <span class="peach-pairing-spinner" />;56}5758export function SyncPairingView(props: SyncPairingProps) {59  const [step, setStep] = createSignal<PairingStep>({ stage: "idle" });60  const [codeInput, setCodeInput] = createSignal("");61  const [secondsLeft, setSecondsLeft] = createSignal(30);62  const [codeError, setCodeError] = createSignal<string | null>(null);6364  let countdownTimer: number | undefined;65  let pairingTimer: number | undefined;6667  onCleanup(() => {68    if (countdownTimer !== undefined) window.clearInterval(countdownTimer);69    if (pairingTimer !== undefined) window.clearTimeout(pairingTimer);70  });7172  function startCountdown(expiresAt: number) {73    if (countdownTimer !== undefined) window.clearInterval(countdownTimer);74    setSecondsLeft(Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)));75    countdownTimer = window.setInterval(() => {76      const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));77      setSecondsLeft(remaining);78      if (remaining === 0) {79        window.clearInterval(countdownTimer);80        setStep({ stage: "idle" });81        void sendMessage({ type: "CANCEL_PAIRING" });82      }83    }, 500);84  }8586  async function handleGenerateCode() {87    const code = generatePairingCode();88    const expiresAt = Date.now() + 30_000;89    setStep({ stage: "generating_code", code, expiresAt });90    startCountdown(expiresAt);9192    const res = await sendMessage({93      type: "START_PAIRING",94      payload: { code, expiresAt },95    });96    if (!(res as { ok: boolean }).ok) {97      setStep({ stage: "error", message: "Failed to start pairing" });98      return;99    }100    setStep({ stage: "waiting_for_peer" });101102    pairingTimer = window.setTimeout(() => {103      setStep({ stage: "idle" });104      void sendMessage({ type: "CANCEL_PAIRING" });105    }, 30_000);106  }107108  async function handleCancel() {109    if (countdownTimer !== undefined) window.clearInterval(countdownTimer);110    if (pairingTimer !== undefined) window.clearTimeout(pairingTimer);111    setStep({ stage: "idle" });112    await sendMessage({ type: "CANCEL_PAIRING" });113  }114115  async function handleConnect() {116    const code = codeInput().replace(/\D/g, "").slice(0, 6);117    if (code.length !== 6) {118      setCodeError("Enter a 6-digit code");119      return;120    }121    setCodeError(null);122    setStep({ stage: "connecting", peerName: "" });123124    const res = await sendMessage({125      type: "ACCEPT_PAIRING",126      payload: { code },127    });128    const response = res as { ok: boolean; data?: PairedDevice; error?: string };129130    if (!response.ok) {131      setStep({ stage: "error", message: response.error ?? "Connection failed" });132      return;133    }134    setStep({ stage: "verifying" });135136    if (response.data) {137      setStep({ stage: "done", device: response.data });138      props.onPaired(response.data);139    }140  }141142  function handleCodeInput(e: InputEvent) {143    const target = e.currentTarget as HTMLInputElement;144    const value = target.value.replace(/\D/g, "").slice(0, 6);145    setCodeInput(value);146    setCodeError(null);147  }148149  return (150    <div class="peach-overlay-view ov-enter-right">151      <div class="peach-overlay-header">152        <button153          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"154          onClick={props.onClose}155          aria-label="Close"156        >157          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">158            <path d="M6 1L1 6L6 11" />159          </svg>160        </button>161        <span class="peach-overlay-title">pair devices</span>162        <div class="peach-overlay-spacer" />163      </div>164165      <div class="peach-pairing-body">166        <Show when={props.mode === "initiate"}>167          <Show when={step().stage === "idle"}>168            <div class="peach-pairing-content">169              <div class="peach-pairing-hero">170                <svg width="40" height="40" viewBox="0 0 40 40" fill="none" class="peach-pairing-icon">171                  <circle cx="20" cy="20" r="18" stroke="var(--peach-accent)" stroke-width="1.5" stroke-dasharray="4 3" />172                  <path d="M14 20h12M20 14v12" stroke="var(--peach-accent)" stroke-width="1.5" stroke-linecap="round" />173                </svg>174              </div>175              <p class="peach-pairing-headline">Pair a new device</p>176              <p class="peach-pairing-subline">177                Generate a code and wait for another device to connect.178              </p>179              <button180                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"181                onClick={() => void handleGenerateCode()}182              >183                Generate pairing code184              </button>185            </div>186          </Show>187188              <Show when={step().stage === "generating_code" || step().stage === "waiting_for_peer"}>189            <div class="peach-pairing-content">190              <Show when={step().stage === "generating_code"}>191                <CodeDisplay code={(step() as { stage: "generating_code"; code: string }).code} />192                <CountdownRing secondsLeft={secondsLeft()} />193              </Show>194              <Show when={step().stage === "waiting_for_peer"}>195                <CodeDisplay code="------" />196                <p class="peach-pairing-status">197                  <Spinner />198                  Waiting for peer...199                </p>200                <p class="peach-pairing-subline peach-pairing-subline--center">201                  Make sure the other device is searching for peers.202                </p>203              </Show>204              <button205                class="peach-btn peach-btn--ghost peach-btn--md"206                onClick={() => void handleCancel()}207              >208                Cancel209              </button>210            </div>211          </Show>212        </Show>213214        <Show when={props.mode === "accept"}>215          <Show when={step().stage === "idle" || step().stage === "entering_code"}>216            <div class="peach-pairing-content">217              <p class="peach-pairing-headline">Pair with another device</p>218              <p class="peach-pairing-subline">219                Enter the 6-digit code shown on the other device.220              </p>221              <div class="peach-pairing-code-inputs">222                {Array.from({ length: 6 }, (_, i) => (223                  <input224                    class={`peach-pairing-code-box ${codeError() ? "has-error" : ""}`}225                    type="text"226                    inputMode="numeric"227                    maxLength={1}228                    value={codeInput()[i] ?? ""}229                    onInput={(e) => {230                      const current = codeInput();231                      const digit = (e.currentTarget as HTMLInputElement).value.replace(/\D/g, "").slice(-1);232                      const newValue = current.slice(0, i) + digit + current.slice(i + 1);233                      setCodeInput(newValue.slice(0, 6));234                      setCodeError(null);235                      if (digit && i < 5) {236                        const inputs = document.querySelectorAll(".peach-pairing-code-box");237                        (inputs[i + 1] as HTMLInputElement)?.focus();238                      }239                    }}240                    onKeyDown={(e) => {241                      if (e.key === "Backspace" && !codeInput()[i] && i > 0) {242                        const inputs = document.querySelectorAll(".peach-pairing-code-box");243                        (inputs[i - 1] as HTMLInputElement)?.focus();244                      }245                    }}246                  />247                ))}248              </div>249              <Show when={codeError()}>250                <p class="peach-pairing-error">{codeError()}</p>251              </Show>252              <button253                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"254                onClick={() => void handleConnect()}255                disabled={codeInput().length !== 6}256              >257                Connect258              </button>259            </div>260          </Show>261262          <Show when={step().stage === "connecting" || step().stage === "verifying"}>263            <div class="peach-pairing-content">264              <Spinner />265              <p class="peach-pairing-status">266                {step().stage === "connecting" ? "Connecting..." : "Verifying..."}267              </p>268            </div>269          </Show>270        </Show>271272        <Show when={step().stage === "done"}>273          <div class="peach-pairing-content">274            <div class="peach-pairing-success-icon">275              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">276                <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />277                <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />278              </svg>279            </div>280            <p class="peach-pairing-headline">Paired!</p>281            <p class="peach-pairing-subline peach-pairing-subline--center">282              {(step() as { stage: "done"; device: PairedDevice }).device.displayName}283            </p>284            <button285              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"286              onClick={props.onClose}287            >288              Done289            </button>290          </div>291        </Show>292293        <Show when={step().stage === "error"}>294          <div class="peach-pairing-content">295            <div class="peach-pairing-error-icon">296              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">297                <circle cx="16" cy="16" r="14" stroke="var(--peach-breach)" stroke-width="1.5" />298                <path d="M11 11L21 21M21 11L11 21" stroke="var(--peach-breach)" stroke-width="1.5" stroke-linecap="round" />299              </svg>300            </div>301            <p class="peach-pairing-headline">Pairing failed</p>302            <p class="peach-pairing-subline peach-pairing-subline--center peach-pairing-error-msg">303              {(step() as { stage: "error"; message: string }).message}304            </p>305            <button306              class="peach-btn peach-btn--ghost peach-btn--md"307              onClick={() => setStep({ stage: "idle" })}308            >309              Try again310            </button>311          </div>312        </Show>313      </div>314    </div>315  );316}