From 600efc39518d36ffb36b3f6efacdd096da78a179 Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 12 Jun 2026 15:10:14 +0900 Subject: [PATCH] Use shared auth and server-side HA calls --- README.md | 34 +++ api.php | 182 ++++++------- css/style.css | 143 +++++++--- js/main.js | 707 ++++++++++++++++++++++++++++---------------------- 4 files changed, 624 insertions(+), 442 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b4cc71 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Custom Launcher + +사이트 본문에 작은 iOS 스타일 배너로 삽입되는 런처입니다. + +```html + + +
+ +``` + +## 기능 + +- 나의 찾기 이동 +- PC 켜기 +- 콕 찌르기 +- 차량 모니터 이동 +- 공통 암호 로그인 +- 공통 암호 변경 + +## 구조 + +- `api.php`: 로그인, 로그아웃, 암호 변경, 콕 찌르기 카운트/웹훅 처리 +- `js/main.js`: 4개 아이콘 렌더링, 인증 흐름, 암호 변경 UI +- `css/style.css`: iOS 테마 배너 스타일 +- `img/`: 런처 아이콘 이미지 + +## 공통 모듈 연동 + +인증, 암호 hash 저장, Home Assistant 호출은 `/custom/common` 모듈을 사용합니다. 런처는 더 이상 Home Assistant 토큰이나 웹훅을 브라우저로 직접 노출하지 않습니다. + +## 운영 메모 + +메인 버튼은 기존 4개를 유지합니다. 암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리합니다. diff --git a/api.php b/api.php index d33a971..018abbd 100644 --- a/api.php +++ b/api.php @@ -1,88 +1,94 @@ - [ - 'Authorization: Bearer ' . HA_LONG_LIVED_TOKEN, - 'Content-Type: application/json' - ], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 5, - ]); - $res = curl_exec($ch); - $errno = curl_errno($ch); - $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($errno !== 0 || $status < 200 || $status >= 300) { - return null; - } - - $json = json_decode($res, true); - // HA는 상태값을 문자열로 줍니다. 숫자면 (string)"12" 형태. - if (!is_array($json) || !isset($json['state'])) return null; - - // 숫자로 캐스팅 시도 (실패해도 문자열 반환 가능) - $stateRaw = $json['state']; - if (is_numeric($stateRaw)) { - return (int)$stateRaw; - } - return $stateRaw; // 숫자가 아니면 원본 문자열 -} - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $password = $_POST['password'] ?? ''; - - if ($password === '0010') { - echo json_encode(['result' => 'ok']); - } else { - echo json_encode(['result' => 'fail']); - } - exit; -} - -if ($_SERVER['REQUEST_METHOD'] === 'GET') { - if (isset($_GET['failMessages'])) { - echo json_encode([ - 'failMessages' => [ - '비밀번호가 틀립니다.', - ] - ], JSON_UNESCAPED_UNICODE); - exit; - } - - if (isset($_GET['pokeCount'])) { - // HA 엔티티 상태 읽기 - $count = ha_get_entity_state(POKE_ENTITY_ID); - - if ($count === null) { - http_response_code(502); - echo json_encode([ - 'ok' => false, - 'error' => 'failed_to_fetch_from_ha' - ], JSON_UNESCAPED_UNICODE); - exit; - } - - echo json_encode([ - 'ok' => true, - 'count' => $count - ], JSON_UNESCAPED_UNICODE); - exit; - } - - http_response_code(404); - echo json_encode(['error' => 'not_found']); - exit; -} - -http_response_code(405); -echo json_encode(['error' => 'method_not_allowed']); -?> \ No newline at end of file + 'ok']); + } + + custom_json(['result' => 'fail'], 401); + } + + if ($action === 'logout') { + custom_clear_auth_cookie(); + custom_json(['result' => 'ok']); + } + + if ($action === 'changePassword') { + $current = (string)($data['currentPassword'] ?? ''); + $next = (string)($data['newPassword'] ?? ''); + + if (!custom_verify_password($current)) { + custom_json(['result' => 'fail', 'message' => 'current_password_invalid'], 401); + } + + if (mb_strlen($next) < 4) { + custom_json(['result' => 'fail', 'message' => 'password_too_short'], 422); + } + + if (!custom_save_password_hash(password_hash($next, PASSWORD_DEFAULT))) { + custom_json(['result' => 'fail', 'message' => 'password_save_failed'], 500); + } + + custom_issue_auth_cookie(); + custom_json(['result' => 'ok']); + } + + if ($action === 'poke') { + $sender = trim((string)($data['sender'] ?? '')); + $message = trim((string)($data['message'] ?? '')); + $ok = custom_ha_webhook('poke', [ + 'sender' => $sender !== '' ? $sender : '익명', + 'message' => $message, + ]); + + custom_json(['result' => $ok ? 'ok' : 'fail'], $ok ? 200 : 502); + } + + custom_json(['error' => 'not_found'], 404); +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + if (isset($_GET['session'])) { + custom_json(['authenticated' => custom_has_auth_cookie()]); + } + + if (isset($_GET['failMessages'])) { + custom_json([ + 'failMessages' => [ + '비밀번호가 틀립니다.', + ], + ]); + } + + if (isset($_GET['pokeCount'])) { + $state = custom_ha_state('main', $config['entities']['poke_count']); + $count = $state['state'] ?? null; + + if ($count === null) { + custom_json(['ok' => false, 'error' => 'failed_to_fetch_from_ha'], 502); + } + + custom_json([ + 'ok' => true, + 'count' => is_numeric($count) ? (int)$count : $count, + ]); + } + + custom_json(['error' => 'not_found'], 404); +} + +custom_json(['error' => 'method_not_allowed'], 405); diff --git a/css/style.css b/css/style.css index 1aa5904..fe26646 100644 --- a/css/style.css +++ b/css/style.css @@ -1,17 +1,18 @@ -.find-container { - display: flex; - justify-content: center; - align-items: center; - width: 100%; +.find-container { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; height: 100px; background: #f2f2f7; border-radius: 16px; padding: 12px; user-select: none; - margin-bottom: 10px; -} - -.password-container { + margin-bottom: 10px; +} + +.password-container { display: block; margin: 0 auto; width: fit-content; @@ -20,31 +21,32 @@ border-radius: 16px; padding: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - margin-bottom: 10px; -} - -#findMyShortcut input[type="password"] { - padding: 8px 12px; - font-size: 16px; - border-radius: 16px; - border: 1px solid #ccc; - width: 200px; - text-align: center; - font-weight: 500; - background-color: #fff; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - appearance: none; - outline: none; - -webkit-appearance: none; -} + margin-bottom: 10px; +} + +#findMyShortcut input[type="password"] { + padding: 8px 12px; + font-size: 16px; + border-radius: 16px; + border: 1px solid #ccc; + width: 200px; + text-align: center; + font-weight: 500; + background-color: #fff; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); + appearance: none; + outline: none; + -webkit-appearance: none; + margin: 3px 0; +} #findMyShortcut input[type="password"]:focus { border-color: #999; box-shadow: none; } -#findMyShortcut button { - padding: 10px 12px; +#findMyShortcut button { + padding: 10px 12px; font-size: 15px; border-radius: 16px; background-color: #007aff; @@ -52,8 +54,8 @@ border: none; margin-left: 8px; font-weight: 500; - transition: background 0.2s ease; -} + transition: background 0.2s ease; +} #findMyShortcut button:active { background-color: #0051c7; @@ -97,10 +99,77 @@ text-overflow: ellipsis; } -#failMsg { - font-size: 13px; - color: red; - min-height: 18px; - margin-top: 8px; - text-align: center; -} \ No newline at end of file +#failMsg { + font-size: 13px; + color: red; + min-height: 18px; + margin-top: 8px; + text-align: center; +} + +.launcher-manage { + position: absolute; + right: 8px; + bottom: 6px; + padding: 4px 7px !important; + margin: 0 !important; + border-radius: 999px !important; + background: rgba(255, 255, 255, 0.68) !important; + color: #8e8e93 !important; + font-size: 11px !important; + line-height: 1 !important; + box-shadow: none !important; +} + +.launcher-form-column { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.launcher-form-header { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 280px; + margin-bottom: 6px; + padding: 8px 0; + font-size: 16px; + color: #000; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.launcher-back { + position: absolute; + left: 0; + top: 1px; + font-size: 16px; + color: #007aff; + text-decoration: none; + font-weight: normal; + cursor: pointer; +} + +.launcher-back .backArrow { + position: relative; + top: 1px; + margin-right: 1px; + font-size: 26px; +} + +.launcher-input-row { + display: flex; + align-items: center; +} + +#findMyShortcut .launcher-text-button { + margin: 2px 0 0 0; + padding: 4px 8px; + background: transparent; + color: #007aff; + font-size: 13px; + box-shadow: none; +} diff --git a/js/main.js b/js/main.js index 9de94de..98c3e4f 100644 --- a/js/main.js +++ b/js/main.js @@ -1,317 +1,390 @@ -const shortcut = document.getElementById('findMyShortcut'); -let failIndex = 0; - -const iconItems = [ - { - id: 'findmydevice', - img: '/custom/launcher/img/find-my.svg', - label: '나의 찾기', - onclick: () => { - requireAccess(() => location.href = '/findmydevice'); - } - }, - { - id: 'wakeonlan', - img: '/custom/launcher/img/wakeonlan-svgrepo-com.svg', - label: 'PC 켜기', - onclick: () => { - fetch('/custom/wakeonlan/api.php?status=1') - .then(res => res.ok ? res.json() : Promise.reject()) - .then(data => { - - if (!data || data.result !== 'ok') { - showAlert('error'); - return; - } - - if (data.state === 'on') { - showAlert('wakeonlan_already'); - return; - } - - fetch('/custom/wakeonlan/api.php', { method: 'POST' }) - .then(res => res.ok ? res.json() : Promise.reject()) - .then(() => { - showAlert('wakeonlan_request_sent'); - - let attempts = 0; - const interval = setInterval(() => { - attempts++; - fetch('/custom/wakeonlan/api.php?status=1') - .then(res => res.ok ? res.json() : Promise.reject()) - .then(status => { - if (status.state === 'on') { - clearInterval(interval); - showAlert('wakeonlan_success'); - } - else if (attempts >= 12) { - clearInterval(interval); - } - }) - .catch(() => clearInterval(interval)); - }, 5000); - }) - .catch(() => showAlert('error')); - }) - .catch(() => showAlert('error')); - } - }, - { - id: 'poke', - img: '/custom/launcher/img/R1280x0.jpg', - label: '콕 찌르기', - onclick: () => { - showAlert('poke_completed'); - const alertBox = document.querySelector('#poke_completed'); - const content = alertBox.querySelector('.alert-content'); - - content.style.minHeight = 'auto'; - content.style.padding = '15px 20px 10px 20px'; - content.style.justifyContent = 'flex-start'; - - const renderMain = (countText = "조회 중...") => { - content.innerHTML = ` -
-

