Files
AriWeb-Suite/portal/ui/layout.mjs

48 lines
5.8 KiB
JavaScript
Raw Permalink 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.
/**
* portal/ui/layout.mjs — 고객 포털 공통 화면 틀(§2.1·§2.3).
*
* 서버가 HTML 뼈대(상단: 현재 그룹·알림·도움말·사용자 메뉴 / 왼쪽: 메뉴 / 본문: 제목·현재 위치·주요 작업 버튼·주의 항목)를 내고,
* 각 화면의 데이터는 같은 origin 의 /api/* 를 fetch 하는 작은 화면 스크립트(/ui/app.js + 영역별 js)가 채운다.
* 모든 화면은 '불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음' 상태를 공통 컴포넌트(aw-state)로 보인다.
* 색만으로 상태를 구분하지 않는다: 상태 배지는 항상 글자(라벨)와 아이콘 문자를 함께 쓴다. 360px 너비에서 표의 핵심 값과 버튼이 가려지지 않게 카드형으로 접힌다.
*/
import { esc } from '../../lib/http.mjs';
import { t } from '../../lib/i18n.mjs';
const MENU = [
['dashboard', '/', '▦'], ['sites', '/sites', '▣'], ['mail', '/mail', '✉'], ['domains', '/domains', '◎'], ['backup', '/backup', '⟲'], ['billing', '/billing', '₩'], ['usage', '/usage', '▥'], ['group', '/group', '⚇'],
];
/**
* 전체 화면 HTML.
* @param {object} o { title, nav(현재 메뉴 키), crumbs:[{label,href}], actions:[{label,href|onclick,kind}], user, group, groups:[], unread, body(html), scripts:[js 경로], alerts:[{severity,text,href}] }
*/
export function page(o) {
const user = o.user || null; const group = o.group || null; const groups = o.groups || [];
const nav = MENU.map(([k, href, ic]) => `<a class="nav${o.nav === k ? ' on' : ''}" href="${href}"><span class="ic" aria-hidden="true">${ic}</span>${esc(t('nav.' + k))}</a>`).join('');
const crumbs = (o.crumbs || []).map((c, i, a) => (i === a.length - 1 || !c.href) ? `<span>${esc(c.label)}</span>` : `<a href="${c.href}">${esc(c.label)}</a>`).join(' <span aria-hidden="true">›</span> ');
const actions = (o.actions || []).map((a) => a.href ? `<a class="btn ${a.kind || ''}" href="${a.href}">${esc(a.label)}</a>` : `<button class="btn ${a.kind || ''}" data-action="${esc(a.action || '')}">${esc(a.label)}</button>`).join('');
const alerts = (o.alerts || []).map((a) => `<a class="alert ${a.severity || 'warning'}" href="${a.href || '#'}"><b>${a.severity === 'critical' ? '⚠ ' : a.severity === 'info' ? 'ⓘ ' : '! '}</b>${esc(a.text)}</a>`).join('');
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(o.title || '')} · ${esc(t('app.portal'))}</title><link rel="stylesheet" href="/ui/app.css"><meta name="csrf" content="${esc(o.csrf || '')}"></head>
<body class="${user ? 'in' : 'out'}">
<a class="skip" href="#main">본문으로</a>
<header class="top"><button class="menu-btn" aria-label="메뉴 열기" data-action="toggle-nav">☰</button><a class="brand" href="/">${esc(t('app.name'))}</a>
${user ? `<div class="group-switch"><label class="sr" for="gsel">사용자 그룹</label><select id="gsel" data-action="switch-group" aria-label="현재 사용자 그룹">${groups.map((g) => `<option value="${esc(g.id)}"${group && g.id === group.id ? ' selected' : ''}>${esc(g.name)}${g.kind === 'personal' ? ' (개인)' : ''}</option>`).join('')}</select></div>
<nav class="top-right"><a href="/notifications" class="bell" aria-label="알림 ${o.unread || 0}건">🔔${o.unread ? `<b class="badge">${o.unread}</b>` : ''}</a><a href="/help">${esc(t('nav.help'))}</a><details class="user"><summary>${esc(user.display_name || user.email)}</summary><div class="dd"><a href="/account">${esc(t('nav.account'))}</a><a href="/account/sessions">로그인 기기</a><form method="post" action="/logout"><input type="hidden" name="csrf" value="${esc(o.csrf || '')}"><button>${esc(t('nav.logout'))}</button></form></div></details></nav>` : `<nav class="top-right"><a href="/login">로그인</a><a href="/signup" class="btn primary">회원가입</a></nav>`}
</header>
<div class="shell">${user ? `<aside class="side" id="side"><nav aria-label="주 메뉴">${nav}</nav></aside>` : ''}
<main id="main" class="main"><div class="ph"><div class="crumbs" aria-label="현재 위치">${crumbs}</div><div class="ph-row"><h1>${esc(o.title || '')}</h1><div class="actions">${actions}</div></div></div>
<div class="alerts" id="alerts">${alerts}</div>
${o.body || ''}
</main></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="modal" class="modal" hidden></div>
<script src="/ui/app.js"></script>${(o.scripts || []).map((s) => `<script src="${esc(s)}"></script>`).join('')}
</body></html>`;
}
/** 상태 배지(글자+기호, 색은 보조) */
export function badge(kind, code, label) { const sym = { ok: '●', done: '✔', failed: '✘', warning: '!', info: 'ⓘ', running: '◌', waiting: '◔', paused: '⏸', neutral: '○' }[kind] || '○'; return `<span class="badge ${esc(kind)} c-${esc(code || '')}"><span aria-hidden="true">${sym}</span> ${esc(label)}</span>`; }
/** 공통 상태 패널(불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음) */
export function statePanel(state, text, { href, retry } = {}) { const labels = { loading: t('state.loading'), empty: t('state.empty'), stale: t('state.stale'), partial: t('state.partial'), failed: t('state.failed'), forbidden: t('state.forbidden') }; return `<div class="aw-state ${esc(state)}" role="status"><b>${esc(labels[state] || state)}</b>${text ? `<p>${esc(text)}</p>` : ''}${href ? `<a class="btn" href="${esc(href)}">이동</a>` : ''}${retry ? `<button class="btn" data-action="retry">${esc(t('common.retry'))}</button>` : ''}</div>`; }
/** 파괴적 작업 확인 상자 데이터(화면 스크립트 aw.confirmDanger 가 그린다): 대상·영향·입력 확인 */
export function dangerSpec({ title, target, impacts = [], typeToConfirm = null, confirmLabel = '실행' }) { return JSON.stringify({ title, target, impacts, typeToConfirm, confirmLabel }); }