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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Advanced (full config JSON)
+
+ 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.
+
+
+
+ Save Full Config
+
+
+
+
+
@@ -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 });
+ });
});