${countText}

- - - - - -
- - -
-

-
- `; - document.getElementById('btn_poke_cancel').onclick = () => { closeAlert('poke_completed'); }; - document.getElementById('btn_poke_confirm').onclick = handlePoke; - }; - - fetch('/custom/launcher/api.php?pokeCount=0') - .then(res => res.json()) - .then(data => { - const n = Number(data.count) || 0; - document.getElementById('poke_title').innerHTML = `현재까지 ${n}회 찔렸습니다!`; - }) - .catch(() => { - document.getElementById('poke_title').innerText = "현재 횟수를 불러올 수 없음"; - }); - - const handlePoke = () => { - const sender = document.getElementById('poke_sender').value.trim() || "익명"; - const message = document.getElementById('poke_message').value.trim() || ""; - const titleField = document.getElementById('poke_title'); - const statusField = document.getElementById('poke_status'); - const confirmBtn = document.getElementById('btn_poke_confirm'); - - confirmBtn.disabled = true; - statusField.innerText = "전송 중..."; - - const params = new URLSearchParams({ sender, message }); - fetch(`https://ha.chaegeon.com/api/webhook/jczSPJyIX4iG4JTvy9HztFq91MsrIfsUErFh4DLstDcsm7RKL?${params.toString()}`, { - method: 'POST', - mode: 'no-cors' - }); - - fetch(`/custom/launcher/api.php?pokeCount=1&sender=${encodeURIComponent(sender)}`) - .then(res => res.json()) - .then(data => { - const n = Number(data.count); - titleField.innerHTML = `현재까지 ${n}회 찔렸습니다!`; - statusField.innerText = "성공!"; - setTimeout(() => { - confirmBtn.disabled = false; - statusField.innerText = ""; - }, 300); - }) - .catch(() => { - statusField.innerText = "오류 발생"; - confirmBtn.disabled = false; - }); - }; - - renderMain(); - } - }, - { - id: 'car', - img: '/custom/launcher/img/car.png', - label: '니모', - onclick: () => { - requireAccess(() => location.href = 'https://seo.chaegeon.com/car/monitor.php'); - } - } -]; - -function renderIcons() { - shortcut.className = 'find-container'; - shortcut.innerHTML = ''; - - iconItems.forEach(item => { - const div = document.createElement('div'); - div.className = 'iconContainer'; - div.id = item.id; - if (item.onclick) div.addEventListener('click', item.onclick); - - const img = document.createElement('img'); - img.src = item.img; - img.alt = item.label; - - const label = document.createElement('div'); - label.className = 'iconLabel'; - label.textContent = item.label; - - div.appendChild(img); - div.appendChild(label); - shortcut.appendChild(div); - }); -} - -function requireAccess(nextAction) { - const access = getCookie('access'); - if (access === '9KxH6236') { - nextAction && nextAction(); - } else { - showPasswordForm(nextAction); - } -} - -function showPasswordForm(onSuccess) { - shortcut.className = 'password-container'; - shortcut.innerHTML = ''; - - const header = document.createElement('div'); - header.id = 'settingsHeader'; - header.style.display = 'flex'; - header.style.alignItems = 'center'; - header.style.justifyContent = 'center'; - header.style.position = 'relative'; - header.style.marginBottom = '15px'; - header.style.padding = '12px'; - header.style.fontSize = '16px'; - header.style.color = '#000'; - header.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - - const backLink = document.createElement('a'); - backLink.id = 'blogBackLink'; - backLink.innerHTML = ' 목록'; - backLink.style.position = 'absolute'; - backLink.style.left = '0px'; - backLink.style.fontSize = '16px'; - backLink.style.top = '3px'; - backLink.style.color = '#007AFF'; - backLink.style.textDecoration = 'none'; - backLink.style.fontWeight = 'normal'; - backLink.style.cursor = 'pointer'; - backLink.addEventListener('click', () => renderIcons()); - - backLink.querySelector('.backArrow').style.cssText = ` - font-size: 26px; - position: relative; - top: 1px; - margin-right: 1px; -`; - - const title = document.createElement('span'); - title.className = 'headerTitle'; - title.textContent = '비밀번호'; - title.style.fontWeight = 'bold'; - - header.appendChild(backLink); - header.appendChild(title); - shortcut.appendChild(header); - - const input = document.createElement('input'); - input.type = 'password'; - input.placeholder = '비밀번호'; - input.id = 'blog_secret'; - input.inputMode = 'numeric'; - input.pattern = '[0-9]*'; - - const button = document.createElement('button'); - button.id = 'blog_secret_check'; - button.textContent = '확인'; - - const msg = document.createElement('div'); - msg.id = 'failMsg'; - - const inputRow = document.createElement('div'); - inputRow.style.display = 'flex'; - inputRow.style.alignItems = 'center'; - inputRow.appendChild(input); - inputRow.appendChild(button); - - const column = document.createElement('div'); - column.style.display = 'flex'; - column.style.flexDirection = 'column'; - column.style.alignItems = 'center'; - column.appendChild(inputRow); - - shortcut.appendChild(column); - - const submit = async () => { - const password = input.value; - - try { - const messageRes = await fetch('/custom/launcher/api.php?failMessages=1'); - const messagesJson = await messageRes.json(); - const failMessages = messagesJson.failMessages || []; - - const res = await fetch('/custom/launcher/api.php', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ password }) - }); - - const data = await res.json(); - - if (data.result === 'ok') { - document.cookie = 'access=9KxH6236; path=/; max-age=21600; SameSite=Strict' + (location.protocol === 'https:' ? '; Secure' : ''); - if (typeof onSuccess === 'function') { - onSuccess(); - } else { - location.reload(); - } - } else { - column.appendChild(msg); - msg.textContent = failMessages[failIndex] || '비밀번호가 틀렸습니다.'; - failIndex = (failIndex + 1) % Math.max(1, failMessages.length); - input.value = ''; - input.focus(); - } - } catch (_) { - } - }; - - button.addEventListener('click', submit); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') submit(); - }); -} - -function getCookie(name) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop().split(';').shift(); - return null; -} - -document.addEventListener('DOMContentLoaded', async () => { - renderIcons(); - - const findShortcut = document.getElementById('findMyShortcut'); - if (findShortcut) { - findShortcut.style.userSelect = 'none'; - findShortcut.style.webkitUserSelect = 'none'; - findShortcut.style.msUserSelect = 'none'; - findShortcut.style.MozUserSelect = 'none'; - findShortcut.style.webkitTouchCallout = 'none'; - findShortcut.addEventListener('dragstart', e => e.preventDefault()); - } -}); - -if (location.pathname.split('/').filter(Boolean).length === 1) document.title = "블로그 - 채건닷컴"; \ No newline at end of file +const shortcut = document.getElementById('findMyShortcut'); +let failIndex = 0; + +const apiUrl = '/custom/launcher/api.php'; + +const iconItems = [ + { + id: 'findmydevice', + img: '/custom/launcher/img/find-my.svg', + label: '나의 찾기', + onclick: () => requireAccess(() => location.href = '/findmydevice') + }, + { + id: 'wakeonlan', + img: '/custom/launcher/img/wakeonlan-svgrepo-com.svg', + label: 'PC 켜기', + onclick: () => requireAccess(wakeComputer) + }, + { + id: 'poke', + img: '/custom/launcher/img/R1280x0.jpg', + label: '콕 찌르기', + onclick: showPokeForm + }, + { + id: 'car', + img: '/custom/launcher/img/car.png', + label: '니모', + onclick: () => requireAccess(() => location.href = 'https://seo.chaegeon.com/car/monitor.php') + } +]; + +function renderIcons() { + shortcut.className = 'find-container'; + shortcut.innerHTML = ''; + + iconItems.forEach(item => { + const div = document.createElement('div'); + div.className = 'iconContainer'; + div.id = item.id; + if (item.onclick) div.addEventListener('click', item.onclick); + + const img = document.createElement('img'); + img.src = item.img; + img.alt = item.label; + + const label = document.createElement('div'); + label.className = 'iconLabel'; + label.textContent = item.label; + + div.appendChild(img); + div.appendChild(label); + shortcut.appendChild(div); + }); + + const manage = document.createElement('button'); + manage.type = 'button'; + manage.className = 'launcher-manage'; + manage.textContent = '관리'; + manage.addEventListener('click', () => showPasswordChangeForm()); + shortcut.appendChild(manage); +} + +async function requireAccess(nextAction) { + try { + const session = await fetch(`${apiUrl}?session=1`, { credentials: 'same-origin' }).then(res => res.json()); + if (session.authenticated) { + nextAction && nextAction(); + return; + } + } catch (_) { + } + + showPasswordForm(nextAction); +} + +function showPasswordForm(onSuccess) { + shortcut.className = 'password-container'; + shortcut.innerHTML = ''; + + const title = createFormHeader('비밀번호', renderIcons); + const input = createPasswordInput('blog_secret', '비밀번호'); + const button = createButton('확인'); + const changeButton = createTextButton('암호 변경', () => showPasswordChangeForm()); + const msg = document.createElement('div'); + msg.id = 'failMsg'; + + const inputRow = document.createElement('div'); + inputRow.className = 'launcher-input-row'; + inputRow.appendChild(input); + inputRow.appendChild(button); + + const column = document.createElement('div'); + column.className = 'launcher-form-column'; + column.appendChild(title); + column.appendChild(inputRow); + column.appendChild(changeButton); + shortcut.appendChild(column); + + const submit = async () => { + const password = input.value; + + try { + const messageRes = await fetch(`${apiUrl}?failMessages=1`); + const messagesJson = await messageRes.json(); + const failMessages = messagesJson.failMessages || []; + + const res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ action: 'login', password }) + }); + const data = await res.json(); + + if (res.ok && data.result === 'ok') { + if (typeof onSuccess === 'function') { + onSuccess(); + } else { + renderIcons(); + } + return; + } + + column.appendChild(msg); + msg.textContent = failMessages[failIndex] || '비밀번호가 틀렸습니다.'; + failIndex = (failIndex + 1) % Math.max(1, failMessages.length); + input.value = ''; + input.focus(); + } catch (_) { + column.appendChild(msg); + msg.textContent = '요청을 처리하지 못했습니다.'; + } + }; + + button.addEventListener('click', submit); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') submit(); + }); + input.focus(); +} + +function showPasswordChangeForm() { + shortcut.className = 'password-container'; + shortcut.innerHTML = ''; + + const title = createFormHeader('암호 변경', renderIcons); + const currentInput = createPasswordInput('current_password', '현재 암호'); + const newInput = createPasswordInput('new_password', '새 암호'); + const confirmInput = createPasswordInput('confirm_password', '새 암호 확인'); + const button = createButton('변경'); + const msg = document.createElement('div'); + msg.id = 'failMsg'; + + const column = document.createElement('div'); + column.className = 'launcher-form-column'; + column.appendChild(title); + column.appendChild(currentInput); + column.appendChild(newInput); + column.appendChild(confirmInput); + column.appendChild(button); + column.appendChild(msg); + shortcut.appendChild(column); + + const submit = async () => { + const currentPassword = currentInput.value; + const newPassword = newInput.value; + const confirmPassword = confirmInput.value; + + if (newPassword !== confirmPassword) { + msg.textContent = '새 암호가 일치하지 않습니다.'; + return; + } + + try { + const res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ action: 'changePassword', currentPassword, newPassword }) + }); + const data = await res.json(); + + if (res.ok && data.result === 'ok') { + msg.style.color = '#34c759'; + msg.textContent = '변경되었습니다.'; + setTimeout(renderIcons, 700); + return; + } + + msg.style.color = 'red'; + msg.textContent = data.message === 'password_too_short' ? '새 암호는 4자 이상이어야 합니다.' : '현재 암호가 틀립니다.'; + } catch (_) { + msg.style.color = 'red'; + msg.textContent = '요청을 처리하지 못했습니다.'; + } + }; + + button.addEventListener('click', submit); + [currentInput, newInput, confirmInput].forEach(input => { + input.addEventListener('keydown', e => { + if (e.key === 'Enter') submit(); + }); + }); + currentInput.focus(); +} + +async function wakeComputer() { + try { + const data = await fetch('/custom/wakeonlan/api.php?status=1', { credentials: 'same-origin' }) + .then(res => res.ok ? res.json() : Promise.reject()); + + if (!data || data.result !== 'ok') { + launcherAlert('error'); + return; + } + + if (data.state === 'on') { + launcherAlert('wakeonlan_already'); + return; + } + + await fetch('/custom/wakeonlan/api.php', { method: 'POST', credentials: 'same-origin' }) + .then(res => res.ok ? res.json() : Promise.reject()); + + launcherAlert('wakeonlan_request_sent'); + + let attempts = 0; + const interval = setInterval(() => { + attempts++; + fetch('/custom/wakeonlan/api.php?status=1', { credentials: 'same-origin' }) + .then(res => res.ok ? res.json() : Promise.reject()) + .then(status => { + if (status.state === 'on') { + clearInterval(interval); + launcherAlert('wakeonlan_success'); + } else if (attempts >= 12) { + clearInterval(interval); + } + }) + .catch(() => clearInterval(interval)); + }, 5000); + } catch (_) { + launcherAlert('error'); + } +} + +function showPokeForm() { + launcherAlert('poke_completed'); + const alertBox = document.querySelector('#poke_completed'); + const content = alertBox?.querySelector('.alert-content'); + if (!content) return; + + content.style.minHeight = 'auto'; + content.style.padding = '15px 20px 10px 20px'; + content.style.justifyContent = 'flex-start'; + + const renderMain = (countText = '조회 중...') => { + content.innerHTML = ` +
+

