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}