서버 게시글
This commit is contained in:
325
GameServer/wwwroot/js/ui.js
Normal file
325
GameServer/wwwroot/js/ui.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// ui.js — 포트폴리오 프론트엔드 공용 UI 헬퍼 모듈
|
||||
// 의존성 없음. 모든 사용자/DB 텍스트는 escapeHtml() 또는 textContent 로만 DOM 에 넣는다.
|
||||
|
||||
/** HTML 특수문자 → 엔티티 매핑 테이블 */
|
||||
const ESCAPE_MAP = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
|
||||
/**
|
||||
* 문자열을 HTML 안전 문자열로 변환한다. null/undefined 는 빈 문자열.
|
||||
* @param {*} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str).replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]);
|
||||
}
|
||||
|
||||
/** 자식 노드 하나를 부모에 붙인다. null/undefined/불리언 은 건너뛴다. */
|
||||
function appendChild(parent, child) {
|
||||
if (child === null || child === undefined || child === false || child === true) return;
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) appendChild(parent, item);
|
||||
return;
|
||||
}
|
||||
if (child instanceof Node) {
|
||||
parent.appendChild(child);
|
||||
return;
|
||||
}
|
||||
parent.appendChild(document.createTextNode(String(child)));
|
||||
}
|
||||
|
||||
/** attrs 의 특수 키(class/text/html/dataset/style/on*)를 처리한다. */
|
||||
function applyAttribute(node, key, value) {
|
||||
if (value === null || value === undefined || value === false) return;
|
||||
|
||||
if (key === 'class' || key === 'className') {
|
||||
node.setAttribute('class', Array.isArray(value) ? value.filter(Boolean).join(' ') : String(value));
|
||||
return;
|
||||
}
|
||||
if (key === 'text') {
|
||||
node.textContent = String(value);
|
||||
return;
|
||||
}
|
||||
if (key === 'html') {
|
||||
// 호출자가 신뢰 가능한(이미 escape 된) HTML 만 넘겨야 한다.
|
||||
node.innerHTML = String(value);
|
||||
return;
|
||||
}
|
||||
if (key === 'dataset') {
|
||||
for (const dk of Object.keys(value)) {
|
||||
const dv = value[dk];
|
||||
if (dv === null || dv === undefined) continue;
|
||||
node.dataset[dk] = String(dv);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'style') {
|
||||
if (typeof value === 'object') Object.assign(node.style, value);
|
||||
else node.setAttribute('style', String(value));
|
||||
return;
|
||||
}
|
||||
if (key.length > 2 && key.slice(0, 2) === 'on' && typeof value === 'function') {
|
||||
node.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
return;
|
||||
}
|
||||
if (value === true) {
|
||||
node.setAttribute(key, '');
|
||||
return;
|
||||
}
|
||||
node.setAttribute(key, String(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 엘리먼트 생성 헬퍼.
|
||||
* @param {string} tag 태그 이름
|
||||
* @param {Object} [attrs] class / text / html / dataset / style / on* / 그 밖의 속성
|
||||
* @param {...any} children 문자열·노드·배열 (null·undefined 는 무시)
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
if (attrs && typeof attrs === 'object') {
|
||||
for (const key of Object.keys(attrs)) applyAttribute(node, key, attrs[key]);
|
||||
}
|
||||
for (const child of children) appendChild(node, child);
|
||||
return node;
|
||||
}
|
||||
|
||||
const TOAST_LIFETIME_MS = 2800;
|
||||
const TOAST_FADE_MS = 220;
|
||||
|
||||
/** #toast-root 를 찾고, 없으면 body 에 하나 만든다. */
|
||||
function toastRoot() {
|
||||
let root = document.getElementById('toast-root');
|
||||
if (!root) {
|
||||
root = el('div', { id: 'toast-root', class: 'toast-root', 'aria-live': 'polite' });
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 알림. 약 2.8초 뒤 자동으로 사라지며 여러 개가 위로 쌓인다.
|
||||
* @param {string} message
|
||||
* @param {'info'|'ok'|'error'} [kind]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function toast(message, kind = 'info') {
|
||||
const cls = kind === 'ok' ? 'toast toast--ok' : kind === 'error' ? 'toast toast--error' : 'toast';
|
||||
const text = message === null || message === undefined ? '' : String(message);
|
||||
const node = el('div', { class: cls, role: 'status', text });
|
||||
const root = toastRoot();
|
||||
root.appendChild(node);
|
||||
|
||||
let removed = false;
|
||||
const remove = () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
node.style.opacity = '0';
|
||||
window.setTimeout(() => node.remove(), TOAST_FADE_MS);
|
||||
};
|
||||
window.setTimeout(remove, TOAST_LIFETIME_MS);
|
||||
node.addEventListener('click', remove);
|
||||
return node;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
|
||||
/** 컨테이너 안에서 실제로 포커스 가능한 요소 목록 */
|
||||
function focusableIn(container) {
|
||||
const all = Array.prototype.slice.call(container.querySelectorAll(FOCUSABLE_SELECTOR));
|
||||
return all.filter((node) => node.offsetParent !== null || node === document.activeElement);
|
||||
}
|
||||
|
||||
/** #modal-root 를 찾고, 없으면 body 에 하나 만든다. */
|
||||
function modalRoot() {
|
||||
let root = document.getElementById('modal-root');
|
||||
if (!root) {
|
||||
root = el('div', { id: 'modal-root' });
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확인/취소 모달. Esc 또는 배경 클릭 시 false 로 resolve 된다.
|
||||
* 모달이 열려 있는 동안 Tab 포커스는 모달 안에 갇힌다.
|
||||
* @param {string} message
|
||||
* @param {{okText?:string, cancelText?:string, title?:string, danger?:boolean}} [opts]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export function confirmDialog(message, opts = {}) {
|
||||
const okText = opts.okText || '확인';
|
||||
const cancelText = opts.cancelText || '취소';
|
||||
const danger = opts.danger === true;
|
||||
const msgText = message === null || message === undefined ? '' : String(message);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
const cancelBtn = el('button', { type: 'button', class: 'btn btn--ghost', text: cancelText });
|
||||
const okBtn = el('button', {
|
||||
type: 'button',
|
||||
class: danger ? 'btn btn--danger' : 'btn btn--primary',
|
||||
text: okText
|
||||
});
|
||||
|
||||
const card = el(
|
||||
'div',
|
||||
{ class: 'modal__card glass', role: 'dialog', 'aria-modal': 'true' },
|
||||
opts.title ? el('p', { class: 'modal__msg', text: String(opts.title) }) : null,
|
||||
el('p', { class: 'modal__msg', text: msgText }),
|
||||
el('div', { class: 'modal__actions' }, cancelBtn, okBtn)
|
||||
);
|
||||
const overlay = el('div', { class: 'modal' }, card);
|
||||
|
||||
let settled = false;
|
||||
const close = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener('keydown', onKeydown, true);
|
||||
overlay.remove();
|
||||
if (previousFocus && document.contains(previousFocus)) previousFocus.focus();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
function onKeydown(ev) {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
close(false);
|
||||
return;
|
||||
}
|
||||
if (ev.key !== 'Tab') return;
|
||||
|
||||
// 포커스 트랩: 모달 밖으로 Tab 이 빠져나가지 못하게 한다.
|
||||
const items = focusableIn(card);
|
||||
if (items.length === 0) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (!card.contains(active)) {
|
||||
ev.preventDefault();
|
||||
first.focus();
|
||||
return;
|
||||
}
|
||||
if (ev.shiftKey && active === first) {
|
||||
ev.preventDefault();
|
||||
last.focus();
|
||||
} else if (!ev.shiftKey && active === last) {
|
||||
ev.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
overlay.addEventListener('mousedown', (ev) => {
|
||||
if (ev.target === overlay) close(false);
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => close(false));
|
||||
okBtn.addEventListener('click', () => close(true));
|
||||
document.addEventListener('keydown', onKeydown, true);
|
||||
|
||||
modalRoot().appendChild(overlay);
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 서버 DateTime 은 UTC 기준이지만 오프셋이 없을 수 있어 Z 를 보정해 준다. */
|
||||
function toLocalDate(value) {
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
if (value === null || value === undefined) return null;
|
||||
let text = String(value).trim();
|
||||
if (!text) return null;
|
||||
const hasZone = /(?:Z|z|[+-]\d{2}:?\d{2})$/.test(text);
|
||||
if (!hasZone && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(text)) {
|
||||
text = text.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
const date = new Date(text);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC 시각을 로컬 기준 'YYYY. MM. DD.' 로 표시한다.
|
||||
* @param {string|Date} iso
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDate(iso) {
|
||||
const date = toLocalDate(iso);
|
||||
if (!date) return '';
|
||||
return date.getFullYear() + '. ' + pad2(date.getMonth() + 1) + '. ' + pad2(date.getDate()) + '.';
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바운스. 반환된 함수에는 cancel() 이 붙어 있다.
|
||||
* @param {Function} fn
|
||||
* @param {number} ms
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function debounce(fn, ms) {
|
||||
const delay = typeof ms === 'number' && ms >= 0 ? ms : 0;
|
||||
let timer = null;
|
||||
function wrapped(...args) {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
const self = this;
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(self, args);
|
||||
}, delay);
|
||||
}
|
||||
wrapped.cancel = () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 진행 표시줄(#app-progress) 토글.
|
||||
* @param {boolean} isBusy
|
||||
*/
|
||||
export function setBusy(isBusy) {
|
||||
const bar = document.getElementById('app-progress');
|
||||
if (!bar) return;
|
||||
bar.classList.toggle('is-busy', !!isBusy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 중 표시할 인덱스 행 스켈레톤 HTML 문자열.
|
||||
* 구조는 views/home.js 의 실제 행(.idx)과 같게 맞춘다.
|
||||
* @param {number} n
|
||||
* @returns {string}
|
||||
*/
|
||||
export function skeletonRows(n) {
|
||||
const count = Math.max(0, Math.floor(Number(n) || 0));
|
||||
let html = '';
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
html +=
|
||||
'<li class="index__item index__item--skeleton" aria-hidden="true">' +
|
||||
'<span class="idx">' +
|
||||
'<span class="idx__no"></span>' +
|
||||
'<span class="idx__title"></span>' +
|
||||
'<span class="idx__meta"></span>' +
|
||||
'</span>' +
|
||||
'</li>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
Reference in New Issue
Block a user