import React, { createContext, useContext, useState, useEffect } from 'react'; import * as LucideIcons from 'lucide-react'; const AppConfigContext = createContext(null); /** * Resolve a Lucide icon name string (e.g. "BookOpen") to the actual React * component. Falls back to HelpCircle if the name isn't found. */ const resolveIcon = (iconName) => { if (!iconName) return LucideIcons.User; return LucideIcons[iconName] || LucideIcons.User; }; /** * Build the advisors lookup object (keyed by persona id) from the config * personas array, mirroring the shape that components already expect. */ const buildAdvisors = (personaItems, overrides = {}) => { if (!personaItems || !Array.isArray(personaItems)) return {}; const advisors = {}; for (const p of personaItems) { const image = p.image || ''; const isIcon = image.startsWith('icon://'); const rawImageUrl = isIcon ? null : image || null; const configImageUrl = rawImageUrl && rawImageUrl.startsWith('/') ? `${process.env.REACT_APP_API_URL}${rawImageUrl}` : rawImageUrl; // Override takes precedence if the advisor has one set. // Override = truthy URL → use it. Override = '' → force default icon. // No override key → fall back to config image. const hasOverride = Object.prototype.hasOwnProperty.call(overrides, p.id); const overrideValue = overrides[p.id]; const avatarUrl = hasOverride ? (overrideValue || null) : configImageUrl; advisors[p.id] = { name: p.name, role: p.role || '', description: p.summary || '', color: p.color || '#6B7280', bgColor: p.bg_color || '#F3F4F6', darkColor: p.dark_color || '#9CA3AF', darkBgColor: p.dark_bg_color || '#374151', icon: resolveIcon(isIcon ? image.replace('icon://', '') : null), avatarUrl, }; } return advisors; }; /** * Derive theme-appropriate colors for a given advisor, identical to the * previous `getAdvisorColors` helper. */ const buildGetAdvisorColors = (advisors) => (advisorId, isDark = false) => { const advisor = advisors[advisorId]; if (!advisor) { return isDark ? { color: '#9CA3AF', bgColor: '#374151', textColor: '#F9FAFB' } : { color: '#6B7280', bgColor: '#F3F4F6', textColor: '#111827' }; } return { color: isDark ? advisor.darkColor : advisor.color, bgColor: isDark ? advisor.darkBgColor : advisor.bgColor, textColor: isDark ? '#F9FAFB' : advisor.color, }; }; export const useAppConfig = () => { const ctx = useContext(AppConfigContext); if (!ctx) { throw new Error('useAppConfig must be used within an AppConfigProvider'); } return ctx; }; export const AppConfigProvider = ({ children }) => { const [config, setConfig] = useState(null); const [personaItems, setPersonaItems] = useState([]); const [advisors, setAdvisors] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [avatarOverrides, setAvatarOverrides] = useState(() => { try { return JSON.parse(localStorage.getItem('advisorAvatarOverrides') || '{}'); } catch { return {}; } }); const [myCustomAvatars, setMyCustomAvatars] = useState(() => { try { return JSON.parse(localStorage.getItem('myCustomAvatars') || '[]'); } catch { return []; } }); useEffect(() => { const fetchConfig = async () => { try { const response = await fetch(`${process.env.REACT_APP_API_URL}/api/config`); if (!response.ok) throw new Error(`Config fetch failed: ${response.status}`); const data = await response.json(); setConfig(data); setPersonaItems(data.personas?.items || []); } catch (err) { console.error('Failed to load app config:', err); setError(err.message); } finally { setLoading(false); } }; fetchConfig(); }, []); useEffect(() => { setAdvisors(buildAdvisors(personaItems, avatarOverrides)); }, [personaItems, avatarOverrides]); const setAdvisorAvatar = (advisorId, url) => { const next = { ...avatarOverrides, [advisorId]: url }; setAvatarOverrides(next); localStorage.setItem('advisorAvatarOverrides', JSON.stringify(next)); }; const addMyAvatar = (url) => { if (myCustomAvatars.includes(url)) return; const next = [url, ...myCustomAvatars]; setMyCustomAvatars(next); localStorage.setItem('myCustomAvatars', JSON.stringify(next)); }; // Sync brand primary from config. Light theme uses the config color; // dark theme keeps CSS-defined lighter teals for contrast on green-tinted surfaces. useEffect(() => { if (config?.app?.title) { document.title = config.app.title; } const applyBrand = () => { if (!config?.app?.primary_color) return; const primary = config.app.primary_color; const root = document.documentElement.style; const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; if (isDark) { root.removeProperty('--accent-primary'); root.removeProperty('--accent-secondary'); root.removeProperty('--accent-gradient'); root.removeProperty('--feature-icon-color'); root.removeProperty('--input-focus'); root.removeProperty('--input-focus-shadow'); return; } root.setProperty('--accent-primary', primary); root.setProperty('--feature-icon-color', primary); root.setProperty('--input-focus', primary); root.setProperty('--accent-secondary', '#047857'); root.setProperty('--accent-gradient', `linear-gradient(135deg, ${primary}, #059669)`); root.setProperty('--input-focus-shadow', '0 0 0 3px rgba(15, 118, 110, 0.15)'); }; applyBrand(); const observer = new MutationObserver(applyBrand); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'], }); return () => observer.disconnect(); }, [config]); const getAdvisorColors = buildGetAdvisorColors(advisors); const allPersonas = advisors; const getAllPersonaColors = getAdvisorColors; const value = { config, advisors, allPersonas, getAdvisorColors, getAllPersonaColors, resolveIcon, loading, error, setAdvisorAvatar, addMyAvatar, myCustomAvatars, }; if (loading) { return (
Loading configuration…
); } if (error && !config) { return (

Failed to load application configuration.

{error}

); } return ( {children} ); }; export default AppConfigContext;