const shortcut = document.getElementById('findMyShortcut'); let failIndex = 0; let lastRenderedAdmin = null; const apiUrl = '/custom/launcher/api.php'; const PIN_LENGTH = 4; const userIconItems = [ { 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') }, { id: 'git', img: 'https://git.chaegeon.com/assets/img/favicon.svg', label: 'GIT', onclick: () => location.href = 'https://git.chaegeon.com/explore/repos' } ]; const adminIconItems = [ userIconItems[0], userIconItems[1], userIconItems[2], userIconItems[3], { id: 'project', img: 'https://git.chaegeon.com/assets/img/favicon.svg', label: '프로젝트', onclick: () => location.href = 'https://git.chaegeon.com/explore/repos' }, { id: 'block', img: '/admin/favicon/block/favicon.svg', label: 'Block', onclick: () => requireAccess(() => location.href = 'https://chaegeon.com/admin/index.php') }, { id: 'notice', img: '/admin/favicon/notice/favicon.svg', label: 'Notice', onclick: () => requireAccess(() => location.href = 'https://chaegeon.com/admin/notice.php') }, { id: 'speedstats', img: '/custom/speedtest/favicon.ico', label: 'Speed Stats', onclick: () => requireAccess(() => location.href = 'https://chaegeon.com/custom/speedtest/results/stats.php') } ]; async function renderIcons() { const session = await getSession(); if (session.loginBlocked) { renderLoginBlocked(session); return; } const isAdmin = !!session.admin; lastRenderedAdmin = isAdmin; const items = isAdmin ? adminIconItems : userIconItems; shortcut.className = `find-container ${isAdmin ? 'launcher-admin-grid' : ''}`; shortcut.innerHTML = ''; items.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); }); if (!isAdmin) { return; } const manage = document.createElement('button'); manage.type = 'button'; manage.className = 'launcher-manage'; manage.textContent = '관리'; manage.addEventListener('click', () => showPasswordChangeForm()); shortcut.appendChild(manage); } async function hasAdminSession() { try { const session = await fetch(`${apiUrl}?adminSession=1`, { credentials: 'same-origin' }).then(res => res.json()); return !!session.admin; } catch (_) { return false; } } async function getSession() { try { return await fetch(`${apiUrl}?session=1`, { credentials: 'same-origin' }).then(res => res.json()); } catch (_) { return { authenticated: false, admin: false }; } } async function requireAccess(nextAction) { try { const session = await getSession(); if (session.loginBlocked) { renderLoginBlocked(session); return; } if (session.admin || session.authenticated) { nextAction && nextAction(); return; } } catch (_) { } showPasswordForm(nextAction); } async function refreshAdminModeIfNeeded() { if (lastRenderedAdmin !== true) return; const isAdmin = await hasAdminSession(); if (!isAdmin) { await renderIcons(); } } document.addEventListener('visibilitychange', () => { if (!document.hidden) { refreshAdminModeIfNeeded(); } }); window.addEventListener('focus', refreshAdminModeIfNeeded); function renderLoginBlocked(session = {}) { shortcut.className = 'launcher-blocked'; shortcut.innerHTML = ''; const title = document.createElement('strong'); title.textContent = '현재 로그인할 수 없는 상태입니다.'; const message = document.createElement('div'); message.textContent = session.blockedMessage || '잠시 후 다시 시도하세요.'; shortcut.appendChild(title); shortcut.appendChild(message); } function showPasswordForm(onSuccess) { shortcut.className = 'password-container'; shortcut.innerHTML = ''; const title = createFormHeader('비밀번호', renderIcons); const input = createPinInput('blog_secret', '비밀번호'); const msg = document.createElement('div'); msg.id = 'failMsg'; const inputRow = document.createElement('div'); inputRow.className = 'launcher-input-row'; inputRow.appendChild(input); const column = document.createElement('div'); column.className = 'launcher-form-column'; column.appendChild(title); column.appendChild(inputRow); shortcut.appendChild(column); const submit = async () => { const password = onlyDigits(input.value); input.value = password; if (password.length !== PIN_LENGTH) return; 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 = '요청을 처리하지 못했습니다.'; } }; const keypad = createPinKeypad([input], submit); column.appendChild(keypad); attachPinInputEvents(input, submit); input.focus(); } function showPasswordChangeForm() { shortcut.className = 'password-container'; shortcut.innerHTML = ''; const title = createFormHeader('암호 변경', renderIcons); const currentInput = createPinInput('current_password', '현재 암호'); const newInput = createPinInput('new_password', '새 암호'); const confirmInput = createPinInput('confirm_password', '새 암호 확인'); const msg = document.createElement('div'); msg.id = 'failMsg'; const status = document.createElement('div'); status.className = 'launcher-pin-status'; status.textContent = '현재 암호 입력'; const column = document.createElement('div'); column.className = 'launcher-form-column'; column.appendChild(title); column.appendChild(status); column.appendChild(currentInput); column.appendChild(newInput); column.appendChild(confirmInput); column.appendChild(msg); shortcut.appendChild(column); const submit = async () => { const currentPassword = onlyDigits(currentInput.value); const newPassword = onlyDigits(newInput.value); const confirmPassword = onlyDigits(confirmInput.value); currentInput.value = currentPassword; newInput.value = newPassword; confirmInput.value = confirmPassword; if ([currentPassword, newPassword, confirmPassword].some(value => value.length !== PIN_LENGTH)) return; if (newPassword !== confirmPassword) { msg.textContent = '새 암호가 일치하지 않습니다.'; newInput.value = ''; confirmInput.value = ''; newInput.focus(); return; } if (!isDigitPin(currentPassword) || !isDigitPin(newPassword)) { 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_digits_only' ? '암호는 숫자 4자리여야 합니다.' : '현재 암호가 틀립니다.'; if (data.message === 'current_password_invalid') { currentInput.value = ''; newInput.value = ''; confirmInput.value = ''; currentInput.focus(); } } catch (_) { msg.style.color = 'red'; msg.textContent = '요청을 처리하지 못했습니다.'; } }; const labels = new Map([ [currentInput, '현재 암호 입력'], [newInput, '새 암호 입력'], [confirmInput, '새 암호 확인 입력'], ]); const syncStatus = () => { const active = document.activeElement && labels.has(document.activeElement) ? document.activeElement : currentInput; status.textContent = labels.get(active); }; const keypad = createPinKeypad([currentInput, newInput, confirmInput], submit, syncStatus); column.insertBefore(keypad, msg); [currentInput, newInput, confirmInput].forEach(input => { attachPinInputEvents(input, submit); input.addEventListener('focus', syncStatus); }); currentInput.focus(); syncStatus(); } 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 onlyDigits(value) { return String(value || '').replace(/\D+/g, ''); } function isDigitPin(value) { return new RegExp(`^\\d{${PIN_LENGTH}}$`).test(String(value || '')); } function createPinInput(id, placeholder) { const input = document.createElement('input'); input.type = 'password'; input.placeholder = placeholder; input.id = id; input.inputMode = 'numeric'; input.pattern = '[0-9]*'; input.autocomplete = 'off'; input.enterKeyHint = 'done'; input.maxLength = PIN_LENGTH; input.readOnly = true; input.className = 'launcher-pin-input'; input.setAttribute('aria-label', placeholder); return input; } function attachPinInputEvents(input, onEnter) { input.addEventListener('focus', () => { document.querySelectorAll('.launcher-pin-input').forEach(el => el.classList.toggle('active', el === input)); }); input.addEventListener('input', () => { input.value = onlyDigits(input.value); }); input.addEventListener('paste', e => { e.preventDefault(); input.value = (input.value + onlyDigits(e.clipboardData?.getData('text') || '')).slice(0, PIN_LENGTH); if (input.value.length === PIN_LENGTH) onEnter(); }); input.addEventListener('keydown', e => { if (/^\d$/.test(e.key)) { e.preventDefault(); input.value = (input.value + e.key).slice(0, PIN_LENGTH); if (input.value.length === PIN_LENGTH) onEnter(); return; } if (e.key === 'Backspace') { e.preventDefault(); input.value = input.value.slice(0, -1); return; } if (e.key === 'Delete') { e.preventDefault(); input.value = ''; return; } if (e.key === 'Enter') { e.preventDefault(); onEnter(); } }); } function createPinKeypad(inputs, onEnter, onActiveChange = null) { let activeInput = inputs[0]; const keypad = document.createElement('div'); keypad.className = 'launcher-pin-keypad'; const clearInputsFrom = input => { const startIndex = inputs.indexOf(input); if (startIndex < 0) return; inputs.slice(startIndex).forEach(item => { item.value = ''; }); }; const setActiveInput = (input, clear = false, clearFollowing = false) => { activeInput = input; if (clearFollowing) { clearInputsFrom(activeInput); } else if (clear) { activeInput.value = ''; } activeInput.focus(); document.querySelectorAll('.launcher-pin-input').forEach(el => el.classList.toggle('active', el === activeInput)); if (typeof onActiveChange === 'function') onActiveChange(activeInput); }; inputs.forEach(input => { input.addEventListener('focus', () => { activeInput = input; if (typeof onActiveChange === 'function') onActiveChange(activeInput); }); input.addEventListener('click', () => { setActiveInput(input, input.value.length > 0, inputs.length > 1 && input.value.length > 0); }); }); ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'clear', '0', 'back'].forEach(key => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `launcher-pin-key ${key === 'clear' || key === 'back' ? 'launcher-pin-action' : ''}`; btn.textContent = key === 'clear' ? 'C' : (key === 'back' ? '⌫' : key); btn.setAttribute('aria-label', key === 'clear' ? '전체 지우기' : (key === 'back' ? '한 글자 지우기' : `${key} 입력`)); btn.addEventListener('click', () => { activeInput.focus(); if (key === 'clear') { activeInput.value = ''; } else if (key === 'back') { activeInput.value = activeInput.value.slice(0, -1); } else { activeInput.value = (activeInput.value + key).slice(0, PIN_LENGTH); if (activeInput.value.length === PIN_LENGTH) { if (inputs.length === 1) { onEnter(); } else { const idx = inputs.indexOf(activeInput); if (idx >= 0 && idx < inputs.length - 1) { const nextInput = inputs[idx + 1]; setActiveInput(nextInput, nextInput.value.length > 0, nextInput.value.length > 0); } else { onEnter(); } } } } }); keypad.appendChild(btn); }); return keypad; } 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 = '블로그 - 채건닷컴'; }