Files
custom-launcher/js/main.js
T

540 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const shortcut = document.getElementById('findMyShortcut');
let failIndex = 0;
const apiUrl = '/custom/launcher/api.php';
const PIN_LENGTH = 4;
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')
}
];
async 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);
});
if (!await hasAdminSession()) {
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 requireAccess(nextAction) {
try {
if (await hasAdminSession()) {
nextAction && nextAction();
return;
}
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 = 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 = `
<div id="poke_container" style="width:100%; display:flex; flex-direction:column; gap:6px;">
<p id="poke_title" style="text-align:center; font-weight:bold; margin-bottom:4px; color:#007AFF; font-size:1.05em;">${countText}</p>
<input type="text" id="poke_sender" placeholder="보내는 이 (미입력 시 익명)" style="padding:8px 12px; border:1px solid #eee; border-radius:10px; outline:none; font-size:14px; background:#f9f9f9;">
<input type="text" id="poke_message" placeholder="할 말 (선택 사항)" style="padding:8px 12px; border:1px solid #eee; border-radius:10px; outline:none; font-size:14px; background:#f9f9f9;">
<div class="button-custom-container" style="margin-top:6px; gap:6px;">
<button class="button-custom" id="btn_poke_cancel" style="background-color:#eee; color:#666; flex:1; min-width:auto;">닫기</button>
<button class="button-custom" id="btn_poke_confirm" style="flex:2; min-width:auto;">콕 찌르기</button>
</div>
<p id="poke_status" style="text-align:center; font-size:10px; color:#bbb; margin-top:2px; height:1px;"></p>
</div>
`;
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 = `현재까지 <span style="color:#ff3b30;">${n}회</span> 찔렸습니다!`;
})
.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 = '<span class="backArrow"></span> 목록';
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 = '블로그 - 채건닷컴';
}