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 = '블로그 - 채건닷컴';
+}