diff --git a/backend/src/db.js b/backend/src/db.js index faad4b5..fc5e7b5 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -48,8 +48,18 @@ export async function initDb() { sunset TIMESTAMPTZ, fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - `) + -- User-editable display settings (currently just display_title). Separate from + -- the status table (fetch bookkeeping, not user-facing) -- same KV shape as + -- tasks' config table. A missing/null display_title falls back to + -- VITE_HOTEL_NAME at the frontend (see routes/config.js), so an unconfigured + -- app still shows something sensible without requiring a rebuild. + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `) } export async function getStatus() { @@ -64,3 +74,16 @@ export async function setStatus(key, value) { [key, JSON.stringify(value)] ) } + +export async function getConfig() { + const { rows } = await pool.query('SELECT key, value FROM config') + return Object.fromEntries(rows.map(r => [r.key, r.value])) +} + +export async function setConfigValue(key, value) { + await pool.query( + `INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, JSON.stringify(value)] + ) +} diff --git a/backend/src/index.js b/backend/src/index.js index 9f175f8..8535207 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -4,6 +4,7 @@ import cors from '@fastify/cors' import { initDb } from './db.js' import { startScheduler } from './lib/scheduler.js' import { publicForecastRoutes, internalForecastRoutes, adminForecastRoutes } from './routes/forecast.js' +import { publicConfigRoutes, adminConfigRoutes } from './routes/config.js' const app = Fastify({ logger: true, trustProxy: true }) const startedAt = Date.now() @@ -19,6 +20,8 @@ app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_ await app.register(publicForecastRoutes) await app.register(internalForecastRoutes) await app.register(adminForecastRoutes) +await app.register(publicConfigRoutes) +await app.register(adminConfigRoutes) try { await initDb() diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js new file mode 100644 index 0000000..2992e56 --- /dev/null +++ b/backend/src/routes/config.js @@ -0,0 +1,32 @@ +import { getConfig, setConfigValue } from '../db.js' +import { requireAuth, requireCap } from '../auth.js' + +// Only these keys are writable through the API. +const ALLOWED_KEYS = new Set(['display_title']) + +// GET is public (not under requireAuth) — Display.tsx needs the title with no +// session at all, same reasoning as routes/forecast.js's publicForecastRoutes. +// Nothing sensitive lives in `config`, just display copy, so that's fine. +export async function publicConfigRoutes(app) { + app.get('/api/public/config', async () => { + const config = await getConfig() + return { display_title: config.display_title ?? null } + }) +} + +// Write is authed + 'settings' cap, same as the forecast admin routes. +export async function adminConfigRoutes(app) { + app.put('/api/admin/config', { preHandler: [requireAuth, requireCap('settings')] }, async (req, reply) => { + const body = req.body || {} + for (const key of Object.keys(body)) { + if (!ALLOWED_KEYS.has(key)) return reply.status(400).send({ error: `Unknown setting: ${key}` }) + } + if ('display_title' in body) { + const title = typeof body.display_title === 'string' ? body.display_title.trim() : '' + // Empty string clears the override, reverting the display to VITE_HOTEL_NAME. + await setConfigValue('display_title', title || null) + } + const config = await getConfig() + return { display_title: config.display_title ?? null } + }) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2eda40a..022d398 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { ForecastPayload, AdminStatus } from './types' +import type { ForecastPayload, AdminStatus, DisplayConfig } from './types' const BASE = '/weather/api' @@ -10,6 +10,13 @@ export async function fetchForecast(): Promise { return res.json() } +// Same deal — the display's title has to load with no session too. +export async function fetchDisplayConfig(): Promise { + const res = await fetch(`${BASE}/public/config`) + if (!res.ok) throw new Error(`Request failed: ${res.status}`) + return res.json() +} + // Admin (authed) requests — same 401-redirect pattern as every other app's api.ts. async function adminRequest(path: string, opts: RequestInit = {}): Promise { const res = await fetch(`${BASE}${path}`, { @@ -34,3 +41,6 @@ export function fetchAdminStatus(): Promise { export function triggerRefresh(): Promise<{ ok: boolean; last_fetch_at: string; provider: string }> { return adminRequest('/admin/refresh', { method: 'POST' }) } +export function updateDisplayConfig(display_title: string): Promise { + return adminRequest('/admin/config', { method: 'PUT', body: JSON.stringify({ display_title }) }) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 4ff322c..c07c3ee 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -186,6 +186,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } .wx-admin-actions { display: flex; gap: 10px; margin-top: 16px; } .wx-admin-link { color: var(--gold); text-decoration: none; font-weight: 500; } .wx-admin-link:hover { text-decoration: underline; } +.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; } /* buttons — same convention as every other app */ .btn { diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index d943fd6..2ddcf0e 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,19 +1,25 @@ import { useEffect, useState } from 'react' -import { RefreshCw, ExternalLink } from 'lucide-react' +import { RefreshCw, ExternalLink, Save } from 'lucide-react' import { useAuth } from '../components/AuthGate' -import { fetchAdminStatus, triggerRefresh } from '../api' +import { fetchAdminStatus, triggerRefresh, fetchDisplayConfig, updateDisplayConfig } from '../api' import { can } from '../types' import type { AdminStatus } from '../types' -// The app's config (hotel location, Met Office API key) lives centrally in the -// shared `settings` app — this page only shows fetch status and lets a settings- -// capable user force an immediate refresh, it doesn't duplicate that config form. +// The app's data config (hotel location, Met Office API key) lives centrally in the +// shared `settings` app — this page only shows fetch status, the display title, and +// lets a settings-capable user force an immediate refresh; it doesn't duplicate that +// integrations form. export default function Admin() { const { user } = useAuth() const [status, setStatus] = useState(null) const [error, setError] = useState('') const [refreshing, setRefreshing] = useState(false) + const [titleInput, setTitleInput] = useState('') + const [titleSaved, setTitleSaved] = useState(null) + const [savingTitle, setSavingTitle] = useState(false) + const [titleSavedFlash, setTitleSavedFlash] = useState(false) + const canManage = can(user, 'settings') async function load() { @@ -25,7 +31,13 @@ export default function Admin() { } } - useEffect(() => { load() }, []) + useEffect(() => { + load() + fetchDisplayConfig().then(c => { + setTitleSaved(c.display_title) + setTitleInput(c.display_title ?? '') + }).catch(() => {}) + }, []) async function refresh() { setRefreshing(true) @@ -40,6 +52,22 @@ export default function Admin() { } } + async function saveTitle() { + setSavingTitle(true) + setError('') + try { + const c = await updateDisplayConfig(titleInput) + setTitleSaved(c.display_title) + setTitleInput(c.display_title ?? '') + setTitleSavedFlash(true) + setTimeout(() => setTitleSavedFlash(false), 2000) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save title') + } finally { + setSavingTitle(false) + } + } + return (
@@ -53,6 +81,36 @@ export default function Admin() { {error &&
{error}
} +
+
Display title
+

+ Shown on the reception display header. Leave blank to use the default ({import.meta.env.VITE_HOTEL_NAME || 'Hotel'}). +

+
+ setTitleInput(e.target.value)} + placeholder={import.meta.env.VITE_HOTEL_NAME || 'Hotel'} + disabled={!canManage} + style={{ + flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, + padding: '8px 10px', fontSize: '13.5px', fontFamily: 'var(--font)', color: 'var(--text-dark)', + }} + /> + {canManage && ( + + )} +
+
+
Last fetch diff --git a/frontend/src/pages/Display.tsx b/frontend/src/pages/Display.tsx index 90cd373..35c2a40 100644 --- a/frontend/src/pages/Display.tsx +++ b/frontend/src/pages/Display.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { CloudOff, Sunrise, Sunset } from 'lucide-react' -import { fetchForecast } from '../api' +import { fetchForecast, fetchDisplayConfig } from '../api' import { WeatherIcon } from '../lib/icons' import type { ForecastPayload, ForecastPoint, Astro } from '../types' @@ -69,7 +69,10 @@ export default function Display() { const [data, setData] = useState(null) const [error, setError] = useState(false) const [clock, setClock] = useState(() => londonNow()) - const hotelName = import.meta.env.VITE_HOTEL_NAME || 'Hotel' + // DB override (set via Admin -> "Display title") takes precedence; falls back to + // the build-time VITE_HOTEL_NAME so an unconfigured install still shows something. + const [displayTitle, setDisplayTitle] = useState(null) + const hotelName = displayTitle || import.meta.env.VITE_HOTEL_NAME || 'Hotel' const hourlyRowRef = useRef(null) useEffect(() => { @@ -87,6 +90,13 @@ export default function Display() { return () => { cancelled = true; clearInterval(interval) } }, []) + useEffect(() => { + // Config (title) changes far less often than the forecast — fetch once on + // mount, no polling needed; a kiosk tab left open for a title change would + // just pick it up on its next reload. + fetchDisplayConfig().then(c => setDisplayTitle(c.display_title)).catch(() => {}) + }, []) + useEffect(() => { const t = setInterval(() => setClock(londonNow()), 30_000) return () => clearInterval(t) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c65aaf7..67b7381 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -34,6 +34,10 @@ export interface ForecastPayload { } } +export interface DisplayConfig { + display_title: string | null // null = no override, frontend falls back to VITE_HOTEL_NAME +} + export interface AdminStatus { last_fetch_at: string | null last_provider: string | null