Files
nodecrypt/web/ui.js
T
2025-05-24 16:18:47 +08:00

344 lines
14 KiB
JavaScript

import {
createAvatarSVG
} from './util.avatar.js';
import {
roomsData,
activeRoomIndex,
togglePrivateChat,
exitRoom
} from './room.js';
import {
escapeHTML
} from './util.string.js';
import {
$id
} from './util.dom.js';
export function renderMainHeader() {
const rd = roomsData[activeRoomIndex];
let roomName = rd ? rd.roomName : 'Room';
let onlineCount = rd && rd.userList ? rd.userList.length : 0;
if (rd && !rd.userList.some(u => u.clientId === rd.myId)) {
onlineCount += 1
}
const safeRoomName = escapeHTML(roomName);
$id("main-header").innerHTML = `<button class="mobile-menu-btn"id="mobile-menu-btn"aria-label="Open Sidebar"><svg width="35px"height="35px"viewBox="0 0 24 24"fill="none"xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier"stroke-width="0"></g><g id="SVGRepo_tracerCarrier"stroke-linecap="round"stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill-rule="evenodd"clip-rule="evenodd"d="M21.4498 10.275L11.9998 3.1875L2.5498 10.275L2.9998 11.625H3.7498V20.25H20.2498V11.625H20.9998L21.4498 10.275ZM5.2498 18.75V10.125L11.9998 5.0625L18.7498 10.125V18.75H14.9999V14.3333L14.2499 13.5833H9.74988L8.99988 14.3333V18.75H5.2498ZM10.4999 18.75H13.4999V15.0833H10.4999V18.75Z"fill="#808080"></path></g></svg></button><div class="main-header-center"id="main-header-center"><div class="main-header-flex"><div class="group-title group-title-bold">#${safeRoomName}</div><span class="main-header-members">${onlineCount}members</span></div></div><div class="main-header-actions"><button class="more-btn"id="more-btn"aria-label="More Options"><svg width="35px"height="35px"viewBox="0 0 24 24"fill="none"xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier"stroke-width="0"></g><g id="SVGRepo_tracerCarrier"stroke-linecap="round"stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><circle cx="12"cy="6"r="1.5"fill="#808080"></circle><circle cx="12"cy="12"r="1.5"fill="#808080"></circle><circle cx="12"cy="18"r="1.5"fill="#808080"></circle></g></svg></button><button class="mobile-info-btn"id="mobile-info-btn"aria-label="Open Members"><svg width="35px"height="35px"viewBox="0 0 24 24"fill="none"xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier"stroke-width="0"></g><g id="SVGRepo_tracerCarrier"stroke-linecap="round"stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill-rule="evenodd"clip-rule="evenodd"d="M16.0603 18.307C14.89 19.0619 13.4962 19.5 12 19.5C10.5038 19.5 9.10996 19.0619 7.93972 18.307C8.66519 16.7938 10.2115 15.75 12 15.75C13.7886 15.75 15.3349 16.794 16.0603 18.307ZM17.2545 17.3516C16.2326 15.5027 14.2632 14.25 12 14.25C9.73663 14.25 7.76733 15.5029 6.74545 17.3516C5.3596 15.9907 4.5 14.0958 4.5 12C4.5 7.85786 7.85786 4.5 12 4.5C16.1421 4.5 19.5 7.85786 19.5 12C19.5 14.0958 18.6404 15.9908 17.2545 17.3516ZM21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12ZM12 12C13.2426 12 14.25 10.9926 14.25 9.75C14.25 8.50736 13.2426 7.5 12 7.5C10.7574 7.5 9.75 8.50736 9.75 9.75C9.75 10.9926 10.7574 12 12 12ZM12 13.5C14.0711 13.5 15.75 11.8211 15.75 9.75C15.75 7.67893 14.0711 6 12 6C9.92893 6 8.25 7.67893 8.25 9.75C8.25 11.8211 9.92893 13.5 12 13.5Z"fill="#808080"></path></g></svg></button><div class="more-menu"id="more-menu"><div class="more-menu-item"data-action="share">Share</div><div class="more-menu-item"data-action="exit">Quit</div></div></div>`;
setupMoreBtnMenu();
setupMobileUIHandlers()
}
export function setupMobileUIHandlers() {
const sidebar = document.getElementById('sidebar');
const rightbar = document.getElementById('rightbar');
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
const mobileInfoBtn = document.getElementById('mobile-info-btn');
const sidebarMask = document.getElementById('mobile-sidebar-mask');
const rightbarMask = document.getElementById('mobile-rightbar-mask');
function isMobile() {
return window.innerWidth <= 768
}
function updateMobileBtnDisplay() {
if (isMobile()) {
if (mobileMenuBtn) mobileMenuBtn.style.display = 'flex';
if (mobileInfoBtn) mobileInfoBtn.style.display = 'flex'
} else {
if (mobileMenuBtn) mobileMenuBtn.style.display = 'none';
if (mobileInfoBtn) mobileInfoBtn.style.display = 'none';
if (sidebar) sidebar.classList.remove('mobile-open');
if (rightbar) rightbar.classList.remove('mobile-open');
if (sidebarMask) sidebarMask.classList.remove('active');
if (rightbarMask) rightbarMask.classList.remove('active')
}
}
updateMobileBtnDisplay();
window.addEventListener('resize', updateMobileBtnDisplay);
if (mobileMenuBtn && sidebar && sidebarMask) {
mobileMenuBtn.onclick = function(e) {
e.stopPropagation();
sidebar.classList.add('mobile-open');
sidebarMask.classList.add('active')
};
sidebarMask.onclick = function() {
sidebar.classList.remove('mobile-open');
sidebarMask.classList.remove('active')
}
}
if (mobileInfoBtn && rightbar && rightbarMask) {
mobileInfoBtn.onclick = function(e) {
e.stopPropagation();
rightbar.classList.add('mobile-open');
rightbarMask.classList.add('active')
};
rightbarMask.onclick = function() {
rightbar.classList.remove('mobile-open');
rightbarMask.classList.remove('active')
}
}
document.addEventListener('click', function(ev) {
if (!isMobile()) return;
if (sidebar && sidebar.classList.contains('mobile-open')) {
if (!sidebar.contains(ev.target) && ev.target !== mobileMenuBtn) {
sidebar.classList.remove('mobile-open');
if (sidebarMask) sidebarMask.classList.remove('active')
}
}
if (rightbar && rightbar.classList.contains('mobile-open')) {
if (!rightbar.contains(ev.target) && ev.target !== mobileInfoBtn) {
rightbar.classList.remove('mobile-open');
if (rightbarMask) rightbarMask.classList.remove('active')
}
}
})
}
export function renderUserList(updateHeader = false) {
const userListEl = $id('member-list');
if (!userListEl) return;
userListEl.innerHTML = '';
const rd = roomsData[activeRoomIndex];
if (!rd) return;
const me = rd.userList.find(u => u.clientId === rd.myId);
const others = rd.userList.filter(u => u.clientId !== rd.myId);
if (me) userListEl.appendChild(createUserItem(me, true));
others.forEach(u => userListEl.appendChild(createUserItem(u, false)));
if (updateHeader) {
renderMainHeader()
}
}
export function createUserItem(user, isMe) {
const div = document.createElement('div');
const rd = roomsData[activeRoomIndex];
const isPrivateTarget = rd && user.clientId === rd.privateChatTargetId;
div.className = 'member' + (isMe ? ' me' : '') + (isPrivateTarget ? ' private-chat-active' : '');
const rawName = user.userName || user.username || user.name || '';
const safeUserName = escapeHTML(rawName);
div.innerHTML = `<span class="avatar"></span><div class="member-info"><div class="member-name">${safeUserName}${isMe?' (me)':''}</div></div>`;
const avatarEl = div.querySelector('.avatar');
if (avatarEl) {
const svg = createAvatarSVG(rawName);
const cleanSvg = svg.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
avatarEl.innerHTML = cleanSvg
}
if (!isMe) {
div.onclick = () => togglePrivateChat(user.clientId, safeUserName)
}
return div
}
export function setupMoreBtnMenu() {
const btn = $id('more-btn');
const menu = $id('more-menu');
if (!btn || !menu) return;
let menuRect = null;
let animating = false;
let isMouseMoveBound = false;
function onMouseMove(ev) {
if (!menuRect) return;
const mx = ev.clientX,
my = ev.clientY;
const btnRect = btn.getBoundingClientRect();
const safe = 100;
const inMenu = mx >= menuRect.left - safe && mx <= menuRect.right + safe && my >= menuRect.top - safe && my <= menuRect.bottom + safe;
const inBtn = mx >= btnRect.left - safe && mx <= btnRect.right + safe && my >= btnRect.top - safe && my <= btnRect.bottom + safe;
if (!inMenu && !inBtn) {
closeMenu()
}
}
function bindMouseMove() {
if (!isMouseMoveBound) {
window.addEventListener('mousemove', onMouseMove);
isMouseMoveBound = true
}
}
function unbindMouseMove() {
if (isMouseMoveBound) {
window.removeEventListener('mousemove', onMouseMove);
isMouseMoveBound = false
}
}
function openMenu() {
menu.classList.remove('close');
menu.classList.add('open');
menu.style.display = 'block';
menuRect = menu.getBoundingClientRect();
bindMouseMove()
}
function closeMenu() {
if (animating) return;
animating = true;
menu.classList.remove('open');
menu.classList.add('close');
setTimeout(() => {
if (menu.classList.contains('close')) menu.style.display = 'none';
animating = false;
unbindMouseMove()
}, 180);
menuRect = null
}
btn.onclick = function(e) {
e.stopPropagation();
if (menu.classList.contains('open')) {
closeMenu()
} else {
openMenu()
}
};
menu.onclick = function(e) {
if (e.target.classList.contains('more-menu-item')) {
if (e.target.dataset.action === 'share') {
if (activeRoomIndex >= 0 && roomsData[activeRoomIndex]) {
const rd = roomsData[activeRoomIndex];
const room = encodeURIComponent(rd.roomName || '');
const pwd = encodeURIComponent(rd.password || '');
const url = `${location.origin}${location.pathname}?node=${room}&pwd=${pwd}`;
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(() => {
window.addSystemMsg('Share link copied!')
}, () => {
prompt('Copy failed, url:', url)
})
} else {
prompt('Copy failed, url:', url)
}
}
closeMenu()
} else if (e.target.dataset.action === 'exit') {
const result = exitRoom();
if (!result) {
location.reload()
}
closeMenu()
}
}
};
document.addEventListener('click', function hideMenu(ev) {
if (!menu.contains(ev.target) && ev.target !== btn) {
closeMenu()
}
});
menu.addEventListener('animationend', function(e) {
animating = false
});
menu.addEventListener('transitionend', function(e) {
animating = false
})
}
export function preventSpaceInput(input) {
if (!input) return;
input.addEventListener('keydown', function(e) {
if (e.key === ' ' || (/[\u0000-\u007f]/.test(e.key) && /[\p{P}\p{S}]/u.test(e.key) && e.key !== "'")) {
e.preventDefault()
}
});
input.addEventListener('input', function(e) {
input.value = input.value.replace(/[\s\p{P}\p{S}]/gu, function(match) {
return match === "'" ? "'" : ''
})
})
}
export function loginFormHandler(modal) {
return function(e) {
e.preventDefault();
let userName, roomName, password, btn, roomInput, warnTip;
if (modal) {
userName = document.getElementById('userName-modal').value.trim();
roomName = document.getElementById('roomName-modal').value.trim();
password = document.getElementById('password-modal').value.trim();
btn = modal.querySelector('.login-btn');
roomInput = document.getElementById('roomName-modal')
} else {
userName = document.getElementById('userName').value.trim();
roomName = document.getElementById('roomName').value.trim();
password = document.getElementById('password').value.trim();
btn = document.querySelector('#login-form .login-btn');
roomInput = document.getElementById('roomName')
}
const exists = roomsData.some(rd => rd.roomName && rd.roomName.toLowerCase() === roomName.toLowerCase());
if (roomInput) {
roomInput.style.border = '';
roomInput.style.background = '';
if (roomInput._warnTip) {
roomInput.parentNode.removeChild(roomInput._warnTip);
roomInput._warnTip = null
}
}
if (exists) {
if (roomInput) {
roomInput.style.border = '1.5px solid #e74c3c';
roomInput.style.background = '#fff6f6';
warnTip = document.createElement('div');
warnTip.style.color = '#e74c3c';
warnTip.style.fontSize = '13px';
warnTip.style.marginTop = '4px';
warnTip.textContent = 'Node already exists';
roomInput.parentNode.appendChild(warnTip);
roomInput._warnTip = warnTip;
roomInput.focus()
}
if (btn) {
btn.disabled = false;
btn.innerText = 'ENTER'
}
return
}
if (btn) {
btn.disabled = true;
btn.innerText = 'Connecting...'
}
window.joinRoom(userName, roomName, password, modal, function(success) {
if (!success && btn) {
btn.disabled = false;
btn.innerText = 'ENTER'
}
})
}
}
export function openLoginModal() {
const modal = document.createElement('div');
modal.className = 'login-modal';
modal.innerHTML = `<div class="login-modal-bg"></div><div class="login-modal-card"><button class="login-modal-close login-modal-close-abs">&times;</button><h1>Enter a Node</h1><form id="login-form-modal"><div class="input-group"><label for="userName-modal">Username</label><input id="userName-modal"type="text"autocomplete="username"required minlength="1"maxlength="15"></div><div class="input-group"><label for="roomName-modal">Node Name</label><input id="roomName-modal"type="text"required minlength="1"maxlength="15"></div><div class="input-group"><label for="password-modal">Node Password<span class="optional">(optional)</span></label><input id="password-modal"type="password"autocomplete="off"minlength="1"maxlength="15"></div><button type="submit"class="login-btn">ENTER</button></form></div>`;
document.body.appendChild(modal);
modal.querySelector('.login-modal-close').onclick = () => modal.remove();
preventSpaceInput(modal.querySelector('#userName-modal'));
preventSpaceInput(modal.querySelector('#roomName-modal'));
preventSpaceInput(modal.querySelector('#password-modal'));
const form = modal.querySelector('#login-form-modal');
form.addEventListener('submit', loginFormHandler(modal));
autofillRoomPwd('userName-modal')
}
export function setupTabs() {
const tabs = document.getElementById("member-tabs").children;
for (let i = 0; i < tabs.length; i++) {
tabs[i].onclick = function() {
for (let j = 0; j < tabs.length; j++) tabs[j].classList.remove("active");
this.classList.add("active")
}
}
}
export function autofillRoomPwd(formPrefix = '') {
const params = new URLSearchParams(window.location.search);
const room = params.get('node');
const pwd = params.get('pwd');
if (room) {
const roomInput = document.getElementById(formPrefix + 'roomName');
if (roomInput) {
roomInput.value = decodeURIComponent(room);
roomInput.readOnly = true;
roomInput.style.background = '#f5f5f5'
}
}
if (pwd) {
const pwdInput = document.getElementById(formPrefix + 'password');
if (pwdInput) {
pwdInput.value = decodeURIComponent(pwd);
pwdInput.readOnly = true;
pwdInput.style.background = '#f5f5f5'
}
}
if (room || pwd) {
window.history.replaceState({}, '', location.pathname)
}
}