Spaces:
Sleeping
Sleeping
| 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)); | |
| }; | |
| // Inject the primary colour as a CSS custom property on <html> so it is | |
| // available everywhere without prop-drilling. | |
| useEffect(() => { | |
| if (config?.app?.primary_color) { | |
| document.documentElement.style.setProperty( | |
| '--accent-primary', | |
| config.app.primary_color | |
| ); | |
| } | |
| // Also update the <title> tag dynamically | |
| if (config?.app?.title) { | |
| document.title = config.app.title; | |
| } | |
| }, [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 ( | |
| <div style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| height: '100vh', | |
| fontFamily: 'system-ui, sans-serif', | |
| color: '#6B7280', | |
| }}> | |
| Loading configuration… | |
| </div> | |
| ); | |
| } | |
| if (error && !config) { | |
| return ( | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| height: '100vh', | |
| fontFamily: 'system-ui, sans-serif', | |
| color: '#EF4444', | |
| gap: '8px', | |
| }}> | |
| <p>Failed to load application configuration.</p> | |
| <p style={{ fontSize: '14px', color: '#6B7280' }}>{error}</p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <AppConfigContext.Provider value={value}> | |
| {children} | |
| </AppConfigContext.Provider> | |
| ); | |
| }; | |
| export default AppConfigContext; | |