From 5ef68fb240b3b17f7728ee10a1168ed05144f56f Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sun, 27 Sep 2026 06:36:53 +0200 Subject: [PATCH] Make the metadata fields readable on a phone Each field was a row of a four-column table: name, lock, value and the site's current value. On a narrow screen the columns squeezed the inputs down to a few characters wide. Each field is now a card with its name and lock on one line and the input below at full width. The site's value shows only when it differs, folded behind a line that opens on tap, instead of always taking its own column. The lock is a labelled toggle instead of a bare checkbox, and Save stays disabled until something changes. On a wide screen, the label sits beside the field instead of above it, and a single-line input no longer stretches to fill the card. --- bindery/static/js/story.js | 120 +++++++++++++++++++++---------------- bindery/static/style.css | 82 ++++++++++++++++++++++++- tests/js/flows.test.mjs | 20 +++++++ 3 files changed, 167 insertions(+), 55 deletions(-) diff --git a/bindery/static/js/story.js b/bindery/static/js/story.js index 50a1732..1c4b82e 100644 --- a/bindery/static/js/story.js +++ b/bindery/static/js/story.js @@ -225,7 +225,13 @@ export function storyPage(main, { storyId }, config) { function renderMetadata() { const { story } = details; initial = {}; - const rows = Object.entries(fields).map(([key, label]) => { + const unsaved = h('span', { class: 'hint' }, 'Unsaved changes'); + const unsavedBox = h('span', {}); + const renderSaveState = () => { + save.disabled = !dirty; + fill(unsavedBox, dirty && unsaved); + }; + const fieldCards = Object.entries(fields).map(([key, label]) => { const edited = key in story.pinned; initial[key] = editText(key, edited ? story.pinned[key] : siteValue(story, key)); const attrs = { @@ -235,71 +241,81 @@ export function storyPage(main, { storyId }, config) { oninput: (e) => { drafts[key] = e.target.value; dirty = true; + renderSaveState(); }, }; const input = key === 'comments' ? h('textarea', { ...attrs, rows: 4 }) : h('input', { ...attrs, type: key === 'pubdate' ? 'date' : 'text' }); + // The current value, edited or from the site. A note appears only when it differs from the site. + const currentText = edited ? formatField(key, story.pinned[key]) : formatField(key, siteValue(story, key)); + const siteText = formatField(key, siteValue(story, key)); + const differs = currentText !== siteText; const note = - edited && - !locked.has(key) && + differs && h( - 'div', - { class: 'field-note' }, - badge('edited', 'Edited', 'A change on the site replaces this value'), - button('Use the site value', async () => { - await api(`stories/${id}/metadata`, 'PUT', { values: { [key]: null } }); - delete drafts[key]; - toast(`${label} follows the site again`); - await load(); - }), + 'details', + { class: 'field-site' }, + h('summary', {}, edited && !locked.has(key) ? [badge('edited', 'Edited'), ' Site value differs'] : 'Site value differs'), + h('p', {}, siteText || h('em', {}, 'Not set on the site')), + edited && + !locked.has(key) && + h( + 'div', + { class: 'controls' }, + button('Use the site value', async () => { + await api(`stories/${id}/metadata`, 'PUT', { values: { [key]: null } }); + delete drafts[key]; + toast(`${label} follows the site again`); + await load(); + }), + ), ); return h( - 'tr', - {}, - h('th', { scope: 'row' }, label), + 'div', + { class: 'field' }, h( - 'td', - {}, - checkbox(locked.has(key), (on) => { - if (on) locked.add(key); - else locked.delete(key); - dirty = true; - renderMetadata(); - }, { 'aria-label': `Lock ${label}` }), + 'div', + { class: 'field-head' }, + h('span', { class: 'field-label' }, label), + h( + 'label', + { class: 'lock-toggle' }, + checkbox(locked.has(key), (on) => { + if (on) locked.add(key); + else locked.delete(key); + dirty = true; + renderMetadata(); + }, { 'aria-label': `Lock ${label}` }), + locked.has(key) ? 'Locked' : 'Lock', + ), ), - h('td', {}, input, note), - h('td', {}, formatField(key, siteValue(story, key))), + input, + note, ); }); - fill( - metadata, - table(['Field', 'Locked', 'Value', 'Latest from site'], rows), - h( - 'div', - { class: 'controls' }, - button('Save', async () => { - await api(`stories/${id}/settings`, 'PUT', { locked_fields: [...locked] }); - const values = {}; - for (const [key, text] of Object.entries(drafts)) { - if (text !== initial[key]) values[key] = key === 'comments' ? textToHtml(text) : text; - } - let message = 'Saved'; - if (Object.keys(values).length) { - const result = await api(`stories/${id}/metadata`, 'PUT', { values }); - if (result.job) { - message = 'Saved. Bindery rebuilds the book with the new metadata.'; - refreshJobs(); - } - } - dirty = false; - drafts = {}; - toast(message); - await load(); - }, { class: 'primary' }), - ), - ); + const save = button('Save', async () => { + await api(`stories/${id}/settings`, 'PUT', { locked_fields: [...locked] }); + const values = {}; + for (const [key, text] of Object.entries(drafts)) { + if (text !== initial[key]) values[key] = key === 'comments' ? textToHtml(text) : text; + } + let message = 'Saved'; + if (Object.keys(values).length) { + const result = await api(`stories/${id}/metadata`, 'PUT', { values }); + if (result.job) { + message = 'Saved. Bindery rebuilds the book with the new metadata.'; + refreshJobs(); + } + } + dirty = false; + drafts = {}; + toast(message); + await load(); + }, { class: 'primary' }); + fill(metadata, h('div', { class: 'field-grid' }, fieldCards), h('div', { class: 'controls' }, save, unsavedBox)); + renderSaveState(); } function renderUpdates() { diff --git a/bindery/static/style.css b/bindery/static/style.css index 6c55c8e..ee071ce 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -505,7 +505,7 @@ button.danger { font-size: var(--text-sm); } -.field-note button { +.field-site .controls button { padding: 0; border: 0; background: none; @@ -513,15 +513,90 @@ button.danger { text-decoration: underline; } -/* A line under a metadata field with an edited value */ -.field-note { +/* One metadata field: a stacked card, so it stays readable on a narrow phone screen. */ +.field-grid { + display: flex; + flex-direction: column; + gap: 14px; +} + +.field-grid > .field { + padding-bottom: 14px; + border-bottom: 1px solid var(--border); +} + +.field-grid > .field:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.field-head { display: flex; align-items: center; + justify-content: space-between; gap: 10px; + margin-bottom: 4px; +} + +/* On a wide screen, the label sits beside the field instead of above it, so a short value such + as a title does not stretch across the whole card. */ +@media (min-width: 700px) { + .field { + display: grid; + grid-template-columns: 11em minmax(0, 1fr); + column-gap: 16px; + } + + .field-head { + flex-direction: column; + align-items: flex-start; + gap: 6px; + margin-bottom: 0; + } + + .field > :not(.field-head) { + grid-column: 2; + } +} + +.field-label { + font-weight: 600; +} + +.lock-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: var(--text-sm); + color: var(--muted); + cursor: pointer; +} + +.lock-toggle:has(input:checked) { + border-color: var(--info); + background: var(--info-bg); + color: var(--info); +} + +/* The site's current value, folded away so a long description does not push other fields down. */ +.field-site { margin-top: 6px; font-size: var(--text-sm); } +.field-site summary { + cursor: pointer; + color: var(--muted); +} + +.field-site p { + margin: 6px 0 0; + color: var(--text); +} + input:not([type='checkbox'], [type='radio']), select, textarea { @@ -554,6 +629,7 @@ textarea { .metadata input:not([type='checkbox']) { width: 100%; + max-width: 32em; } .ini, diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index c552ffb..3e4de20 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -321,6 +321,26 @@ test('metadata saves skip unchanged drafts, allow edits without a rebuild, and r assert.deepEqual(api.sent('PUT', 'stories/1/metadata').at(-1).body, { values: { title: null } }); }); +test('the metadata card shows the site value only when it differs, and drops the button once locked', async () => { + await mountStory(); + const card = section(main, 'Metadata'); + assert.match(card.textContent, /Site value differs/); + assert.equal(nodes(card).filter((el) => el.tagName === 'summary').length, 1); + assert.ok(find(card, 'button', 'Use the site value')); + change(field(main, 'Lock Title'), true); + assert.match(card.textContent, /Site value differs/); + assert.equal(find(card, 'button', 'Use the site value'), undefined); +}); + +test('the Save button of the metadata card is disabled until something changes', async () => { + await mountStory(); + const save = find(section(main, 'Metadata'), 'button', 'Save'); + assert.equal(save.disabled, true); + input(field(main, 'Authors'), 'Someone else'); + assert.equal(save.disabled, false); + assert.match(section(main, 'Metadata').textContent, /Unsaved changes/); +}); + test('failed rule saves do not queue a rebuild', async () => { await mountStory(); const card = section(main, 'Replacement rules');