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:
+68
-52
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user