Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
48 lines
5.8 KiB
JavaScript
48 lines
5.8 KiB
JavaScript
/**
|
||
* 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 }); }
|