feat(ui): ui-shell header with tool links, mobile side nav, Youreka logo

- header: Carbon ui-shell (cds-header) with the Youreka logo (from
  idid-enterprise-portal), Image Cropper / Fingerprint Agent nav items
  (aria-current on the active tool), the agent status tag on the wizard,
  and the operator chip + Sign out on desktop
- mobile: cds-side-nav (logo above the items, both tools, a divider, then
  Sign out) toggled by the ui-shell menu button; account actions move out
  of the header below 1056px, the nav is hidden at desktop widths
- the header is built once in server.js (appHeader) and injected into the
  landing, both tools and error pages — no per-page duplication
- removed my duplicate menu-button toggle: ui-shell coordinates the menu
  button and side nav itself (the duplicate cancelled it out)
- fixes: 'cds-header' was hidden by a selector-list typo
  (cds-header, cds-side-nav:not(:defined)); the collapsed side nav no
  longer captures taps

Tools banner logo asset added (public/assets/youreka-logo.png).
This commit is contained in:
2026-10-01 03:53:24 +01:00
parent 22735c0709
commit 340d8da19d
9 changed files with 115 additions and 111 deletions
+61 -25
View File
@@ -75,19 +75,7 @@ function errorPage(res, status, title, message, actions = {}) {
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="app">
<nav class="nav">
<div class="container">
<a class="brand" href="/">
<span class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a8 8 0 0 0-8 8c0 3.5 1 5.5 2 7"/><path d="M16 21a10 10 0 0 0 4-13 8 8 0 0 0-15.7-1"/><path d="M8 21a8 8 0 0 1-1-11 5 5 0 0 1 9.7 1.4A6 6 0 0 1 12 17"/><path d="M12 21a4 4 0 0 0 4-4 4 4 0 0 0-1.2-2.9"/></svg>
</span>
YOUreka <small>Tools</small>
</a>
<div class="nav-right">
<a class="linklike" href="/auth/logout">Sign out</a>
</div>
</div>
</nav>
${appHeader(null, null)}
<main class="container narrow center-v">
<div class="card message-card">
<div class="message-icon error">${ICONS[icon] || ICONS.alert}</div>
@@ -118,6 +106,50 @@ function esc(value) {
.replace(/'/g, '&#39;');
};
/**
* The application header and mobile side nav (Carbon ui-shell), shared by
* every page. `session` is present when the operator is signed in; `active`
* marks the current tool. Rendered server-side so pages need no client
* session fetch and hold a single header definition.
*/
const appHeader = (session, active) => {
const toolLink = (href, label, key) =>
`<cds-header-nav-item href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-header-nav-item>`;
const sideLink = (href, label, key) =>
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-side-nav-link>`;
const name = session ? (session.firstName || session.phone || '') : '';
const right = session
? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span>
<a class="linklike" href="/auth/logout">Sign out</a>`
: '<a class="linklike" href="/auth/login">Sign in</a>';
return `
<cds-header aria-label="YOUreka Tools">
<cds-header-menu-button button-label-active="Close menu" button-label-inactive="Open menu" id="header-menu-button"></cds-header-menu-button>
<cds-header-name href="/">
<span class="brand-logo"><img src="/assets/youreka-logo.png" alt="" width="20" height="20"></span>YOUreka<span class="brand-suffix"> Tools</span>
</cds-header-name>
<cds-header-nav>
${toolLink('/cropper', 'Image Cropper', 'cropper')}
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
</cds-header-nav>
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
<div class="header-right">${right}</div>
</cds-header>
<cds-side-nav aria-label="Tools" id="side-nav">
<div class="side-nav-brand">
<img src="/assets/youreka-logo.png" alt="YOUreka" width="40" height="40">
<span>YOUreka Tools</span>
</div>
<cds-side-nav-items>
${sideLink('/cropper', 'Image Cropper', 'cropper')}
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
<cds-side-nav-divider></cds-side-nav-divider>
<cds-side-nav-link href="/auth/logout">Sign out</cds-side-nav-link>
</cds-side-nav-items>
</cds-side-nav>
`;
};
// ── app factory ──────────────────────────────────────────────────────────────
export function createApp(overrides = {}) {
const cfg = {
@@ -300,7 +332,7 @@ export function createApp(overrides = {}) {
* client never has to fetch it (and never flashes a loading state).
* Per-user content — must not be cached.
*/
const renderPage = (res, file, session) => {
const renderPage = (res, file, session, active) => {
fs.readFile(path.join(__dirname, 'public', 'assets', file), 'utf8', (error, html) => {
if (error) return errorPage(res, 500, 'Something went wrong', 'The page could not be loaded.');
const data = session
@@ -310,21 +342,24 @@ export function createApp(overrides = {}) {
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.type('html')
.send(html.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`));
.send(
html
.replace('<!--app-header-->', appHeader(session, active))
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
);
});
};
app.get('/', (req, res, next) => {
fs.readFile(path.join(__dirname, 'public', 'index.html'), 'utf8', (error, html) => {
if (error) return next(error);
const nav = req.session
? `<span class="user-chip"><span class="avatar">${esc((req.session.firstName || req.session.phone || '?').trim().slice(0, 2).toUpperCase())}</span>${esc(req.session.firstName || req.session.phone || '')}</span><a class="linklike" href="/auth/logout">Sign out</a>`
: '<a class="linklike" href="/auth/login">Sign in</a>';
res
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.type('html')
.send(html.replace('<!--session-nav-->', nav));
.send(html.replace('<!--app-header-->', appHeader(req.session, null)));
});
});
@@ -363,7 +398,7 @@ export function createApp(overrides = {}) {
// ── wizard page (session required) ────────────────────────────────────────
app.get('/fingerprint', requirePage, async (req, res, next) => {
try {
renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }));
renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }), 'fingerprint');
} catch (e) {
next(e);
}
@@ -399,11 +434,12 @@ export function createApp(overrides = {}) {
next();
};
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
renderPage(res, 'cropper/index.html', {
phone: req.session.phone,
firstName: req.session.firstName,
lastName: req.session.lastName,
});
renderPage(
res,
'cropper/index.html',
{ phone: req.session.phone, firstName: req.session.firstName, lastName: req.session.lastName },
'cropper'
);
});
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
if (cropperClients) {