Add admin config screen (issue #14)
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s
Lets the photographer edit config/site.json from /dashboard instead of SSHing in and hand-editing the file. Two tiers: - Quick-edit form for the fields actually touched day to day (site title/tagline/hero, photographer bio, contact/social, Venmo username, session pricing, theme colors) - Raw JSON textarea for everything else (portfolio categories, locations, session types, Immich settings) - the form's fields are a subset of this, not a separate source of truth Backend: GET/PUT /api/admin/config, gated by the existing requireAdmin middleware (OIDC admin session or legacy ADMIN_SECRET). PUT validates the body is an object with the required top-level sections, writes atomically (temp file + rename), and keeps one prior version as site.json.bak before overwriting. Required a docker-compose.yml change: the api service had no volume mount for config/ at all before this (only portfolio/nginx did, and read-only) - added a read-write mount so the API can actually write the file the live site reads.
This commit is contained in:
@@ -12,6 +12,8 @@ const PORT = process.env.PORT || 3001;
|
||||
|
||||
const CONTRACTS_DIR = path.join(__dirname, 'contracts');
|
||||
const SIGNED_DIR = path.join(__dirname, 'signed-contracts');
|
||||
const CONFIG_PATH = path.join(__dirname, 'config', 'site.json');
|
||||
const CONFIG_BACKUP_PATH = path.join(__dirname, 'config', 'site.json.bak');
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
@@ -374,4 +376,39 @@ app.get('/api/admin/payments/export', requireAdmin, (req, res) => {
|
||||
res.send(csv);
|
||||
});
|
||||
|
||||
// Site config (issue #14) — lets the photographer edit config/site.json from
|
||||
// the browser instead of SSHing in. Requires the api service to have a
|
||||
// read-write mount for ./config (see docker-compose.yml); the portfolio/nginx
|
||||
// service's own mount of the same host directory is read-only.
|
||||
app.get('/api/admin/config', requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.type('application/json').send(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: `Could not read config: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/admin/config', requireAdmin, (req, res) => {
|
||||
const next = req.body;
|
||||
if (!next || typeof next !== 'object' || Array.isArray(next)) {
|
||||
return res.status(400).json({ error: 'Body must be a JSON object' });
|
||||
}
|
||||
for (const key of ['site', 'photographer', 'contact']) {
|
||||
if (!next[key] || typeof next[key] !== 'object') {
|
||||
return res.status(400).json({ error: `Missing or invalid required section: "${key}"` });
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Keep one prior version so a bad save can be undone by hand - not a
|
||||
// full history, just a safety net against fat-fingering the editor.
|
||||
if (fs.existsSync(CONFIG_PATH)) fs.copyFileSync(CONFIG_PATH, CONFIG_BACKUP_PATH);
|
||||
const tmpPath = `${CONFIG_PATH}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n');
|
||||
fs.renameSync(tmpPath, CONFIG_PATH);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: `Could not write config: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
|
||||
|
||||
@@ -39,6 +39,10 @@ services:
|
||||
- ./api/data:/app/data
|
||||
- ./api/signed-contracts:/app/signed-contracts
|
||||
- ./api/contracts:/app/contracts:ro
|
||||
# Read-write: the admin config screen (issue #14) edits site.json here.
|
||||
# Shares the same host directory the portfolio/nginx service mounts
|
||||
# read-only, so writes show up on the live site (subject to its 5-min cache).
|
||||
- ./config:/app/config
|
||||
networks:
|
||||
- web
|
||||
healthcheck:
|
||||
|
||||
@@ -226,6 +226,24 @@ tr.expanded td { background: var(--bg); }
|
||||
font-family: inherit; font-size: .87rem; resize: vertical; min-height: 60px; background: var(--bg);
|
||||
}
|
||||
.notes-input:focus { outline: none; border-color: var(--primary); }
|
||||
|
||||
/* ── Config panel ─────────────────────────────────── */
|
||||
.cfg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; }
|
||||
.cfg-field label { display: block; font-size: .78rem; color: var(--text-muted); margin-bottom: .3rem; }
|
||||
.cfg-field input {
|
||||
width: 100%; padding: .55rem .75rem; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-family: inherit; font-size: .87rem; color: var(--text); background: var(--bg); outline: none;
|
||||
}
|
||||
.cfg-field input:focus { border-color: var(--primary); }
|
||||
.cfg-field input[type="color"] { padding: .25rem; height: 2.4rem; }
|
||||
.cfg-json {
|
||||
width: 100%; min-height: 360px; padding: .75rem; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-family: 'SF Mono', Consolas, monospace; font-size: .8rem; resize: vertical; background: var(--bg);
|
||||
}
|
||||
.cfg-json:focus { outline: none; border-color: var(--primary); }
|
||||
.cfg-status { font-size: .82rem; margin-top: .6rem; display: none; }
|
||||
.cfg-status.ok { color: var(--green); display: block; }
|
||||
.cfg-status.err { color: var(--red); display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -258,6 +276,7 @@ tr.expanded td { background: var(--bg); }
|
||||
<a onclick="showPanel('calendar')" id="nav-calendar">■ Calendar</a>
|
||||
<a onclick="showPanel('bookings')" id="nav-bookings">■ Bookings</a>
|
||||
<a onclick="showPanel('payments')" id="nav-payments">■ Payments <span class="badge" id="pending-badge" style="display:none"></span></a>
|
||||
<a onclick="showPanel('config')" id="nav-config">■ Config</a>
|
||||
</nav>
|
||||
<main>
|
||||
<!-- Overview panel -->
|
||||
@@ -333,6 +352,83 @@ tr.expanded td { background: var(--bg); }
|
||||
</div>
|
||||
<div id="payments-table-wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- Config panel -->
|
||||
<div class="panel" id="panel-config">
|
||||
<h2 class="panel-title">Config</h2>
|
||||
|
||||
<div class="card" id="cfg-loading"><div class="loading">Loading…</div></div>
|
||||
|
||||
<div id="cfg-content" style="display:none">
|
||||
<div class="card">
|
||||
<h3>Site</h3>
|
||||
<div class="cfg-grid">
|
||||
<div class="cfg-field"><label>Title</label><input id="cfg-site-title"></div>
|
||||
<div class="cfg-field"><label>Tagline</label><input id="cfg-site-tagline"></div>
|
||||
<div class="cfg-field"><label>Hero image URL</label><input id="cfg-site-heroImage" placeholder="/images/hero.jpg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Photographer</h3>
|
||||
<div class="cfg-grid">
|
||||
<div class="cfg-field"><label>Name</label><input id="cfg-photographer-name"></div>
|
||||
<div class="cfg-field"><label>Profile image URL</label><input id="cfg-photographer-image"></div>
|
||||
<div class="cfg-field" style="grid-column:1/-1"><label>Bio</label><input id="cfg-photographer-bio"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Contact & Social</h3>
|
||||
<div class="cfg-grid">
|
||||
<div class="cfg-field"><label>Email</label><input id="cfg-contact-email" type="email"></div>
|
||||
<div class="cfg-field"><label>Phone</label><input id="cfg-contact-phone"></div>
|
||||
<div class="cfg-field"><label>Instagram</label><input id="cfg-social-instagram"></div>
|
||||
<div class="cfg-field"><label>Facebook</label><input id="cfg-social-facebook"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Booking</h3>
|
||||
<div class="cfg-grid">
|
||||
<div class="cfg-field"><label>Venmo username</label><input id="cfg-booking-venmoUsername" placeholder="LisiLouPhoto"></div>
|
||||
<div class="cfg-field"><label>Mini session price ($)</label><input id="cfg-booking-pricing-mini" type="number" min="0"></div>
|
||||
<div class="cfg-field"><label>Full session price ($)</label><input id="cfg-booking-pricing-full" type="number" min="0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Theme</h3>
|
||||
<div class="cfg-grid">
|
||||
<div class="cfg-field"><label>Primary color</label><input id="cfg-theme-primaryColor" type="color"></div>
|
||||
<div class="cfg-field"><label>Accent color</label><input id="cfg-theme-accentColor" type="color"></div>
|
||||
<div class="cfg-field"><label>Background color</label><input id="cfg-theme-backgroundColor" type="color"></div>
|
||||
<div class="cfg-field"><label>Text color</label><input id="cfg-theme-textColor" type="color"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<button class="btn btn-primary" onclick="saveConfigQuick()">Save Changes</button>
|
||||
<span id="cfg-quick-status" class="cfg-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Advanced (full config JSON)</h3>
|
||||
<p style="color:var(--text-muted);font-size:.82rem;margin-bottom:.75rem;">
|
||||
Everything above lives here too — portfolio categories, locations, session types,
|
||||
Immich settings, and anything else not covered by the form. Edit with care: this
|
||||
replaces the entire file. The previous version is kept as a one-step backup.
|
||||
</p>
|
||||
<textarea class="cfg-json" id="cfg-raw-json" spellcheck="false"></textarea>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:.75rem;">
|
||||
<button class="btn btn-outline" onclick="saveConfigRaw()">Save Full Config</button>
|
||||
<span id="cfg-raw-status" class="cfg-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,6 +572,117 @@ function showPanel(name) {
|
||||
if (name === 'calendar') renderCalendar();
|
||||
if (name === 'bookings') loadBookings();
|
||||
if (name === 'payments') loadPaymentsPanel();
|
||||
if (name === 'config') loadConfigPanel();
|
||||
}
|
||||
|
||||
// ── Config panel (issue #14) ─────────────────────────────────────────────────
|
||||
let configDraft = null;
|
||||
|
||||
function cfgGet(obj, path) {
|
||||
return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||||
}
|
||||
function cfgSet(obj, path, value) {
|
||||
const keys = path.split('.');
|
||||
const last = keys.pop();
|
||||
const target = keys.reduce((o, k) => (o[k] ??= {}), obj);
|
||||
target[last] = value;
|
||||
}
|
||||
|
||||
const CFG_QUICK_FIELDS = [
|
||||
['cfg-site-title', 'site.title'],
|
||||
['cfg-site-tagline', 'site.tagline'],
|
||||
['cfg-site-heroImage', 'site.heroImage'],
|
||||
['cfg-photographer-name', 'photographer.name'],
|
||||
['cfg-photographer-image', 'photographer.image'],
|
||||
['cfg-photographer-bio', 'photographer.bio'],
|
||||
['cfg-contact-email', 'contact.email'],
|
||||
['cfg-contact-phone', 'contact.phone'],
|
||||
['cfg-social-instagram', 'social.instagram'],
|
||||
['cfg-social-facebook', 'social.facebook'],
|
||||
['cfg-booking-venmoUsername', 'booking.venmoUsername'],
|
||||
];
|
||||
const CFG_QUICK_NUMBER_FIELDS = [
|
||||
['cfg-booking-pricing-mini', 'booking.pricing.mini'],
|
||||
['cfg-booking-pricing-full', 'booking.pricing.full'],
|
||||
];
|
||||
const CFG_QUICK_COLOR_FIELDS = [
|
||||
['cfg-theme-primaryColor', 'theme.primaryColor'],
|
||||
['cfg-theme-accentColor', 'theme.accentColor'],
|
||||
['cfg-theme-backgroundColor', 'theme.backgroundColor'],
|
||||
['cfg-theme-textColor', 'theme.textColor'],
|
||||
];
|
||||
|
||||
async function loadConfigPanel() {
|
||||
document.getElementById('cfg-loading').style.display = 'block';
|
||||
document.getElementById('cfg-content').style.display = 'none';
|
||||
try {
|
||||
const r = await api('GET', '/api/admin/config');
|
||||
if (!r.ok) throw new Error(r.data?.error || 'Failed to load config');
|
||||
configDraft = r.data;
|
||||
|
||||
for (const [id, path] of CFG_QUICK_FIELDS) {
|
||||
document.getElementById(id).value = cfgGet(configDraft, path) || '';
|
||||
}
|
||||
for (const [id, path] of CFG_QUICK_NUMBER_FIELDS) {
|
||||
document.getElementById(id).value = cfgGet(configDraft, path) ?? '';
|
||||
}
|
||||
for (const [id, path] of CFG_QUICK_COLOR_FIELDS) {
|
||||
document.getElementById(id).value = cfgGet(configDraft, path) || '#000000';
|
||||
}
|
||||
document.getElementById('cfg-raw-json').value = JSON.stringify(configDraft, null, 2);
|
||||
|
||||
document.getElementById('cfg-loading').style.display = 'none';
|
||||
document.getElementById('cfg-content').style.display = 'block';
|
||||
} catch (e) {
|
||||
document.getElementById('cfg-loading').innerHTML =
|
||||
`<div class="cfg-status err" style="display:block">Could not load config: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function cfgShowStatus(id, ok, msg) {
|
||||
const el = document.getElementById(id);
|
||||
el.className = 'cfg-status ' + (ok ? 'ok' : 'err');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
if (ok) setTimeout(() => { el.style.display = 'none'; }, 3000);
|
||||
}
|
||||
|
||||
async function saveConfigQuick() {
|
||||
if (!configDraft) return;
|
||||
for (const [id, path] of CFG_QUICK_FIELDS) {
|
||||
cfgSet(configDraft, path, document.getElementById(id).value);
|
||||
}
|
||||
for (const [id, path] of CFG_QUICK_NUMBER_FIELDS) {
|
||||
const v = document.getElementById(id).value;
|
||||
cfgSet(configDraft, path, v === '' ? null : Number(v));
|
||||
}
|
||||
for (const [id, path] of CFG_QUICK_COLOR_FIELDS) {
|
||||
cfgSet(configDraft, path, document.getElementById(id).value);
|
||||
}
|
||||
document.getElementById('cfg-raw-json').value = JSON.stringify(configDraft, null, 2);
|
||||
await cfgPersist(configDraft, 'cfg-quick-status');
|
||||
}
|
||||
|
||||
async function saveConfigRaw() {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(document.getElementById('cfg-raw-json').value);
|
||||
} catch (e) {
|
||||
return cfgShowStatus('cfg-raw-status', false, `Invalid JSON: ${e.message}`);
|
||||
}
|
||||
configDraft = parsed;
|
||||
await cfgPersist(parsed, 'cfg-raw-status');
|
||||
await loadConfigPanel(); // re-sync the quick-edit fields from the saved result
|
||||
}
|
||||
|
||||
async function cfgPersist(payload, statusId) {
|
||||
try {
|
||||
const r = await api('PUT', '/api/admin/config', payload);
|
||||
if (!r.ok) throw new Error(r.data?.error || `Save failed (${r.status})`);
|
||||
cfgShowStatus(statusId, true, 'Saved. Live site picks this up within ~5 minutes (nginx cache).');
|
||||
} catch (e) {
|
||||
cfgShowStatus(statusId, false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overview ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -142,4 +142,25 @@ test.describe('Admin dashboard — panels', () => {
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/lisilou-bookings.*\.csv/);
|
||||
});
|
||||
|
||||
test('Config panel loads current site.json into the form', async ({ page }) => {
|
||||
await page.locator('#nav-config').click();
|
||||
await expect(page.locator('#panel-config')).toBeVisible();
|
||||
await expect(page.locator('#cfg-content')).toBeVisible({ timeout: 8_000 });
|
||||
// Title field should be populated from real config, not left blank
|
||||
await expect(page.locator('#cfg-site-title')).not.toHaveValue('');
|
||||
// Raw JSON textarea should contain valid, non-trivial JSON
|
||||
const raw = await page.locator('#cfg-raw-json').inputValue();
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
expect(JSON.parse(raw)).toHaveProperty('site');
|
||||
});
|
||||
|
||||
test('Config panel round-trips a no-op save without error', async ({ page }) => {
|
||||
// Saves the exact same content back - proves the PUT path works without
|
||||
// mutating the real config the live site depends on.
|
||||
await page.locator('#nav-config').click();
|
||||
await expect(page.locator('#cfg-content')).toBeVisible({ timeout: 8_000 });
|
||||
await page.locator('button:has-text("Save Full Config")').click();
|
||||
await expect(page.locator('#cfg-raw-status')).toHaveClass(/ok/, { timeout: 8_000 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user