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:
+36
-20
@@ -225,7 +225,13 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
function renderMetadata() {
|
function renderMetadata() {
|
||||||
const { story } = details;
|
const { story } = details;
|
||||||
initial = {};
|
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;
|
const edited = key in story.pinned;
|
||||||
initial[key] = editText(key, edited ? story.pinned[key] : siteValue(story, key));
|
initial[key] = editText(key, edited ? story.pinned[key] : siteValue(story, key));
|
||||||
const attrs = {
|
const attrs = {
|
||||||
@@ -235,51 +241,61 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
oninput: (e) => {
|
oninput: (e) => {
|
||||||
drafts[key] = e.target.value;
|
drafts[key] = e.target.value;
|
||||||
dirty = true;
|
dirty = true;
|
||||||
|
renderSaveState();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const input =
|
const input =
|
||||||
key === 'comments'
|
key === 'comments'
|
||||||
? h('textarea', { ...attrs, rows: 4 })
|
? h('textarea', { ...attrs, rows: 4 })
|
||||||
: h('input', { ...attrs, type: key === 'pubdate' ? 'date' : 'text' });
|
: 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 =
|
const note =
|
||||||
|
differs &&
|
||||||
|
h(
|
||||||
|
'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 &&
|
edited &&
|
||||||
!locked.has(key) &&
|
!locked.has(key) &&
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'field-note' },
|
{ class: 'controls' },
|
||||||
badge('edited', 'Edited', 'A change on the site replaces this value'),
|
|
||||||
button('Use the site value', async () => {
|
button('Use the site value', async () => {
|
||||||
await api(`stories/${id}/metadata`, 'PUT', { values: { [key]: null } });
|
await api(`stories/${id}/metadata`, 'PUT', { values: { [key]: null } });
|
||||||
delete drafts[key];
|
delete drafts[key];
|
||||||
toast(`${label} follows the site again`);
|
toast(`${label} follows the site again`);
|
||||||
await load();
|
await load();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return h(
|
return h(
|
||||||
'tr',
|
'div',
|
||||||
{},
|
{ class: 'field' },
|
||||||
h('th', { scope: 'row' }, label),
|
|
||||||
h(
|
h(
|
||||||
'td',
|
'div',
|
||||||
{},
|
{ class: 'field-head' },
|
||||||
|
h('span', { class: 'field-label' }, label),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'lock-toggle' },
|
||||||
checkbox(locked.has(key), (on) => {
|
checkbox(locked.has(key), (on) => {
|
||||||
if (on) locked.add(key);
|
if (on) locked.add(key);
|
||||||
else locked.delete(key);
|
else locked.delete(key);
|
||||||
dirty = true;
|
dirty = true;
|
||||||
renderMetadata();
|
renderMetadata();
|
||||||
}, { 'aria-label': `Lock ${label}` }),
|
}, { 'aria-label': `Lock ${label}` }),
|
||||||
|
locked.has(key) ? 'Locked' : 'Lock',
|
||||||
),
|
),
|
||||||
h('td', {}, input, note),
|
),
|
||||||
h('td', {}, formatField(key, siteValue(story, key))),
|
input,
|
||||||
|
note,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
fill(
|
const save = button('Save', async () => {
|
||||||
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] });
|
await api(`stories/${id}/settings`, 'PUT', { locked_fields: [...locked] });
|
||||||
const values = {};
|
const values = {};
|
||||||
for (const [key, text] of Object.entries(drafts)) {
|
for (const [key, text] of Object.entries(drafts)) {
|
||||||
@@ -297,9 +313,9 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
drafts = {};
|
drafts = {};
|
||||||
toast(message);
|
toast(message);
|
||||||
await load();
|
await load();
|
||||||
}, { class: 'primary' }),
|
}, { class: 'primary' });
|
||||||
),
|
fill(metadata, h('div', { class: 'field-grid' }, fieldCards), h('div', { class: 'controls' }, save, unsavedBox));
|
||||||
);
|
renderSaveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUpdates() {
|
function renderUpdates() {
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ button.danger {
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-note button {
|
.field-site .controls button {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -513,15 +513,90 @@ button.danger {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A line under a metadata field with an edited value */
|
/* One metadata field: a stacked card, so it stays readable on a narrow phone screen. */
|
||||||
.field-note {
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
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;
|
margin-top: 6px;
|
||||||
font-size: var(--text-sm);
|
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']),
|
input:not([type='checkbox'], [type='radio']),
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
@@ -554,6 +629,7 @@ textarea {
|
|||||||
|
|
||||||
.metadata input:not([type='checkbox']) {
|
.metadata input:not([type='checkbox']) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 32em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ini,
|
.ini,
|
||||||
|
|||||||
@@ -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 } });
|
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 () => {
|
test('failed rule saves do not queue a rebuild', async () => {
|
||||||
await mountStory();
|
await mountStory();
|
||||||
const card = section(main, 'Replacement rules');
|
const card = section(main, 'Replacement rules');
|
||||||
|
|||||||
Reference in New Issue
Block a user