Make subreddit list editable from the dashboard

Adds an inline subreddit editor on the dashboard that lets you add,
remove, and edit (tone, type, weight) the subreddit list, with changes
persisted to config.yaml on disk so they survive restarts.

Backend
- src/config/persistence.ts: round-trips config.yaml via
  YAML.parseDocument, mutating existing row nodes in place so their
  trailing inline `# ...` comments survive an edit; new rows are
  appended as flow-style maps to match the on-disk idiom; removed rows
  are dropped. Default values ('default' tone, 'image' type) are
  omitted on write so the file stays clean. Atomic via tmp+rename.
- src/web/routes/config.ts: GET /api/config now also returns the
  current subreddit list and the available tone keys. PUT /api/config
  accepts a `subreddits` field, validates with the existing
  SubredditConfigSchema, rejects duplicates, persists, then mutates
  ctx.config.reddit.subreddits in place so the processor picks up the
  new tone immediately without a restart.

Frontend
- public/index.html: replaces the read-only subreddit chip list in the
  Configuration snapshot section with a grid of editable rows (name,
  type, tone dropdown, weight, remove). + Add / Save buttons with
  inline status. Tones are sourced from /api/config so the catalog
  drives the dropdown.

Tests
- tests/config-persistence.test.ts: covers in-place mutation comment
  preservation, removal, appends, reorder, default omission, and
  atomic write cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 22:43:05 +01:00
1 parent 46a213e3e0
commit d9b9a79bdd
4 files changed
+476 -5

No files matched your search

