diff --git a/AGENTS.md b/AGENTS.md index 823fa9f..3875acd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,10 +13,12 @@ - Background jobs (transcribe, summarize) run via in-process `ThreadPoolExecutor(max_workers=2)` — no Redis/Celery. - Frontend serves on port **8020** (d20 nod). -## Critical convention: flat settings schema +## Critical convention: per-campaign settings schema -Settings are a flat key/value table in SQLite. **Every layer must share exact same keys:** -`backend/app/config.py:DEFAULT_SETTINGS`, `frontend/src/api.ts:AppSettings`, the settings router, and Setup/Settings pages. Changing a key without updating all layers silently desyncs the UI. +Settings are split into **global** (`settings` table, just `onboarding_completed`) and **per-campaign** (`campaign_settings` table, everything else). Every layer must share the exact same keys: +`backend/app/config.py:CAMPAIGN_SETTINGS`, `frontend/src/api.ts:CampaignSettings`, campaigns router, and Settings/Setup pages. New campaigns inherit settings from the "default" campaign on creation. +The settings router (`/api/settings`) only handles global; campaign settings live at `/api/campaigns/{id}/settings`. +When adding a setting key, add it to `config.py:CAMPAIGN_SETTINGS`, the frontend type, and both the backend router and frontend Settings page. ## Docker / framework quirks diff --git a/backend/app/config.py b/backend/app/config.py index 7d0ce39..7e0aa7a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,16 +31,15 @@ def campaign_notes_dir(campaign_id: str) -> Path: for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR): d.mkdir(parents=True, exist_ok=True) -# Runtime-editable settings (stored in DB, these are just first-run defaults). -# NOTE: this flat key/value schema is the single source of truth for settings - -# it's what every consumer (pipeline/summarize.py, pipeline/transcribe.py, -# routers/models.py, routers/diagnostics.py) actually reads. The settings -# router and frontend must mirror these exact keys - a nested schema would -# silently disconnect the Settings/Setup UI from the pipeline. -DEFAULT_SETTINGS = { - # Onboarding +# Runtime-editable global settings (stored in the `settings` table). +# Only onboarding state lives here; everything else is per-campaign. +GLOBAL_SETTINGS = { "onboarding_completed": "false", +} +# Per-campaign settings — seeded into campaign_settings on creation. +# Every layer (frontend, router, pipeline) reads these from the campaign. +CAMPAIGN_SETTINGS = { # Transcription "whisper_model": "medium", # tiny|base|small|medium|large-v3 - user picks based on their hardware "whisper_compute_type": "int8", diff --git a/backend/app/database.py b/backend/app/database.py index a644775..68fde2c 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -6,6 +6,7 @@ import uuid from contextlib import contextmanager from . import config +from .config import campaign_audio_dir, campaign_transcript_dir, campaign_notes_dir _local = threading.local() @@ -94,6 +95,14 @@ def init_db(): created_at REAL NOT NULL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS campaign_settings ( + campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + UNIQUE(campaign_id, key) + ) + """) try: conn.execute("ALTER TABLE sessions ADD COLUMN campaign_id TEXT REFERENCES campaigns(id) ON DELETE SET NULL") except Exception: @@ -105,13 +114,30 @@ def init_db(): ("default", "Default Campaign", now()), ) conn.execute("UPDATE sessions SET campaign_id = 'default' WHERE campaign_id IS NULL") + campaign_audio_dir("default").mkdir(parents=True, exist_ok=True) + campaign_transcript_dir("default").mkdir(parents=True, exist_ok=True) + campaign_notes_dir("default").mkdir(parents=True, exist_ok=True) - # seed defaults if empty - existing = {r["key"] for r in conn.execute("SELECT key FROM settings")} - for k, v in config.DEFAULT_SETTINGS.items(): - if k not in existing: + # seed global settings (just onboarding_completed) + existing_global = {r["key"] for r in conn.execute("SELECT key FROM settings")} + for k, v in config.GLOBAL_SETTINGS.items(): + if k not in existing_global: conn.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (k, v)) + # seed default campaign settings from CAMPAIGN_SETTINGS (migrate old global rows) + existing_cs = {r["key"] for r in conn.execute( + "SELECT key FROM campaign_settings WHERE campaign_id = 'default'" + )} + for k, v in config.CAMPAIGN_SETTINGS.items(): + if k not in existing_cs: + # If this key was previously stored in global settings, carry it over + old_row = conn.execute("SELECT value FROM settings WHERE key = ?", (k,)).fetchone() + val = old_row["value"] if old_row else v + conn.execute( + "INSERT INTO campaign_settings (campaign_id, key, value) VALUES (?, ?, ?)", + ("default", k, val), + ) + def get_settings() -> dict: conn = get_conn() @@ -122,6 +148,8 @@ def get_settings() -> dict: def update_settings(patch: dict): with tx() as conn: for k, v in patch.items(): + if k not in config.GLOBAL_SETTINGS: + continue conn.execute( "INSERT INTO settings (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", @@ -129,6 +157,24 @@ def update_settings(patch: dict): ) +def get_campaign_settings(campaign_id: str) -> dict: + conn = get_conn() + rows = conn.execute( + "SELECT key, value FROM campaign_settings WHERE campaign_id = ?", (campaign_id,) + ).fetchall() + return {r["key"]: r["value"] for r in rows} + + +def update_campaign_settings(campaign_id: str, patch: dict): + with tx() as conn: + for k, v in patch.items(): + conn.execute( + "INSERT INTO campaign_settings (campaign_id, key, value) VALUES (?, ?, ?) " + "ON CONFLICT(campaign_id, key) DO UPDATE SET value=excluded.value", + (campaign_id, k, str(v)), + ) + + def new_id() -> str: return uuid.uuid4().hex[:12] @@ -168,7 +214,17 @@ def create_campaign(name: str, description: str = "") -> dict: "INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, ?, ?)", (campaign_id, name, description, now()), ) - from .config import campaign_audio_dir, campaign_transcript_dir, campaign_notes_dir + # Inherit CAMPAIGN_SETTINGS from the "default" campaign as a starting point + parent = conn.execute( + "SELECT key, value FROM campaign_settings WHERE campaign_id = 'default'" + ).fetchall() + inherited = {r["key"]: r["value"] for r in parent} + for k, v in config.CAMPAIGN_SETTINGS.items(): + val = inherited.get(k, v) + conn.execute( + "INSERT INTO campaign_settings (campaign_id, key, value) VALUES (?, ?, ?)", + (campaign_id, k, val), + ) campaign_audio_dir(campaign_id).mkdir(parents=True, exist_ok=True) campaign_transcript_dir(campaign_id).mkdir(parents=True, exist_ok=True) campaign_notes_dir(campaign_id).mkdir(parents=True, exist_ok=True) diff --git a/backend/app/diagnostics.py b/backend/app/diagnostics.py index f943dd2..bcf8774 100644 --- a/backend/app/diagnostics.py +++ b/backend/app/diagnostics.py @@ -26,16 +26,16 @@ def _check_gpu() -> dict: return {"name": "gpu", "ok": False, "message": f"Could not query GPU: {e}"} -def _check_hf_token() -> dict: - settings = db.get_settings() +def _check_hf_token(campaign_id: str = "default") -> dict: + settings = db.get_campaign_settings(campaign_id) token = settings.get("hf_token", "") if not token: return {"name": "huggingface_token", "ok": False, "message": "No HF token set. Diarization (speaker separation) will fail without one. Add it in Settings."} return {"name": "huggingface_token", "ok": True, "message": "Token is set (not validated against HF until first diarization run)."} -def _check_llm_backend() -> dict: - settings = db.get_settings() +def _check_llm_backend(campaign_id: str = "default") -> dict: + settings = db.get_campaign_settings(campaign_id) if settings.get("llm_mode") == "api": if not settings.get("api_key"): return {"name": "llm_backend", "ok": False, "message": "Hosted API selected but no API key set."} @@ -63,11 +63,11 @@ def _check_disk_space() -> dict: return {"name": "disk_space", "ok": ok, "message": f"{free_gb:.1f}GB free in {config.DATA_DIR}" + ("" if ok else " — this is low, recordings and model caches need room")} -def run_diagnostics() -> list[dict]: +def run_diagnostics(campaign_id: str = "default") -> list[dict]: return [ _check_ffmpeg(), _check_gpu(), - _check_hf_token(), - _check_llm_backend(), + _check_hf_token(campaign_id), + _check_llm_backend(campaign_id), _check_disk_space(), ] diff --git a/backend/app/pipeline/summarize.py b/backend/app/pipeline/summarize.py index f27bd5e..92e5a49 100644 --- a/backend/app/pipeline/summarize.py +++ b/backend/app/pipeline/summarize.py @@ -144,7 +144,7 @@ def make_llm_caller(settings: dict): def summarize_session(turns: list[dict], settings: dict, progress_cb=None, player_recap_style: str | None = None, - player_recap_custom_prompt: str | None = None) -> tuple[str, str]: + player_recap_custom_prompt: str | None = None) -> tuple[str, str, str]: call = make_llm_caller(settings) ctx_path = settings.get("world_context_path") or "" if ctx_path and Path(ctx_path).exists(): @@ -175,5 +175,9 @@ def summarize_session(turns: list[dict], settings: dict, progress_cb=None, player_template = raw if raw else PLAYER_FINAL_PROMPTS["story"] else: player_template = PLAYER_FINAL_PROMPTS.get(style, PLAYER_FINAL_PROMPTS["story"]) - player_final = call(player_template.format(summaries="\n\n".join(player_summaries))) - return dm_final, player_final + summaries_text = "\n\n".join(player_summaries) + if "{summaries}" in player_template: + player_final = call(player_template.format(summaries=summaries_text)) + else: + player_final = call(f"{player_template}\n\nSession excerpts:\n{summaries_text}") + return dm_final, player_final, player_template diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 5ebb533..1ecdc62 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, HTTPException -from .. import database as db +from .. import database as db, config router = APIRouter(prefix="/api/campaigns", tags=["campaigns"]) @@ -45,3 +45,24 @@ def delete_campaign(campaign_id: str): raise HTTPException(404, "Campaign not found") db.delete_campaign(campaign_id) return {"ok": True} + + +@router.get("/{campaign_id}/settings") +def get_campaign_settings(campaign_id: str): + if not db.get_campaign(campaign_id): + raise HTTPException(404, "Campaign not found") + raw = db.get_campaign_settings(campaign_id) + # return defaults for any missing keys + merged = {**config.CAMPAIGN_SETTINGS, **raw} + return merged + + +@router.post("/{campaign_id}/settings") +def update_campaign_settings(campaign_id: str, body: dict): + if not db.get_campaign(campaign_id): + raise HTTPException(404, "Campaign not found") + clean = {k: str(v) for k, v in body.items() if k in config.CAMPAIGN_SETTINGS} + db.update_campaign_settings(campaign_id, clean) + raw = db.get_campaign_settings(campaign_id) + merged = {**config.CAMPAIGN_SETTINGS, **raw} + return merged diff --git a/backend/app/routers/diagnostics.py b/backend/app/routers/diagnostics.py index e42d881..e0adc31 100644 --- a/backend/app/routers/diagnostics.py +++ b/backend/app/routers/diagnostics.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Query from ..diagnostics import run_diagnostics @@ -6,6 +6,6 @@ router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"]) @router.get("") -def get_diagnostics(): - checks = run_diagnostics() +def get_diagnostics(campaign_id: str = Query("default")): + checks = run_diagnostics(campaign_id) return {"ok": all(c["ok"] for c in checks), "checks": checks} diff --git a/backend/app/routers/models.py b/backend/app/routers/models.py index e997328..7c419ea 100644 --- a/backend/app/routers/models.py +++ b/backend/app/routers/models.py @@ -1,5 +1,5 @@ import requests -from fastapi import APIRouter +from fastapi import APIRouter, Query from .. import database as db @@ -7,10 +7,10 @@ router = APIRouter(prefix="/api/models", tags=["models"]) @router.get("/ollama") -def list_ollama_models(): +def list_ollama_models(campaign_id: str = Query("default")): """List models already pulled on the configured Ollama host, so the setup wizard can offer a dropdown instead of asking the user to type a tag blind.""" - settings = db.get_settings() + settings = db.get_campaign_settings(campaign_id) host = settings.get("ollama_host", "http://host.docker.internal:11434") try: resp = requests.get(f"{host.rstrip('/')}/api/tags", timeout=5) diff --git a/backend/app/routers/notes.py b/backend/app/routers/notes.py index f424553..9b3e487 100644 --- a/backend/app/routers/notes.py +++ b/backend/app/routers/notes.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Request from .. import database as db, jobs from ..pipeline.turns import load_turns -from ..pipeline.summarize import summarize_session, PLAYER_FINAL_PROMPTS +from ..pipeline.summarize import summarize_session router = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"]) @@ -18,26 +18,21 @@ async def generate_notes(session_id: str, request: Request): if not session["transcript_path"]: raise HTTPException(400, "Session hasn't been transcribed yet") - settings = db.get_settings() + cid = session["campaign_id"] or "default" + settings = db.get_campaign_settings(cid) speaker_map = {r["raw_label"]: r["display_name"] for r in db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,)) if r["display_name"]} - # Per-request style overrides the global setting + # Per-request style overrides the campaign setting style = body.get("player_recap_style") or settings.get("player_recap_style") or "story" custom = body.get("player_recap_custom_prompt") or settings.get("player_recap_custom_prompt") or "" - # Resolve the prompt template (same logic as summarize_session) - if style == "custom": - player_template = custom if custom else PLAYER_FINAL_PROMPTS["story"] - else: - player_template = PLAYER_FINAL_PROMPTS.get(style, PLAYER_FINAL_PROMPTS["story"]) - job_id = db.create_job(session_id, "summarize") def run(progress_cb): turns = load_turns(Path(session["transcript_path"]), speaker_map) - dm_notes, player_recap = summarize_session( + dm_notes, player_recap, used_template = summarize_session( turns, settings, progress_cb=progress_cb, player_recap_style=style, player_recap_custom_prompt=custom, @@ -47,7 +42,7 @@ async def generate_notes(session_id: str, request: Request): "INSERT INTO notes (session_id, dm_notes, player_recap, player_recap_prompt, generated_at) VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, " "player_recap=excluded.player_recap, player_recap_prompt=excluded.player_recap_prompt, generated_at=excluded.generated_at", - (session_id, dm_notes, player_recap, player_template, db.now()), + (session_id, dm_notes, player_recap, used_template, db.now()), ) conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,)) diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index ebcc113..cfb33c4 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -84,8 +84,9 @@ def get_session(session_id: str): return session -def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path): - settings = db.get_settings() +def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path, campaign_id: str | None = None): + cid = campaign_id or "default" + settings = db.get_campaign_settings(cid) def run(progress_cb): db.get_conn() # ensure thread-local connection exists in this worker thread @@ -159,7 +160,7 @@ async def create_session( audio_path = audio_dir / f"{session_id}.wav" transcript_path = transcript_dir / f"{session_id}.json" - job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) + job_id = _run_transcription(session_id, video_path, audio_path, transcript_path, campaign_id) return {"session_id": session_id, "job_id": job_id} @@ -179,7 +180,7 @@ def retry_transcription(session_id: str): else: audio_path = config.AUDIO_DIR / f"{session_id}.wav" transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" - job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) + job_id = _run_transcription(session_id, video_path, audio_path, transcript_path, campaign_id) return {"job_id": job_id} diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index d86ad95..746a2f6 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -6,27 +6,10 @@ from .. import database as db, config router = APIRouter(prefix="/api/settings", tags=["settings"]) -# Settings are stored as flat key/value rows (see database.py / config.DEFAULT_SETTINGS). -# This router is a thin pass-through over that table - it must not invent its own -# schema, or the Settings/Setup pages will silently stop affecting the actual -# transcription/summarization pipeline, which reads these same flat keys. +# Only global (GLOBAL_SETTINGS) keys live here; campaign settings are under /api/campaigns/{id}/settings. -# Env var overrides — set these in docker-compose.yml to prefill the setup wizard _ENV_OVERRIDES = { "onboarding_completed": "NAT20_ONBOARDING_COMPLETED", - "hf_token": "NAT20_HF_TOKEN", - "whisper_model": "NAT20_WHISPER_MODEL", - "whisper_compute_type": "NAT20_WHISPER_COMPUTE_TYPE", - "ollama_host": "NAT20_OLLAMA_HOST", - "ollama_model": "NAT20_OLLAMA_MODEL", - "api_base_url": "NAT20_API_BASE_URL", - "api_key": "NAT20_API_KEY", - "api_model": "NAT20_API_MODEL", - "chunk_word_target": "NAT20_CHUNK_WORD_TARGET", - "world_context": "NAT20_WORLD_CONTEXT", - "world_context_path": "NAT20_WORLD_CONTEXT_PATH", - "player_recap_style": "NAT20_PLAYER_RECAP_STYLE", - "player_recap_custom_prompt": "NAT20_PLAYER_RECAP_CUSTOM_PROMPT", } _BOOL_KEYS = {"onboarding_completed"} @@ -39,15 +22,15 @@ def _coerce(key: str, value: str): def _merge_with_env(raw: dict) -> dict: - """Priority: explicit user DB saves (non-default) > env vars > DEFAULT_SETTINGS.""" - merged = {**config.DEFAULT_SETTINGS} + """Priority: explicit user DB saves (non-default) > env vars > GLOBAL_SETTINGS.""" + merged = {**config.GLOBAL_SETTINGS} for key, env_name in _ENV_OVERRIDES.items(): val = os.environ.get(env_name) if val is not None: merged[key] = val for key, val in raw.items(): db_val = val.strip() - if db_val and db_val != config.DEFAULT_SETTINGS.get(key, ""): + if db_val and db_val != config.GLOBAL_SETTINGS.get(key, ""): merged[key] = db_val return merged @@ -61,7 +44,7 @@ async def get_settings(): @router.post("") async def update_settings(patch: dict): - clean = {k: str(v) for k, v in patch.items() if k in config.DEFAULT_SETTINGS} + clean = {k: str(v) for k, v in patch.items() if k in config.GLOBAL_SETTINGS} db.update_settings(clean) raw = db.get_settings() merged = _merge_with_env(raw) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fbc3709..f350edd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,11 +1,12 @@ const BASE_URL = '/api'; -// Flat settings schema - must mirror backend/app/config.py's DEFAULT_SETTINGS -// exactly. This is the single source of truth; don't reintroduce a nested -// shape here without updating config.py, routers/settings.py, Setup.tsx and -// Settings.tsx together. -export interface AppSettings { +// Global settings (just onboarding tracking). +export interface GlobalSettings { onboarding_completed: boolean; +} + +// Per-campaign settings — mirrors backend CAMPAIGN_SETTINGS. +export interface CampaignSettings { whisper_model: string; whisper_compute_type: string; hf_token: string; @@ -100,11 +101,11 @@ export const campaignApi = { export const api = { // Settings - getSettings: async (): Promise => { + getSettings: async (): Promise => { const res = await fetch(`${BASE_URL}/settings`); return res.json(); }, - updateSettings: async (settings: Partial): Promise => { + updateSettings: async (settings: Partial): Promise => { const res = await fetch(`${BASE_URL}/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -112,9 +113,21 @@ export const api = { }); return res.json(); }, - // kept as an alias since some pages call it by this name - saveSettings: async (settings: Partial): Promise => { - return api.updateSettings(settings); + + // Campaign settings + getCampaignSettings: async (campaignId: string): Promise => { + const res = await fetch(`${BASE_URL}/campaigns/${encodeURIComponent(campaignId)}/settings`); + if (!res.ok) throw new Error('Failed to get campaign settings'); + return res.json(); + }, + updateCampaignSettings: async (campaignId: string, settings: Partial): Promise => { + const res = await fetch(`${BASE_URL}/campaigns/${encodeURIComponent(campaignId)}/settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + if (!res.ok) throw new Error('Failed to update campaign settings'); + return res.json(); }, // Sessions diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 30c7d3d..0c9f853 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,27 +1,35 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useCampaign } from "../contexts/CampaignContext"; export default function Settings() { + const { currentCampaign } = useCampaign(); const [settings, setSettings] = useState(null); const [saved, setSaved] = useState(false); - useEffect(() => { - api.getSettings().then(setSettings); - }, []); + const campaignId = currentCampaign?.id; - if (!settings) return null; + useEffect(() => { + if (!campaignId) return; + api.getCampaignSettings(campaignId).then(setSettings); + }, [campaignId]); + + if (!settings || !campaignId) return null; const set = (k: string, v: any) => setSettings({ ...settings, [k]: v }); const save = async () => { - await api.updateSettings(settings); + await api.updateCampaignSettings(campaignId, settings); setSaved(true); setTimeout(() => setSaved(false), 2000); }; return (
-