${countText}

+ + +
+ + +
+

+
+ `; + document.getElementById('btn_poke_cancel').onclick = () => closeLauncherAlert('poke_completed'); + document.getElementById('btn_poke_confirm').onclick = handlePoke; + }; + + const refreshCount = () => fetch(`${apiUrl}?pokeCount=1`) + .then(res => res.json()) + .then(data => { + const n = Number(data.count) || 0; + document.getElementById('poke_title').innerHTML = `현재까지 ${n}회 찔렸습니다!`; + }) + .catch(() => { + document.getElementById('poke_title').innerText = '현재 횟수를 불러올 수 없음'; + }); + + const handlePoke = () => { + const sender = document.getElementById('poke_sender').value.trim() || '익명'; + const message = document.getElementById('poke_message').value.trim() || ''; + const statusField = document.getElementById('poke_status'); + const confirmBtn = document.getElementById('btn_poke_confirm'); + + confirmBtn.disabled = true; + statusField.innerText = '전송 중...'; + + fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ action: 'poke', sender, message }) + }) + .then(res => res.ok ? res.json() : Promise.reject()) + .then(() => refreshCount()) + .then(() => { + statusField.innerText = '성공!'; + setTimeout(() => { + confirmBtn.disabled = false; + statusField.innerText = ''; + }, 300); + }) + .catch(() => { + statusField.innerText = '오류 발생'; + confirmBtn.disabled = false; + }); + }; + + renderMain(); + refreshCount(); +} + +function createFormHeader(text, onBack) { + const header = document.createElement('div'); + header.className = 'launcher-form-header'; + + const backLink = document.createElement('a'); + backLink.innerHTML = ' 목록'; + backLink.className = 'launcher-back'; + backLink.addEventListener('click', onBack); + + const title = document.createElement('span'); + title.className = 'headerTitle'; + title.textContent = text; + + header.appendChild(backLink); + header.appendChild(title); + return header; +} + +function createPasswordInput(id, placeholder) { + const input = document.createElement('input'); + input.type = 'password'; + input.placeholder = placeholder; + input.id = id; + input.autocomplete = 'current-password'; + return input; +} + +function createButton(text) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = text; + return button; +} + +function createTextButton(text, onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'launcher-text-button'; + button.textContent = text; + button.addEventListener('click', onClick); + return button; +} + +function launcherAlert(name) { + if (typeof showAlert === 'function') { + showAlert(name); + } +} + +function closeLauncherAlert(name) { + if (typeof closeAlert === 'function') { + closeAlert(name); + } +} + +document.addEventListener('DOMContentLoaded', () => { + renderIcons(); + + const findShortcut = document.getElementById('findMyShortcut'); + if (findShortcut) { + findShortcut.style.userSelect = 'none'; + findShortcut.style.webkitUserSelect = 'none'; + findShortcut.style.msUserSelect = 'none'; + findShortcut.style.MozUserSelect = 'none'; + findShortcut.style.webkitTouchCallout = 'none'; + findShortcut.addEventListener('dragstart', e => e.preventDefault()); + } +}); + +if (location.pathname.split('/').filter(Boolean).length === 1) { + document.title = '블로그 - 채건닷컴'; +}