+241 -4
View File
@@ -942,6 +942,109 @@
border-color: rgba(120, 150, 110, .25);
}
.subreddit-editor {
display: flex;
flex-direction: column;
gap: 6px;
}
.subreddit-editor-row {
display: grid;
grid-template-columns: minmax(140px, 1.5fr) 90px 1fr 70px 32px;
gap: 8px;
align-items: center;
padding: 6px 8px;
background: var(--card);
border: 1px solid var(--rule);
border-radius: var(--r-sm);
}
.subreddit-editor-row.new {
border-style: dashed;
border-color: var(--terracotta-soft);
}
.subreddit-editor-row input,
.subreddit-editor-row select {
width: 100%;
padding: 5px 8px;
font-size: 12px;
background: var(--bg);
color: var(--ink);
border: 1px solid var(--rule);
border-radius: var(--r-sm);
font-family: inherit;
}
.subreddit-editor-row input[readonly] {
background: transparent;
border-color: transparent;
font-weight: 600;
color: var(--ink);
padding-left: 4px;
}
.subreddit-editor-row .remove-btn {
background: transparent;
color: var(--ink-faint);
border: 1px solid var(--rule);
border-radius: var(--r-sm);
cursor: pointer;
padding: 4px;
font-size: 14px;
line-height: 1;
}
.subreddit-editor-row .remove-btn:hover {
background: var(--terracotta-soft);
color: var(--terracotta-deep);
border-color: rgba(201, 123, 94, .3);
}
.subreddit-editor-actions {
display: flex;
gap: 10px;
margin-top: 12px;
align-items: center;
}
.subreddit-editor-actions button {
padding: 7px 14px;
font-size: 12px;
font-weight: 500;
border-radius: var(--r-sm);
cursor: pointer;
border: 1px solid var(--rule);
background: var(--card);
color: var(--ink);
font-family: inherit;
}
.subreddit-editor-actions button.primary {
background: var(--terracotta-deep);
color: white;
border-color: var(--terracotta-deep);
}
.subreddit-editor-actions button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.subreddit-editor-status {
font-size: 12px;
color: var(--ink-faint);
margin-left: auto;
}
.subreddit-editor-status.error {
color: var(--terracotta-deep);
}
.subreddit-editor-status.success {
color: var(--sage-deep);
}
.empty-state {
padding: 48px 22px;
text-align: center;
@@ -2418,9 +2521,14 @@
</div>
<div class="config-section">
<div class="config-grid">
<div class="config-item">
<div class="config-item" style="grid-column: 1 / -1;">
<h4>Monitoring subreddits</h4>
<div class="value" id="config-subreddits"></div>
<div class="subreddit-editor" id="subreddit-editor"></div>
<div class="subreddit-editor-actions">
<button type="button" onclick="addSubredditRow()">+ Add subreddit</button>
<button type="button" class="primary" id="save-subreddits-btn" onclick="saveSubreddits()">Save changes</button>
<span class="subreddit-editor-status" id="subreddit-editor-status"></span>
</div>
</div>
<div class="config-item">
<h4>Target genders</h4>
@@ -2615,8 +2723,6 @@
controlBtn.className = 'control-btn start';
}
document.getElementById('config-subreddits').innerHTML = stats.subreddits
.map(s => `<span class="tag${s.type === 'chat' ? ' tag-chat' : ''}" title="${s.type}">r/${s.name}</span>`).join('');
document.getElementById('config-genders').innerHTML = stats.targetGenders
.map(g => `<span class="tag">${g}</span>`).join('');
@@ -3691,11 +3797,142 @@
const res = await fetch('/api/config');
currentConfig = await res.json();
populateSettingsForm();
renderSubredditEditor();
} catch (err) {
console.error('Failed to fetch config:', err);
}
}
// ---------- Subreddit editor ----------
function renderSubredditEditor() {
const container = document.getElementById('subreddit-editor');
if (!container || !currentConfig?.subreddits) return;
const tones = currentConfig.tones || ['default'];
container.innerHTML = '';
for (const sub of currentConfig.subreddits) {
container.appendChild(buildSubredditRow(sub, tones, false));
}
}
function buildSubredditRow(sub, tones, isNew) {
const row = document.createElement('div');
row.className = 'subreddit-editor-row' + (isNew ? ' new' : '');
row.dataset.original = sub.name || '';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = sub.name || '';
nameInput.placeholder = 'subreddit name';
nameInput.dataset.field = 'name';
if (!isNew) nameInput.readOnly = true;
row.appendChild(nameInput);
const typeSelect = document.createElement('select');
typeSelect.dataset.field = 'type';
for (const t of ['image', 'chat']) {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
if ((sub.type || 'image') === t) opt.selected = true;
typeSelect.appendChild(opt);
}
row.appendChild(typeSelect);
const toneSelect = document.createElement('select');
toneSelect.dataset.field = 'tone';
for (const t of tones) {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
if ((sub.tone || 'default') === t) opt.selected = true;
toneSelect.appendChild(opt);
}
row.appendChild(toneSelect);
const weightInput = document.createElement('input');
weightInput.type = 'number';
weightInput.min = '1';
weightInput.step = '1';
weightInput.value = sub.weight ?? '';
weightInput.placeholder = '1';
weightInput.dataset.field = 'weight';
row.appendChild(weightInput);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.textContent = '×';
removeBtn.title = 'Remove';
removeBtn.onclick = () => row.remove();
row.appendChild(removeBtn);
return row;
}
function addSubredditRow() {
const container = document.getElementById('subreddit-editor');
if (!container) return;
const tones = currentConfig?.tones || ['default'];
container.appendChild(buildSubredditRow({ tone: 'default', type: 'image', weight: 1 }, tones, true));
}
function readSubredditEditor() {
const rows = document.querySelectorAll('#subreddit-editor .subreddit-editor-row');
const out = [];
for (const row of rows) {
const name = row.querySelector('[data-field="name"]').value.trim().replace(/^\/?r\//i, '');
if (!name) continue;
const weightStr = row.querySelector('[data-field="weight"]').value.trim();
const weight = weightStr === '' ? undefined : Number(weightStr);
out.push({
name,
type: row.querySelector('[data-field="type"]').value,
tone: row.querySelector('[data-field="tone"]').value,
...(Number.isFinite(weight) ? { weight } : {}),
});
}
return out;
}
function setSubredditStatus(text, kind) {
const el = document.getElementById('subreddit-editor-status');
if (!el) return;
el.textContent = text || '';
el.className = 'subreddit-editor-status' + (kind ? ' ' + kind : '');
}
async function saveSubreddits() {
const btn = document.getElementById('save-subreddits-btn');
const subreddits = readSubredditEditor();
if (subreddits.length === 0) {
setSubredditStatus('At least one subreddit required.', 'error');
return;
}
btn.disabled = true;
setSubredditStatus('Saving…');
try {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subreddits }),
});
const result = await res.json();
if (!res.ok || !result.success) {
setSubredditStatus('Save failed: ' + (result.error || res.statusText), 'error');
} else {
currentConfig.subreddits = result.subreddits;
renderSubredditEditor();
setSubredditStatus('Saved.', 'success');
setTimeout(() => setSubredditStatus(''), 3000);
fetchStats();
}
} catch (err) {
setSubredditStatus('Save failed: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
}
function populateSettingsForm() {
if (!currentConfig) return;