Add the web interface
Plain HTML, CSS and JavaScript modules, with no build step. Hash routes and relative URLs make it work under any path. The job list is polled: every 2 seconds while a job runs, and less often otherwise.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// Start-up, login, the header and the hash routes (#/story/3 and so on).
|
||||
|
||||
import { chapterPage } from './chapter.js';
|
||||
import { startPolling, stopPolling } from './jobs.js';
|
||||
import { api, fill, h, setStoryTitleHandler, setUnauthorizedHandler, showError } from './lib.js';
|
||||
import { settingsPage } from './settings.js';
|
||||
import { storiesPage } from './stories.js';
|
||||
import { storyPage } from './story.js';
|
||||
|
||||
const ROUTES = [
|
||||
[/^\/$/, storiesPage, []],
|
||||
[/^\/story\/(\d+)$/, storyPage, ['storyId']],
|
||||
[/^\/story\/(\d+)\/chapter\/(\d+)$/, chapterPage, ['storyId', 'chapterId']],
|
||||
[/^\/settings$/, settingsPage, []],
|
||||
];
|
||||
|
||||
const app = document.getElementById('bindery-app');
|
||||
let auth = null;
|
||||
let config = null;
|
||||
let cleanup = null;
|
||||
let loggedIn = false;
|
||||
let nav = null;
|
||||
let storyTab = null;
|
||||
let storyTabShown = false;
|
||||
|
||||
const currentPath = () => window.location.hash.replace(/^#/, '') || '/';
|
||||
// The story of #/story/3 and of its chapter pages, such as #/story/3/chapter/5.
|
||||
const currentStoryId = () => currentPath().match(/^\/story\/(\d+)(?:\/|$)/)?.[1];
|
||||
|
||||
// A story tab stays current on the chapter pages of its story.
|
||||
function markCurrent(a, path) {
|
||||
const target = a.getAttribute('href').replace(/^#/, '');
|
||||
if (path === target || (target !== '/' && path.startsWith(`${target}/`))) a.setAttribute('aria-current', 'page');
|
||||
else a.removeAttribute('aria-current');
|
||||
}
|
||||
|
||||
function hideStoryTab() {
|
||||
if (storyTabShown) storyTab.remove();
|
||||
storyTabShown = false;
|
||||
}
|
||||
|
||||
function notFound(main) {
|
||||
document.title = 'Not found · Bindery';
|
||||
fill(main, h('section', { class: 'card' }, h('h1', {}, 'Page not found'), h('p', {}, h('a', { href: '#/' }, 'Go to the story list'))));
|
||||
}
|
||||
|
||||
function route() {
|
||||
const main = document.getElementById('main');
|
||||
if (!main) return;
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
const path = currentPath();
|
||||
// The page of another story reports its own title.
|
||||
if (storyTab?.getAttribute('href') !== `#/story/${currentStoryId()}`) hideStoryTab();
|
||||
const found = ROUTES.find(([pattern]) => pattern.test(path));
|
||||
if (found) {
|
||||
const [pattern, page, names] = found;
|
||||
const match = path.match(pattern);
|
||||
cleanup = page(main, Object.fromEntries(names.map((name, i) => [name, match[i + 1]])), config) || null;
|
||||
} else {
|
||||
notFound(main);
|
||||
}
|
||||
for (const a of document.querySelectorAll('header nav a')) markCurrent(a, path);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api('auth/logout', 'POST');
|
||||
if (auth.logout_url) {
|
||||
window.location.href = auth.logout_url;
|
||||
return;
|
||||
}
|
||||
await start();
|
||||
}
|
||||
|
||||
function shell() {
|
||||
const user =
|
||||
auth.mode !== 'none' &&
|
||||
auth.user && [
|
||||
h('span', {}, auth.user),
|
||||
(auth.mode === 'cwa' || auth.logout_url) &&
|
||||
h('button', { type: 'button', onclick: () => logout().catch(showError) }, 'Log out'),
|
||||
];
|
||||
nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), h('a', { href: '#/settings' }, 'Settings'));
|
||||
storyTab = h('a', { class: 'story-tab' });
|
||||
storyTabShown = false;
|
||||
fill(
|
||||
app,
|
||||
h(
|
||||
'header',
|
||||
{},
|
||||
h('a', { href: '#/', class: 'brand' }, h('img', { src: 'static/favicon.svg', alt: '', width: 22, height: 22 }), 'Bindery'),
|
||||
h('div', { class: 'account' }, user),
|
||||
nav,
|
||||
),
|
||||
h('main', { id: 'main' }),
|
||||
);
|
||||
}
|
||||
|
||||
function loginPage() {
|
||||
document.title = 'Log in · Bindery';
|
||||
if (auth.mode === 'proxy') {
|
||||
fill(
|
||||
app,
|
||||
h(
|
||||
'main',
|
||||
{ class: 'login' },
|
||||
h('div', { class: 'card' }, h('h1', {}, 'Bindery'), h('p', {}, 'Your reverse proxy did not send a user name. Log in through the proxy.')),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const error = h('p', { class: 'error', role: 'alert' });
|
||||
const username = h('input', { name: 'username', autocomplete: 'username', required: true });
|
||||
const password = h('input', { name: 'password', type: 'password', autocomplete: 'current-password', required: true });
|
||||
const remember = h('input', { type: 'checkbox', checked: true });
|
||||
const submit = h('button', { type: 'submit', class: 'primary' }, 'Log in');
|
||||
const form = h(
|
||||
'form',
|
||||
{
|
||||
class: 'card',
|
||||
onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
submit.disabled = true;
|
||||
error.textContent = '';
|
||||
try {
|
||||
await api('auth/login', 'POST', { username: username.value, password: password.value, remember: remember.checked });
|
||||
await start();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
h('h1', { class: 'brand' }, h('img', { src: 'static/favicon.svg', alt: '', width: 28, height: 28 }), 'Bindery'),
|
||||
h('p', { class: 'hint' }, 'Log in with your CWA account.'),
|
||||
h('label', {}, 'User name', username),
|
||||
h('label', {}, 'Password', password),
|
||||
h('label', {}, remember, ' Keep me logged in'),
|
||||
error,
|
||||
submit,
|
||||
);
|
||||
fill(app, h('main', { class: 'login' }, form));
|
||||
username.focus();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
auth = await api('auth/check');
|
||||
if (!auth.authenticated) {
|
||||
loggedIn = false;
|
||||
stopPolling();
|
||||
loginPage();
|
||||
return;
|
||||
}
|
||||
config = await api('config');
|
||||
loggedIn = true;
|
||||
shell();
|
||||
route();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
setStoryTitleHandler((storyId, title) => {
|
||||
if (!storyTab || !title || storyId !== currentStoryId()) return;
|
||||
fill(storyTab, title);
|
||||
storyTab.setAttribute('title', title);
|
||||
storyTab.setAttribute('href', `#/story/${storyId}`);
|
||||
markCurrent(storyTab, currentPath());
|
||||
if (!storyTabShown) nav.append(storyTab);
|
||||
storyTabShown = true;
|
||||
});
|
||||
// A 401 means the session ended. Show the login page once, not on each failed poll.
|
||||
// A reverse proxy often needs a full page load to step in again, and its own login page has no
|
||||
// form to retry from, so reload the page instead of re-checking auth inside the app.
|
||||
setUnauthorizedHandler(() => {
|
||||
if (!loggedIn) return;
|
||||
if (auth?.mode === 'proxy') window.location.reload();
|
||||
else start().catch(showError);
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (loggedIn) route();
|
||||
});
|
||||
start().catch((e) => {
|
||||
fill(app, h('main', { class: 'login' }, h('div', { class: 'card' }, h('h1', {}, 'Bindery'), h('p', { class: 'error' }, e.message))));
|
||||
});
|
||||
Reference in New Issue
Block a user