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,268 @@
|
||||
// The story list: add stories, filter them, see all jobs, and import epub files and library books.
|
||||
|
||||
import { jobPanel, refreshJobs, watchJobs } from './jobs.js';
|
||||
import { api, badge, button, checkbox, fill, fmtDate, h, link, showError, toast } from './lib.js';
|
||||
|
||||
function libraryImport(config) {
|
||||
if (!config.library_available) {
|
||||
return h('p', { class: 'hint' }, 'Set CWA_URL to import books from the library.');
|
||||
}
|
||||
let candidates = [];
|
||||
let selected = new Set();
|
||||
let check = true;
|
||||
const results = h('div');
|
||||
|
||||
const render = () => {
|
||||
if (!candidates.length) {
|
||||
fill(results, h('p', { class: 'empty' }, 'Every library book from a story site is tracked already.'));
|
||||
return;
|
||||
}
|
||||
const toggle = (id, on) => {
|
||||
if (on) selected.add(id);
|
||||
else selected.delete(id);
|
||||
};
|
||||
fill(
|
||||
results,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'table-wrap' },
|
||||
h(
|
||||
'table',
|
||||
{},
|
||||
h(
|
||||
'thead',
|
||||
{},
|
||||
h(
|
||||
'tr',
|
||||
{},
|
||||
h(
|
||||
'th',
|
||||
{},
|
||||
checkbox(selected.size === candidates.length, (on) => {
|
||||
selected = new Set(on ? candidates.map((c) => c.calibre_id) : []);
|
||||
render();
|
||||
}, { 'aria-label': 'Select all' }),
|
||||
),
|
||||
h('th', {}, 'Title'),
|
||||
h('th', {}, 'Author'),
|
||||
h('th', {}, 'Site'),
|
||||
),
|
||||
),
|
||||
h(
|
||||
'tbody',
|
||||
{},
|
||||
candidates.map((c) =>
|
||||
h(
|
||||
'tr',
|
||||
{},
|
||||
h('td', {}, checkbox(selected.has(c.calibre_id), (on) => toggle(c.calibre_id, on), { 'aria-label': c.title })),
|
||||
h('td', {}, c.title),
|
||||
h('td', {}, c.authors),
|
||||
h('td', {}, c.site),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
button(
|
||||
'Import selected',
|
||||
async () => {
|
||||
const books = candidates
|
||||
.filter((c) => selected.has(c.calibre_id))
|
||||
.map((c) => ({ id: c.calibre_id, uuid: c.uuid, title: c.title }));
|
||||
if (!books.length) return;
|
||||
const jobs = await api('library/import', 'POST', { books, check });
|
||||
toast(`Queued ${jobs.length} imports`);
|
||||
fill(results);
|
||||
refreshJobs();
|
||||
},
|
||||
{ class: 'primary' },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h(
|
||||
'p',
|
||||
{ class: 'hint' },
|
||||
'Finds library books whose publisher is a story site, for example books from the FanFicFare calibre plugin. ' +
|
||||
'Importing downloads each ePub from CWA and keeps its chapters, including chapters the site no longer has.',
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'controls' },
|
||||
button('Find stories in the library', async () => {
|
||||
candidates = await api('library/candidates');
|
||||
selected = new Set(candidates.map((c) => c.calibre_id));
|
||||
render();
|
||||
}),
|
||||
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
|
||||
),
|
||||
results,
|
||||
);
|
||||
}
|
||||
|
||||
// Upload a FanFicFare ePub. Bindery stores its chapters as if it had downloaded
|
||||
// them, so chapters that the site stubbed or removed stay in the book.
|
||||
export function epubImport(storyId) {
|
||||
let check = true;
|
||||
const file = h('input', { type: 'file', accept: '.epub,application/epub+zip', 'aria-label': 'Epub file' });
|
||||
const upload = button(
|
||||
'Import',
|
||||
async () => {
|
||||
const chosen = file.files[0];
|
||||
if (!chosen) {
|
||||
toast('Choose an ePub file', true);
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('file', chosen);
|
||||
form.append('check', String(check));
|
||||
await api(storyId ? `stories/${storyId}/import-epub` : 'stories/import-epub', 'POST', form);
|
||||
toast(`Queued the import of ${chosen.name}`);
|
||||
file.value = '';
|
||||
refreshJobs();
|
||||
},
|
||||
{ class: 'primary' },
|
||||
);
|
||||
const hint = storyId
|
||||
? 'Adds the chapters of an ePub of this story that Bindery does not have, in the order of the ePub. ' +
|
||||
'Chapters that Bindery has already keep their stored text.'
|
||||
: 'Starts tracking the story of an ePub made by FanFicFare, with all of its chapters. ' +
|
||||
'If Bindery tracks the story already, it adds the chapters that it does not have.';
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h('p', { class: 'hint' }, hint, ' The next update adds the chapters that the site published after the ePub.'),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'controls' },
|
||||
file,
|
||||
upload,
|
||||
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function storyRow(s) {
|
||||
return h(
|
||||
'tr',
|
||||
{},
|
||||
h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]),
|
||||
h('td', {}, s.author),
|
||||
h('td', { class: 'story-site' }, s.site),
|
||||
h(
|
||||
'td',
|
||||
{},
|
||||
String(s.chapter_count),
|
||||
s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')],
|
||||
),
|
||||
h(
|
||||
'td',
|
||||
{},
|
||||
s.status && badge(s.status),
|
||||
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
|
||||
' ',
|
||||
h('small', {}, s.message),
|
||||
),
|
||||
h('td', { class: 'date' }, fmtDate(s.last_checked)),
|
||||
);
|
||||
}
|
||||
|
||||
export function storiesPage(main, _params, config) {
|
||||
document.title = 'Stories · Bindery';
|
||||
let stories = null;
|
||||
let filter = '';
|
||||
const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…'));
|
||||
const jobs = jobPanel();
|
||||
|
||||
const render = () => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
const shown = q
|
||||
? stories.filter((s) => [s.title, s.author, s.site, s.url].some((v) => v?.toLowerCase().includes(q)))
|
||||
: stories;
|
||||
if (!shown.length) {
|
||||
const text = stories.length ? 'No stories match.' : 'No stories yet. Add a URL above, or import an ePub.';
|
||||
fill(table, h('p', { class: 'empty' }, text));
|
||||
return;
|
||||
}
|
||||
const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last check'].map((t) => h('th', {}, t));
|
||||
fill(table, h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow)))));
|
||||
};
|
||||
|
||||
const load = () =>
|
||||
api('stories')
|
||||
.then((list) => {
|
||||
stories = list;
|
||||
render();
|
||||
})
|
||||
.catch(showError);
|
||||
|
||||
const urls = h('textarea', {
|
||||
class: 'story-urls',
|
||||
rows: 3,
|
||||
placeholder: 'https://www.royalroad.com/fiction/… (one URL per line)',
|
||||
'aria-label': 'Story URLs',
|
||||
});
|
||||
const add = button(
|
||||
'Add',
|
||||
async () => {
|
||||
if (!urls.value.trim()) return;
|
||||
const result = await api('stories', 'POST', { urls: urls.value });
|
||||
if (result.errors.length) toast(result.errors.join('\n'), true);
|
||||
if (result.jobs.length) {
|
||||
toast(`Queued ${result.jobs.length} ${result.jobs.length === 1 ? 'story' : 'stories'}`);
|
||||
urls.value = '';
|
||||
refreshJobs();
|
||||
}
|
||||
},
|
||||
{ class: 'primary' },
|
||||
);
|
||||
|
||||
fill(
|
||||
main,
|
||||
h(
|
||||
'section',
|
||||
{ class: 'card' },
|
||||
h('h2', {}, 'Add stories'),
|
||||
urls,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'controls' },
|
||||
add,
|
||||
h(
|
||||
'span',
|
||||
{ class: 'hint' },
|
||||
'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.',
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
'section',
|
||||
{ class: 'card' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'section-heading' },
|
||||
h('h2', {}, 'Stories'),
|
||||
h('input', {
|
||||
type: 'search',
|
||||
placeholder: 'Filter',
|
||||
'aria-label': 'Filter stories',
|
||||
oninput: (e) => {
|
||||
filter = e.target.value;
|
||||
if (stories) render();
|
||||
},
|
||||
}),
|
||||
),
|
||||
table,
|
||||
),
|
||||
h('section', { class: 'card' }, h('h2', {}, 'Jobs'), jobs.el),
|
||||
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import an epub file')), epubImport()),
|
||||
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)),
|
||||
);
|
||||
|
||||
load();
|
||||
return watchJobs({ render: jobs.render, onFinished: load });
|
||||
}
|
||||
Reference in New Issue
Block a user