Updated Alarm Panel
unknown
html
19 hours ago
33 kB
7
No Index
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>Alarm Panel</title> <style> :root { --bg: #0c0f14; --panel-top: #233247; --panel-bot: #1b2636; --chip-blue: #0f76c7; --chip-cyan: #12a6d8; --text: #e9f2fb; --muted: #b9c6d6; } @property --g1 { syntax: "<color>"; inherits: false; initial-value: #0d3563; } @property --g2 { syntax: "<color>"; inherits: false; initial-value: #0e74c8; } @property --g3 { syntax: "<color>"; inherits: false; initial-value: #0f89ce; } /* animated shield colors (must inherit so .shield sees body’s updates) */ @property --sh1 { syntax: "<color>"; inherits: true; initial-value: #1d90d1; } @property --sh2 { syntax: "<color>"; inherits: true; initial-value: #0f76c7; } @property --sh3 { syntax: "<color>"; inherits: true; initial-value: #0e5aa7; } html, body { height: 100%; } body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial; background: linear-gradient( 135deg, var(--g1) 0%, var(--g2) 50%, var(--g3) 100% ); color: var(--text); display: flex; align-items: center; justify-content: center; transition: --g1 0.45s ease, --g2 0.45s ease, --g3 0.45s ease; } body.bg-blue { --g1: #0d3563; --g2: #0e74c8; --g3: #0f89ce; --sh1: #1d90d1; --sh2: #0f76c7; --sh3: #0e5aa7; } body.bg-red { --g1: #b42727; --g2: #0e74c8; --g3: #0f89ce; --sh1: #e56a6a; --sh2: #c53c3c; --sh3: #8a2f2f; } body.bg-green { --g1: #10a34a; --g2: #0e74c8; --g3: #0f89ce; --sh1: #31d27f; --sh2: #10a34a; --sh3: #0d7f3a; } .wrap { width: min(1200px, 96vw); display: grid; grid-template-columns: 1.1fr 1fr; gap: 28px; } /* Left: status + actions */ .status { min-height: 280px; border-radius: 24px; padding: 24px; background: linear-gradient( 180deg, var(--panel-top) 0%, var(--panel-bot) 100% ); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(255, 255, 255, 0.04); display: grid; grid-template-rows: 1fr auto auto; place-items: center; position: relative; } .shield { width: 140px; height: 140px; border-radius: 999px; display: grid; place-items: center; position: relative; background: radial-gradient( circle at 50% 40%, var(--sh1) 0%, var(--sh2) 65%, var(--sh3) 100% ); box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38), inset 0 0 22px rgba(255, 255, 255, 0.08); overflow: visible; /* animate the shield colors */ transition: --sh1 0.45s ease, --sh2 0.45s ease, --sh3 0.45s ease; } .shield svg { width: 76px; height: 76px; fill: #fff; } .title { font-weight: 700; font-size: 22px; margin-top: 6px; } .state { color: var(--muted); font-size: 14px; margin-top: 4px; } .actions { margin-top: 18px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; } .actions.one { grid-template-columns: 1fr; } .action { border-radius: 16px; padding: 14px; background: #1f2838; text-align: center; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.35); user-select: none; cursor: pointer; transition: transform 0.12s ease, filter 0.2s ease, box-shadow 0.2s ease; } .action:active { transform: scale(0.97); } .iconchip { display: inline-grid; place-items: center; width: 42px; height: 42px; border-radius: 999px; color: #fff; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); margin-bottom: 8px; } .chip-blue { background: var(--chip-blue); } .chip-cyan { background: var(--chip-cyan); } .svgicon { width: 24px; height: 24px; background: #fff; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; -webkit-mask-size: contain; mask-size: contain; } .action span { display: block; font-weight: 600; color: var(--text); } .action.danger { background: linear-gradient(180deg, #b64848 0%, #8a2f2f 100%); } /* Right: keypad panel */ .panel { border-radius: 24px; padding: 18px 20px 20px; background: linear-gradient(180deg, #222b38 0%, #1a2230 100%); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.36), inset 0 0 0 1px rgba(255, 255, 255, 0.04); } .panel .head { font-size: 22px; font-weight: 700; display: flex; align-items: center; gap: 10px; } .panel .head { display: none !important; } .badge { font-size: 12px; padding: 3px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); } .codebox { margin: 12px 0 16px; background: #212a39; border-radius: 12px; padding: 10px 12px; color: #e9f2fb; border: 1px solid transparent; min-height: 22px; display: flex; align-items: center; justify-content: space-between; gap: 10px; } .codebox input { font-size: 28px; letter-spacing: 2px; font-weight: 600; background: none; border: none; color: #ffffff; } .pill { border-radius: 999px; padding: 8px 12px; background: rgba(18, 166, 216, 0.18); border: 1px solid rgba(255, 255, 255, 0.06); cursor: pointer; user-select: none; } .pill.icon { width: 44px; height: 38px; display: grid; place-items: center; font-size: 18px; padding: 0; } .pill.icon#clearBtn { visibility: hidden; font-size: 30px; color: #ffffff; border-radius: 100%; height: 44px; } .keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } .key { position: relative; overflow: hidden; height: 72px; border-radius: 999px; display: grid; place-items: center; font-size: 20px; color: #cbd7e6; background: rgba(255, 255, 255, 0.06); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 6px 18px rgba(0, 0, 0, 0.35); user-select: none; cursor: pointer; transition: transform 0.1s ease; } .key:active { transform: scale(0.96); } .key .r { position: absolute; border-radius: 999px; pointer-events: none; width: 12px; height: 12px; background: rgba(255, 255, 255, 0.25); transform: translate(-50%, -50%) scale(0.2); animation: rip 0.45s ease-out; } @keyframes rip { to { opacity: 0; transform: translate(-50%, -50%) scale(2.2); } } /* Key highlight effect */ .key.lit { background: rgba(18, 166, 216, 0.35); color: #fff; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1), 0 0 0 10px rgba(18, 166, 216, 0.12), 0 6px 18px rgba(0, 0, 0, 0.35); } .hidden { display: none; } .busy .action, .busy .key { pointer-events: none; filter: grayscale(0.2); opacity: 0.85; } .shield { overflow: visible; } @keyframes breatheRed { 0% { box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38), 0 0 0 0 rgba(214, 77, 77, 0.45), inset 0 0 22px rgba(255, 255, 255, 0.08); } 70% { box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38), 0 0 0 36px rgba(214, 77, 77, 0), inset 0 0 22px rgba(255, 255, 255, 0.08); } 100% { box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38), 0 0 0 0 rgba(214, 77, 77, 0), inset 0 0 22px rgba(255, 255, 255, 0.08); } } @keyframes breatheGreen { 0% { box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38), 0 0 0 0 rgba(16, 163, 74, 0.45), inset 0 0 22px rgba(255, 255, 255, 0.08); } 70% { box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38), 0 0 0 36px rgba(16, 163, 74, 0), inset 0 0 22px rgba(255, 255, 255, 0.08); } 100% { box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38), 0 0 0 0 rgba(16, 163, 74, 0), inset 0 0 22px rgba(255, 255, 255, 0.08); } } .shield.arming { animation: breatheRed 1.05s ease-out infinite; } .shield.disarming { animation: breatheGreen 1.05s ease-out infinite; } /* Expanding ring that travels outwards */ .shield::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; border: 2px solid transparent; opacity: 0; transform: scale(1); } @keyframes ringOutRed { 0% { transform: scale(1); opacity: 0.55; border-color: rgba(214, 77, 77, 0.7); } 100% { transform: scale(2.6); opacity: 0; border-color: rgba(214, 77, 77, 0); } } @keyframes ringOutGreen { 0% { transform: scale(1); opacity: 0.55; border-color: rgba(16, 163, 74, 0.7); } 100% { transform: scale(2.6); opacity: 0; border-color: rgba(16, 163, 74, 0); } } .shield.arming::after { animation: ringOutRed 1.05s ease-out infinite; } .shield.disarming::after { animation: ringOutGreen 1.05s ease-out infinite; } .codebox.error { border: 1px solid #d64d4d; box-shadow: 0 0 0 6px rgba(214, 77, 77, 0.15), inset 0 0 0 1px rgba(255, 255, 255, 0.05); background: radial-gradient( circle, rgba(214, 77, 77, 0.15) 0%, rgba(214, 77, 77, 0.05) 100% ); animation: wobble 0.35s; } @keyframes wobble { 0% { transform: translateX(0); } 25% { transform: translateX(-6px); } 50% { transform: translateX(6px); } 75% { transform: translateX(-3px); } 100% { transform: translateX(0); } } @media (max-width: 900px) { .wrap { grid-template-columns: 1fr; } .status { min-height: 240px; } } </style> </head> <body class="bg-blue"> <div class="wrap"> <!-- LEFT: Status + actions --> <div> <div class="status" id="statusTile"> <div class="shield" id="shieldIcon" aria-hidden="true"> <svg viewBox="0 0 24 24"> <path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z" /> </svg> </div> <div class="title" id="titleText">Ready to Arm</div> <div class="state" id="stateText">—</div> </div> <!-- dynamic actions go here --> <div class="actions" id="actions"></div> </div> <!-- RIGHT: Keypad --> <div class="panel"> <div class="head"> Keypad <span class="badge" id="badgeState">—</span> </div> <div class="codebox"> <input id="codeInput" type="password" inputmode="numeric" pattern="[0-9]*" placeholder="Code" /> <button id="clearBtn" class="pill icon" aria-label="Clear" title="Clear" > × </button> </div> <div class="keypad" id="keypad"></div> </div> </div> <script> /*** CONFIG ***/ const HA_BASE_URL = window.location.origin; const HA_TOKEN = "REPLACE_WITH_YOUR_LONG_LIVED_ACCESS_TOKEN"; const ALARM_ENTITY = "alarm_control_panel.home_alarm"; // change to your alarm entity_id const REQUIRE_CODE_TO_ARM = true; // set true if your alarm needs a code to arm /*** REST helpers ***/ async function haService(domain, service, data) { const res = await fetch( `${HA_BASE_URL}/api/services/${domain}/${service}`, { method: "POST", headers: { Authorization: `Bearer ${HA_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify(data), } ); const text = await res.text(); if (!res.ok) throw new Error( `${res.status} ${res.statusText}\n${text || ""}`.trim() ); return text ? JSON.parse(text) : {}; } async function haState(entity_id) { const res = await fetch(`${HA_BASE_URL}/api/states/${entity_id}`, { headers: { Authorization: `Bearer ${HA_TOKEN}` }, }); if (!res.ok) throw new Error("state fetch failed"); return res.json(); } /*** Elements ***/ const body = document.body; const wrap = document.body; const badgeState = document.getElementById("badgeState"); const stateText = document.getElementById("stateText"); const titleText = document.getElementById("titleText"); const shield = document.getElementById("shieldIcon"); const actions = document.getElementById("actions"); const codeInput = document.getElementById("codeInput"); const clearBtn = document.getElementById("clearBtn"); /*** Countdowns / state cache ***/ let awayCountdownTimer = null; let awayCountdownRemaining = null; let transientBGUntil = 0; let lastKnownState = null; let lastRequestedArmMode = null; // 'away' | 'home' | null let awayCountdownSource = null; // null | 'fallback' | 'ha' let disarmingUntil = 0; // timestamp until which we show "Disarming" /*** Pretty state + icons ***/ function prettyState(st) { if (st === "disarmed") return "Disarmed"; if (st === "armed_home") return "Armed (Stay)"; if (st === "armed_away") return "Armed (Away)"; if (st === "armed_night") return "Armed (Night)"; if (st === "pending" || st === "arming") return "Arming"; return st?.replace("_", " ") || "—"; } function shieldPathFor(st) { const check = '<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z"/>'; const home = '<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,7L7,11H9V17H15V11H17"/>'; const lock = '<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M15,11V9A3,3 0 0,0 9,9V11H8A2,2 0 0,0 6,13V18A2,2 0 0,0 8,20H16A2,2 0 0,0 18,18V13A2,2 0 0,0 16,11H15M12,17A2,2 0 0,1 10,15A2,2 0 0,1 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17Z"/>'; const alarm = '<path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M11,6H13V13H11V6M11,16H13V18H11V16Z"/>'; if (st === "disarmed") return check; if (st === "armed_home") return home; if (st === "armed_away") return lock; if (st === "pending" || st === "arming" || st === "triggered") return alarm; return check; } /*** BG color helpers ***/ function setBG(color) { body.classList.remove("bg-blue", "bg-red", "bg-green"); body.classList.add("bg-" + color); } function parseRemaining(attrs) { const dyn = [ "seconds_remaining", "remaining", "exit_delay_remaining", "delay_remaining", "time_remaining", ]; for (const k of dyn) { const v = Number(attrs?.[k]); if (Number.isFinite(v) && v >= 0) return { value: Math.ceil(v), source: "ha" }; } const stat = ["exit_delay", "delay", "arming_time"]; for (const k of stat) { const v = Number(attrs?.[k]); if (Number.isFinite(v) && v > 0) return { value: Math.ceil(v), source: "fallback" }; } return null; } function setUI(st, attrs = {}) { const isArming = st === "pending" || st === "arming"; const isDisarmed = st === "disarmed"; const isArmedAny = st === "armed_home" || st === "armed_away" || st === "armed_night" || st === "armed_custom_bypass"; // Clear input only on transition to disarmed const wasDisarmed = lastKnownState === "disarmed"; if (!wasDisarmed && isDisarmed) { if (codeInput) codeInput.value = ""; toggleClear(); const box = document.querySelector(".codebox"); box?.classList.remove("error"); stopAwayCountdown(); lastRequestedArmMode = null; disarmingUntil = 0; // ensure it stops showing "Disarming" } if (isArmedAny) { // once fully armed, clear the remembered request lastRequestedArmMode = null; } badgeState.textContent = prettyState(st); titleText.textContent = isArming ? "System Arming" : isDisarmed ? "Ready to Arm" : "System Armed"; shield.innerHTML = `<svg viewBox="0 0 24 24">${shieldPathFor( st )}</svg>`; // Background color logic (keeps transient green during disarm) if (Date.now() < transientBGUntil) { // keep transient color } else { if (isDisarmed) setBG("blue"); else setBG("red"); // arming & armed = red } // Shield animation shield.classList.remove("disarming", "arming"); if (isArming) shield.classList.add("arming"); // Actions renderActions(st); // ----- Arming Away countdown logic (no reset from static attrs) ----- const awayAttr = attrs?.arm_mode === "away" || attrs?.exit_mode === "away" || attrs?.mode === "away" || attrs?.armed_mode === "away"; const awayModeActive = awayAttr || lastRequestedArmMode === "away"; if ((st === "pending" || st === "arming") && awayModeActive) { const rem = parseRemaining(attrs); if (rem) { if (rem.source === "ha") { if ( awayCountdownSource !== "ha" || Math.abs((awayCountdownRemaining ?? rem.value) - rem.value) > 2 ) { startAwayCountdown(rem.value, "ha"); } } else { if (awayCountdownRemaining == null) startAwayCountdown(rem.value, "fallback"); } } else if (awayCountdownRemaining == null) { startAwayCountdown(60, "fallback"); } } else if (st === "armed_away" || isDisarmed) { stopAwayCountdown(); } // Countdown text vs normal if (awayCountdownRemaining != null) { stateText.textContent = `Arming Away (${awayCountdownRemaining}s)`; badgeState.textContent = "Arming"; titleText.textContent = "System Arming"; } else { stateText.textContent = prettyState(st); } // While disarming (after button tap, before HA flips), override labels if (Date.now() < disarmingUntil) { titleText.textContent = "Disarming"; badgeState.textContent = "Disarming"; stateText.textContent = "Disarming"; } // update last known lastKnownState = st; } /*** Actions area ***/ function renderActions(st) { actions.innerHTML = ""; if (st === "disarmed" || st === "unknown") { actions.className = "actions"; actions.appendChild( makeAction("Arm Away", "armAway.svg", "chip-blue", armAway) ); actions.appendChild( makeAction("Arm Stay", "armStay.svg", "chip-cyan", armHome) ); } else { actions.className = "actions one"; const dis = makeAction("Disarm", "disarm.svg", "chip-cyan", disarm); dis.classList.add("danger"); actions.appendChild(dis); } } function makeAction(label, svgPath, chipClass, onClick) { const b = document.createElement("button"); b.type = "button"; b.className = "action"; b.innerHTML = ` <div class="iconchip ${chipClass}"> <div class="svgicon" style="-webkit-mask-image:url('${svgPath}'); mask-image:url('${svgPath}');"></div> </div> <span>${label}</span>`; b.addEventListener("click", () => { press(b); onClick(); }); return b; } /*** Poll state ***/ async function refresh() { try { const s = await haState(ALARM_ENTITY); setUI(s.state, s.attributes || {}); } catch (e) { console.error(e); badgeState.textContent = "Offline"; } } /*** Determine outcome after a service call (state-based verification) ***/ function isArmedState(st) { return ( st === "armed_home" || st === "armed_away" || st === "armed_night" || st === "armed_custom_bypass" ); } function isDisarmedState(st) { return st === "disarmed"; } function isArmingState(st) { return st === "pending" || st === "arming"; } /** * Verify that the requested action actually took effect by polling state briefly. * - For disarm: expect disarmed; otherwise treat as incorrect code. * - For arm_*: expect pending or armed_*; otherwise treat as failure (likely needs code). */ async function verifyOutcome(service, maxMs = 2500) { const start = Date.now(); while (Date.now() - start < maxMs) { const s = await haState(ALARM_ENTITY); const st = s.state; if (service === "alarm_disarm") { if (isDisarmedState(st)) return { ok: true, state: st }; } else { // arm_home / arm_away if (isArmingState(st) || isArmedState(st)) return { ok: true, state: st }; } await new Promise((r) => setTimeout(r, 250)); } return { ok: false, state: lastKnownState }; } /*** Toast + inline code error ***/ function toast(msg, type = "info") { let t = document.getElementById("toast"); if (!t) { t = document.createElement("div"); t.id = "toast"; t.style.cssText = "position:fixed;left:50%;bottom:28px;transform:translateX(-50%);" + "color:#e9f2fb;padding:10px 14px;border-radius:999px;" + "box-shadow:0 6px 18px rgba(0,0,0,.35);z-index:9999;font:600 14px system-ui;transition:opacity .2s"; document.body.appendChild(t); } const bg = type === "error" ? "#b64848" : type === "ok" ? "#146c3d" : "#1e2a3a"; t.style.background = bg; t.textContent = msg; t.style.opacity = "1"; clearTimeout(toast._t); toast._t = setTimeout(() => (t.style.opacity = "0"), 1800); } function codeError(message = "Incorrect code") { const box = document.querySelector(".codebox"); box.classList.add("error"); codeInput.value = ""; toggleClear(); clearTimeout(codeError._t); codeError._t = setTimeout(() => { box.classList.remove("error"); }, 2000); toast(message, "error"); } /*** Service calls with verification ***/ function needCodeFor(service) { return service === "alarm_disarm" || REQUIRE_CODE_TO_ARM; } async function callAlarm(service) { const typed = (codeInput?.value || "").trim(); const must = needCodeFor(service); const isArm = service.startsWith("alarm_arm_"); if (must && !typed) { toast("Enter code first", "error"); shake(".codebox"); return; } // Remember which mode was requested (drives countdown/fallback) if (service === "alarm_arm_away") lastRequestedArmMode = "away"; else if (service === "alarm_arm_home") lastRequestedArmMode = "home"; else if (service === "alarm_disarm") lastRequestedArmMode = null; // Intent: pulse + background shield.classList.remove("disarming", "arming"); if (service === "alarm_disarm") { shield.classList.add("disarming"); // green pulse setBG("green"); disarmingUntil = Date.now() + 5000; transientBGUntil = disarmingUntil; } else { shield.classList.add("arming"); // red pulse setBG("red"); if (service === "alarm_arm_away" && awayCountdownRemaining == null) { startAwayCountdown(60, "fallback"); // immediate fallback; HA will override when attrs arrive } } wrap.classList.add("busy"); const payload = { entity_id: ALARM_ENTITY, ...(typed ? { code: typed } : {}), }; try { await haService("alarm_control_panel", service, payload); // Verify that the state actually changed as intended const result = await verifyOutcome(service, 2500); if (!result.ok) { // Treat as incorrect/missing code if (service === "alarm_disarm") { codeError("Incorrect code"); setBG("red"); // still armed disarmingUntil = 0; // stop saying Disarming } else if (isArm) { toast("Arming failed — enter code", "error"); shake(".codebox"); } } else { // Success: snap UI and clear code after arm (not on disarm) setTimeout(refresh, 250); if (service !== "alarm_disarm") { codeInput.value = ""; toggleClear(); } else { disarmingUntil = 0; // disarmed now; stop saying Disarming } } } catch (e) { console.error(e); const msg = (e?.message || "").toLowerCase(); if (service === "alarm_disarm") disarmingUntil = 0; // stop Disarming on error if (msg.includes("code") || msg.includes("pin") || typed) { codeError("Incorrect code"); } else if (!typed && isArm) { toast("Code required to arm — enter code", "error"); } else { toast("Action failed", "error"); } } finally { setTimeout(() => { shield.classList.remove("disarming", "arming"); wrap.classList.remove("busy"); }, 700); } } const armHome = () => callAlarm("alarm_arm_home"); const armAway = () => callAlarm("alarm_arm_away"); const disarm = () => callAlarm("alarm_disarm"); /*** Countdown helpers ***/ function startAwayCountdown(seconds, source = "fallback") { stopAwayCountdown(); // clear any old timer/source awayCountdownSource = source; awayCountdownRemaining = Math.max(1, Math.ceil(Number(seconds) || 60)); updateCountdownUI(); awayCountdownTimer = setInterval(() => { awayCountdownRemaining--; updateCountdownUI(); if (awayCountdownRemaining <= 0) { stopAwayCountdown(); } }, 1000); } function stopAwayCountdown() { if (awayCountdownTimer) { clearInterval(awayCountdownTimer); awayCountdownTimer = null; } awayCountdownRemaining = null; awayCountdownSource = null; } function updateCountdownUI() { if (awayCountdownRemaining != null) { stateText.textContent = `Arming Away (${awayCountdownRemaining}s)`; badgeState.textContent = "Arming"; titleText.textContent = "System Arming"; } } /*** Build keypad (ripple + blue "light" effect) ***/ (function buildKeypad() { const kp = document.getElementById("keypad"); const layout = [ "1", "2", "3", "4", "5", "6", "7", "8", "9", "×", "0", "←", ]; layout.forEach((k) => { const key = document.createElement("div"); key.className = "key"; key.textContent = k; key.addEventListener("click", (ev) => { press(key); ripple(key, ev); light(key); if (k === "×") { codeInput.value = ""; toggleClear(); return; } if (k === "←") { codeInput.value = codeInput.value.slice(0, -1); toggleClear(); return; } if (/[0-9]/.test(k)) { codeInput.value += k; toggleClear(); } }); kp.appendChild(key); }); })(); /*** Anim + UI helpers ***/ function press(el) { el.style.transform = "scale(0.97)"; setTimeout(() => (el.style.transform = ""), 110); } function ripple(el, ev) { const r = document.createElement("span"); r.className = "r"; const rect = el.getBoundingClientRect(); r.style.left = ev.clientX - rect.left + "px"; r.style.top = ev.clientY - rect.top + "px"; el.appendChild(r); setTimeout(() => r.remove(), 460); } function light(el) { el.classList.add("lit"); setTimeout(() => el.classList.remove("lit"), 180); } function shake(sel) { const el = document.querySelector(sel); if (!el) return; el.animate( [ { transform: "translateX(0)" }, { transform: "translateX(-6px)" }, { transform: "translateX(6px)" }, { transform: "translateX(0)" }, ], { duration: 300, iterations: 1 } ); } function toggleClear() { clearBtn.style.visibility = codeInput.value.length ? "visible" : "hidden"; } /*** Bindings ***/ codeInput.addEventListener("input", toggleClear); clearBtn.onclick = () => { codeInput.value = ""; toggleClear(); }; /*** Init ***/ setBG("blue"); toggleClear(); refresh(); setInterval(refresh, 3000); </script> </body> </html>
Editor is loading...