From 4ac34ca943ab07a22c9351d54f74cf79deb457bb Mon Sep 17 00:00:00 2001 From: Jerod Hodgkin Date: Mon, 20 Jul 2026 06:37:20 +0000 Subject: [PATCH] Add admin config screen (issue #14) 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. --- api/server.js | 37 +++++++ docker-compose.yml | 4 + src/dashboard.html | 207 ++++++++++++++++++++++++++++++++++++++++ tests/dashboard.spec.js | 21 ++++ 4 files changed, 269 insertions(+) diff --git a/api/server.js b/api/server.js index 9eff8ff..ffca86d 100644 --- a/api/server.js +++ b/api/server.js @@ -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}`)); diff --git a/docker-compose.yml b/docker-compose.yml index daff071..ae50894 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/dashboard.html b/src/dashboard.html index b174904..57f8101 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -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; } @@ -258,6 +276,7 @@ tr.expanded td { background: var(--bg); } ■ Calendar ■ Bookings ■ Payments + ■ Config
@@ -333,6 +352,83 @@ tr.expanded td { background: var(--bg); }
+ + +
+

Config

+ +
Loading…
+ + +
@@ -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 = + `
Could not load config: ${e.message}
`; + } +} + +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 ────────────────────────────────────────────────────────────────── diff --git a/tests/dashboard.spec.js b/tests/dashboard.spec.js index d1f9977..53bba0f 100644 --- a/tests/dashboard.spec.js +++ b/tests/dashboard.spec.js @@ -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 }); + }); });