e48cdb4fc5e59e17e5179b35a0339f357ba657be
4 * A bunch of shorthand to put together common parts of an HTML page.
7 const { lazy
} = require('@squeep/lazy-property');
11 * Set up expected fields for how we handle error reporting
15 const initContext
= (ctx
) => {
17 ctx
.notifications
= [];
21 * Some fields may have values outside normal dates, handle them here.
23 * @param {String} otherwise
25 const dateOrNot
= (date
, otherwise
) => {
29 if (typeof date
=== 'number') {
30 date
= new Date(date
);
32 const dateMs
= date
.getTime();
33 if (!Number
.isFinite(dateMs
)
37 return date
.toString();
42 * Why is rendering a Date as a string this complicated?
43 * We handle the infinities because pg-promise might provide those in
44 * lieu of a Date object from timestamp fields.
45 * @param {Date|Number|String} date
46 * @param {String=} pInf
47 * @param {String=} nInf
48 * @param {String=} otherwise
50 const dateFormat
= (date
, pInf
= 'Never', nInf
= 'Forever', otherwise
= '') => {
51 const isDatableType
= ['number', 'string'].includes(typeof date
);
59 || Number
.isNaN(date
.valueOf())
60 || (!(date
instanceof Date
) && !isDatableType
)) {
65 date
= new Date(date
);
67 const parts
= dateFormat
._dtf
.formatToParts(date
);
68 return parts
.map((p
) => p
.value
).join('');
70 lazy(dateFormat
, '_dtf', () => {
71 const dateTimeFormatOptions
= {
75 return new Intl
.DateTimeFormat(undefined, dateTimeFormatOptions
);
80 * Wrap a Date in a <time> block.
82 * @param {Object} options
83 * @param {String=} options.title
85 const timeElement
= (date
, options
= {}) => {
93 ...(title
&& { title
}),
94 ...(date
instanceof Date
&& { datetime: date
.toISOString() }),
98 elementAttributes(attributes
),
100 dateFormat(date
, pInf
, nInf
, otherwise
),
108 * @param {Number} seconds
111 const secondsToPeriod
= (seconds
) => {
115 const nextResult
= (factor
, label
) => {
116 const r
= factor
? value
% factor : value
;
118 result
.push(`${r} ${label}${r != 1 ? 's' : ''}`);
120 value
= factor
? Math
.floor(value
/ factor
) : value
;
123 nextResult(60, 'second');
124 nextResult(60, 'minute');
125 nextResult(24, 'hour');
126 nextResult(30, 'day');
127 nextResult(undefined, 'month');
130 return result
.join(' ');
135 * Return array of strings prefixed with tabs.
136 * @param {Number} indent
137 * @param {String[]} list
139 const indented
= (indent
, list
) => {
140 const spacer
= '\t'.repeat(indent
);
141 return list
.map((l
) => `${spacer}${l}`);
146 * Render the preamble <head> for an HTML page.
147 * @param {Number} pagePathLevel number of paths below root this page is
148 * @param {Object} ctx
149 * @param {Object} options
150 * @param {String[]=} options.headElements
151 * @param {String=} options.pageTitle
154 function htmlHead(pagePathLevel
, ctx
, options
) {
155 const rootPathPfx
= '../'.repeat(pagePathLevel
);
161 \t\t<meta charset="utf-8">
162 \t\t<meta name="viewport" content="width=device-width,initial-scale=1">
163 \t\t<link rel="stylesheet" href="${rootPathPfx}static/theme.css">
164 \t\t<link rel="stylesheet" href="${rootPathPfx}static/custom.css">
165 ${headElements.map((e) => '\t\t' + e).join('\n')}
166 \t\t<title>${pageTitle}</title>
172 * Render the main content of an HTML page.
173 * @param {Number} pagePathLevel
174 * @param {Object} ctx
175 * @param {Object} options
176 * @param {Object} options.bodyAttributes
177 * @param {String[]} main
180 function htmlBody(pagePathLevel
, ctx
, options
, main
= []) {
184 const firefoxFix
= '\n<script>0</script>'; // This fixes a layout rendering flash on load in Firefox; do not know why this works, but it does.
186 \t<body${elementAttributes(bodyAttributes)}>${firefoxFix}
187 ${htmlHeader(pagePathLevel, ctx, options)}
188 ${htmlMessages(ctx, options)}
192 ${htmlFooter(ctx, options)}
198 * Render a navigation link for the header section.
199 * @param {Object} nav
200 * @param {String} nav.href
201 * @param {String} nav.class
202 * @param {String} nav.text
205 function renderNavLink(nav
) {
206 return `<a href="${nav.href}"${nav.class ? (' class="' + nav.class + '"') : ''}>${nav.text}</a>`;
211 * Render the navigation section of the page header.
212 * @param {Object} ctx
213 * @param {Object} options
214 * @param {Object[]=} options.navLinks
217 function htmlNav(ctx
, options
) {
219 const spacer
= '\t'.repeat(indent
);
223 return navLinks
.length
? `${spacer}<nav>
224 ${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
225 ${spacer}</nav>` : '';
230 * Render the banner and navigation header.
231 * @param {Number} pagePathLevel
232 * @param {Object} ctx
233 * @param {Object} options
234 * @param {String[]=} options.logoUrl
235 * @param {String[]=} options.logoAlt
236 * @param {String[]=} options.pageTitle
239 function htmlHeader(pagePathLevel
, ctx
, options
) {
240 const rootPathPfx
= '../'.repeat(pagePathLevel
);
246 const logoImg
= logoUrl
? `<img src="${rootPathPfx}${logoUrl}" alt="logo" class="${logoAlt}">` : '';
248 \t\t\t<h1>${logoImg}${pageTitle}</h1>
249 ${htmlNav(ctx, options)}
255 * Render the bottom boilerplate.
256 * @param {Object} ctx
257 * @param {Object} options
258 * @param {String[]} options.footerEntries
261 function htmlFooter(ctx
, options
) {
263 const spacer
= '\t'.repeat(indent
);
268 return footerEntries
.length
? `${spacer}<footer>
269 ${OL(footerEntries, indent + 1)}
270 ${spacer}</footer>` : '';
275 * Convert an object into element attributes.
276 * @param {Object} attributes
279 function elementAttributes(attributes
) {
280 const attr
= Object
.entries(attributes
).map(([name
, value
]) => `${name}="${value}"`).join(' ');
281 return attr
? ' ' + attr : '';
286 * Wrap an item in a list item element.
287 * @param {String} item
288 * @param {Number} indent
289 * @param {Object} attributes
292 function LI(item
, indent
= 0, attributes
= {}) {
293 const spacer
= '\t'.repeat(indent
);
294 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
299 * Wrap a list of items in an unordered list.
300 * @param {String[]} items
301 * @param {Number} indent
302 * @param {Object} attributes
303 * @param {(item) => Object} itemAttributeGenerator
306 function UL(items
, indent
= 0, attributes
= {}, itemAttributeGenerator
= () => {}) {
307 const spacer
= '\t'.repeat(indent
);
308 return `${spacer}<ul${elementAttributes(attributes)}>
309 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
315 * Wrap a list of items in an ordered list.
316 * @param {String[]} items
317 * @param {Number} indent
318 * @param {Object} attributes
319 * @param {(item) => Object} itemAttributeGenerator
323 function OL(items
, indent
= 0, attributes
= {}, itemAttributeGenerator
= () => {}) {
324 const spacer
= '\t'.repeat(indent
);
325 return `${spacer}<ol${elementAttributes(attributes)}>
326 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
332 * Show any error or notice messages from context.
333 * @param {Object} ctx
334 * @param {String[]=} ctx.errors
335 * @param {String[]=} ctx.notifications
336 * @param {Object} options
337 * @param {String=} options.errorHeading
338 * @param {String[]=} options.errorContent
339 * @param {String=} options.notificationHeading
340 * @param {String[]=} options.notificationContent
342 function htmlMessages(ctx
, options
) {
343 const errorHeading
= options
?.errorHeading
? `
344 \t<h2>${options.errorHeading}</h2>` : '';
345 const errorContent
= options
?.errorContent
?.length
? '\n' + options
.errorContent
.map(((content
) => `\t${content}`)).join('\n') : '';
346 const notificationHeading
= options
?.notificationHeading
? `\n\t<h2>${options.notificationHeading}</h2>` : '';
347 const notificationContent
= options
?.notificationContent
?.length
? '\n' + options
.notificationContent
.map(((content
) => `\t${content}`)).join('\n') : '';
348 const errors
= ctx
?.errors
?.length
? `
349 <section class="error">${errorHeading}${errorContent}
352 const notifications
= ctx
?.notifications
?.length
? `
353 <section class="notification">${notificationHeading}${notificationContent}
354 ${UL(ctx.notifications, 1)}
356 return [errors
, notifications
].join('\n');
361 * Render all parts of an HTML page. Adds user logout nav link automatically.
362 * @param {Number} pagePathLevel - relative path-distance to base
363 * @param {Object} ctx
364 * @param {Object=} ctx.session
365 * @param {String=} ctx.session.authenticatedIdentifier
366 * @param {String=} ctx.session.authenticatedProfile
367 * @param {String[]=} ctx.errors
368 * @param {String[]=} ctx.notifications
369 * @param {Object} options
370 * @param {String=} options.pageTitle
371 * @param {String=} options.logoUrl
372 * @param {String=} options.logoAlt
373 * @param {Object[]=} options.bodyAttributes
374 * @param {String[]=} options.headElements
375 * @param {Object[]=} options.navLinks
376 * @param {String[]=} options.footerEntries
377 * @param {String=} options.errorHeading
378 * @param {String[]=} options.errorContent
379 * @param {String=} options.notificationHeading
380 * @param {String[]=} options.notificationContent
381 * @param {String[]} main - contents
384 function htmlPage(pagePathLevel
, ctx
, options
, main
= []) {
385 const user
= ctx
?.session
?.authenticatedProfile
|| ctx
?.session
?.authenticatedIdentifier
;
387 if (!options
.navLinks
) {
388 options
.navLinks
= [];
390 const logoutRedirect
= ctx
?.url
? `?r=${encodeURIComponent(ctx.url)}` : '';
391 const adminPath
= (pagePathLevel
> 0) ? `${'../'.repeat(pagePathLevel - 1)}` : 'admin/';
392 options
.navLinks
.push({
394 href: `${adminPath}settings`,
396 text: `Logout (${user})`,
397 href: `${adminPath}logout${logoutRedirect}`,
404 htmlHead(pagePathLevel
, ctx
, options
),
405 htmlBody(pagePathLevel
, ctx
, options
, main
),