610 lines
18 KiB
JavaScript
610 lines
18 KiB
JavaScript
const shortcut = document.getElementById('findMyShortcut');
|
||
let failIndex = 0;
|
||
|
||
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],
|
||
{
|
||
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: 'weblog',
|
||
img: '/weblog/favicon.svg',
|
||
label: 'Weblog',
|
||
onclick: () => requireAccess(() => location.href = 'https://chaegeon.com/weblog/index.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;
|
||
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() {
|
||
const session = await getSession();
|
||
return !!session.admin;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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 = `
|
||
<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 = '블로그 - 채건닷컴';
|
||
}
|