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.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 1a10fe53f3
commit 5ef68fb240
3 changed files with 167 additions and 55 deletions
+68 -52
View File
@@ -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() {
+79 -3
View File
@@ -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,