Settings

+
+

Settings

+ for {currentCampaign?.name} +
diff --git a/frontend/src/pages/Setup.tsx b/frontend/src/pages/Setup.tsx index f0aea27..84818b7 100644 --- a/frontend/src/pages/Setup.tsx +++ b/frontend/src/pages/Setup.tsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react'; -import { api, AppSettings } from '../api'; +import { api } from '../api'; -const DEFAULT_SETTINGS: AppSettings = { - onboarding_completed: false, +const DEFAULT_SETTINGS: Record = { whisper_model: 'medium', whisper_compute_type: 'int8', hf_token: '', @@ -21,23 +20,24 @@ const DEFAULT_SETTINGS: AppSettings = { export default function Setup({ onComplete }: { onComplete: () => void }) { const [step, setStep] = useState(1); - const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [settings, setSettings] = useState>(DEFAULT_SETTINGS); + // On first load, try to fetch existing settings from the "default" campaign + // (which is always created by init_db). useEffect(() => { - api.getSettings().then(data => { - if (data) { - setSettings({ ...DEFAULT_SETTINGS, ...data }); - } - }).catch(err => { - console.error('Failed to connect to settings endpoint:', err); - }); + api.getCampaignSettings('default') + .then(data => { + if (data) setSettings(prev => ({ ...prev, ...data })); + }) + .catch(() => {}); }, []); const handleNext = () => setStep(prev => prev + 1); const handleBack = () => setStep(prev => prev - 1); const handleSave = async () => { - await api.saveSettings({ ...settings, onboarding_completed: true }); + await api.updateCampaignSettings('default', settings); + await api.updateSettings({ onboarding_completed: true }); onComplete(); }; @@ -195,7 +195,7 @@ export default function Setup({ onComplete }: { onComplete: () => void }) {

Player recap style