Merge from dev to loaf-prod for DEMO #25
@@ -25,6 +25,7 @@ import AdminPermissions from './pages/admin/AdminPermissions';
|
|||||||
import AdminSettings from './pages/admin/AdminSettings';
|
import AdminSettings from './pages/admin/AdminSettings';
|
||||||
import AdminMemberTiers from './pages/admin/AdminMemberTiers';
|
import AdminMemberTiers from './pages/admin/AdminMemberTiers';
|
||||||
import AdminRoles from './pages/admin/AdminRoles';
|
import AdminRoles from './pages/admin/AdminRoles';
|
||||||
|
import AdminTheme from './pages/admin/AdminTheme';
|
||||||
import AdminEvents from './pages/admin/AdminEvents';
|
import AdminEvents from './pages/admin/AdminEvents';
|
||||||
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
||||||
import AdminValidations from './pages/admin/AdminValidations';
|
import AdminValidations from './pages/admin/AdminValidations';
|
||||||
@@ -302,6 +303,7 @@ function App() {
|
|||||||
<Route path="stripe" element={<AdminSettings />} />
|
<Route path="stripe" element={<AdminSettings />} />
|
||||||
<Route path="permissions" element={<AdminRoles />} />
|
<Route path="permissions" element={<AdminRoles />} />
|
||||||
<Route path="member-tiers" element={<AdminMemberTiers />} />
|
<Route path="member-tiers" element={<AdminMemberTiers />} />
|
||||||
|
<Route path="theme" element={<AdminTheme />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* 404 - Catch all undefined routes */}
|
{/* 404 - Catch all undefined routes */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { getLogoUrl } = useThemeConfig();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
const [storageUsed, setStorageUsed] = useState(0);
|
const [storageUsed, setStorageUsed] = useState(0);
|
||||||
@@ -281,7 +283,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
|
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
|
||||||
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
||||||
<img
|
<img
|
||||||
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
src={getLogoUrl()}
|
||||||
alt="LOAF Logo"
|
alt="LOAF Logo"
|
||||||
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ChevronDown, Menu, X } from 'lucide-react';
|
import { ChevronDown, Menu, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -12,11 +13,12 @@ import {
|
|||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { getLogoUrl } = useThemeConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
// LOAF logo (local)
|
// Get logo URL from theme config (with fallback to default)
|
||||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
const loafLogo = getLogoUrl();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||||
|
|
||||||
const PublicFooter = () => {
|
const PublicFooter = () => {
|
||||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
const { getLogoUrl } = useThemeConfig();
|
||||||
|
const loafLogo = getLogoUrl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||||
import { ChevronDown, Menu, X } from 'lucide-react';
|
import { ChevronDown, Menu, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
|
|
||||||
const PublicNavbar = () => {
|
const PublicNavbar = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { getLogoUrl } = useThemeConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
@@ -30,8 +32,8 @@ const PublicNavbar = () => {
|
|||||||
return location.pathname.startsWith('/about');
|
return location.pathname.startsWith('/about');
|
||||||
};
|
};
|
||||||
|
|
||||||
// LOAF logo (local)
|
// Get logo URL from theme config (with fallback to default)
|
||||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
const loafLogo = getLogoUrl();
|
||||||
|
|
||||||
const handleAuthAction = () => {
|
const handleAuthAction = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -1,46 +1,45 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { CreditCard, Shield, Settings, Star } from 'lucide-react';
|
import { CreditCard, Shield, Star, Palette } from 'lucide-react';
|
||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ label: 'Stripe Integration', path: '/admin/settings/stripe', icon: CreditCard },
|
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
|
||||||
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
|
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
|
||||||
{ label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star },
|
{ label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star },
|
||||||
|
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SettingsSidebar = () => {
|
const SettingsTabs = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-full lg:w-64 shrink-0 bg-background border border-[var(--neutral-800)] rounded-2xl p-4 lg:sticky lg:top-8 h-max">
|
<div className="w-full border-b border-border">
|
||||||
<div className="flex items-center gap-2 px-2 pb-3 border-b border-[var(--neutral-800)]">
|
<nav className="flex gap-1 overflow-x-auto pb-px -mb-px" aria-label="Settings tabs">
|
||||||
<Settings className="h-4 w-4 text-[var(--purple-ink)]" />
|
|
||||||
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Settings
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<nav className="mt-3 space-y-1">
|
|
||||||
{settingsItems.map((item) => {
|
{settingsItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname === item.path;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.label}
|
key={item.label}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
className={`
|
||||||
[
|
flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap
|
||||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
border-b-2 transition-all duration-200
|
||||||
isActive
|
${isActive
|
||||||
? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]'
|
? 'border-primary text-primary bg-primary/5'
|
||||||
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/10',
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
].join(' ')
|
|
||||||
}
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className={`h-4 w-4 ${isActive ? 'text-primary' : ''}`} />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsSidebar;
|
export default SettingsTabs;
|
||||||
|
|||||||
161
src/context/ThemeConfigContext.js
Normal file
161
src/context/ThemeConfigContext.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const ThemeConfigContext = createContext();
|
||||||
|
|
||||||
|
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
|
||||||
|
|
||||||
|
const DEFAULT_THEME = {
|
||||||
|
site_name: 'LOAF - Lesbians Over Age Fifty',
|
||||||
|
site_short_name: 'LOAF',
|
||||||
|
site_description: 'A community organization for lesbians over age fifty in Houston and surrounding areas.',
|
||||||
|
logo_url: null,
|
||||||
|
favicon_url: null,
|
||||||
|
colors: {
|
||||||
|
primary: '280 47% 27%',
|
||||||
|
primary_foreground: '0 0% 100%',
|
||||||
|
accent: '24 86% 55%',
|
||||||
|
brand_purple: '256 35% 47%',
|
||||||
|
brand_orange: '24 86% 55%',
|
||||||
|
brand_lavender: '262 46% 80%'
|
||||||
|
},
|
||||||
|
meta_theme_color: '#664fa3'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeConfigProvider = ({ children }) => {
|
||||||
|
const [themeConfig, setThemeConfig] = useState(DEFAULT_THEME);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const applyThemeToDOM = useCallback((config) => {
|
||||||
|
// Apply CSS variables for colors
|
||||||
|
if (config.colors) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
Object.entries(config.colors).forEach(([key, value]) => {
|
||||||
|
// Convert snake_case to kebab-case for CSS variable names
|
||||||
|
const cssVarName = `--${key.replace(/_/g, '-')}`;
|
||||||
|
root.style.setProperty(cssVarName, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update favicon
|
||||||
|
if (config.favicon_url) {
|
||||||
|
let link = document.querySelector("link[rel*='icon']");
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.href = config.favicon_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document title
|
||||||
|
if (config.site_name) {
|
||||||
|
document.title = config.site_name;
|
||||||
|
// Also store for use by pages that want to append their own title
|
||||||
|
window.__SITE_NAME__ = config.site_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update meta description
|
||||||
|
if (config.site_description) {
|
||||||
|
let metaDesc = document.querySelector("meta[name='description']");
|
||||||
|
if (!metaDesc) {
|
||||||
|
metaDesc = document.createElement('meta');
|
||||||
|
metaDesc.name = 'description';
|
||||||
|
document.head.appendChild(metaDesc);
|
||||||
|
}
|
||||||
|
metaDesc.content = config.site_description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update meta theme-color for PWA
|
||||||
|
if (config.meta_theme_color) {
|
||||||
|
let meta = document.querySelector("meta[name='theme-color']");
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.name = 'theme-color';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
meta.content = config.meta_theme_color;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchThemeConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_URL}/api/config/theme`);
|
||||||
|
const config = { ...DEFAULT_THEME, ...response.data };
|
||||||
|
|
||||||
|
// Merge colors if provided
|
||||||
|
if (response.data.colors) {
|
||||||
|
config.colors = { ...DEFAULT_THEME.colors, ...response.data.colors };
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeConfig(config);
|
||||||
|
applyThemeToDOM(config);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch theme config, using defaults:', err.message);
|
||||||
|
setError(err.message);
|
||||||
|
// Apply default theme to DOM
|
||||||
|
applyThemeToDOM(DEFAULT_THEME);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [applyThemeToDOM]);
|
||||||
|
|
||||||
|
// Fetch theme config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchThemeConfig();
|
||||||
|
}, [fetchThemeConfig]);
|
||||||
|
|
||||||
|
// Helper function to get logo URL with fallback
|
||||||
|
const getLogoUrl = useCallback(() => {
|
||||||
|
return themeConfig.logo_url || `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||||
|
}, [themeConfig.logo_url]);
|
||||||
|
|
||||||
|
// Helper function to get favicon URL with fallback
|
||||||
|
const getFaviconUrl = useCallback(() => {
|
||||||
|
return themeConfig.favicon_url || `${process.env.PUBLIC_URL}/favicon.ico`;
|
||||||
|
}, [themeConfig.favicon_url]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
// Theme configuration
|
||||||
|
themeConfig,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Convenience accessors
|
||||||
|
siteName: themeConfig.site_name,
|
||||||
|
siteShortName: themeConfig.site_short_name,
|
||||||
|
siteDescription: themeConfig.site_description,
|
||||||
|
colors: themeConfig.colors,
|
||||||
|
metaThemeColor: themeConfig.meta_theme_color,
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
getLogoUrl,
|
||||||
|
getFaviconUrl,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
refreshTheme: fetchThemeConfig,
|
||||||
|
|
||||||
|
// Default theme for reference
|
||||||
|
DEFAULT_THEME
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeConfigContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeConfigContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useThemeConfig = () => {
|
||||||
|
const context = useContext(ThemeConfigContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useThemeConfig must be used within a ThemeConfigProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeConfigContext;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { ThemeProvider } from 'next-themes';
|
import { ThemeProvider } from 'next-themes';
|
||||||
|
import { ThemeConfigProvider } from './context/ThemeConfigContext';
|
||||||
import '@fontsource/fraunces/600.css';
|
import '@fontsource/fraunces/600.css';
|
||||||
import '@fontsource/dm-sans/400.css';
|
import '@fontsource/dm-sans/400.css';
|
||||||
import '@fontsource/dm-sans/700.css';
|
import '@fontsource/dm-sans/700.css';
|
||||||
@@ -16,7 +17,9 @@ root.render(
|
|||||||
enableSystem={false}
|
enableSystem={false}
|
||||||
storageKey="admin-theme"
|
storageKey="admin-theme"
|
||||||
>
|
>
|
||||||
|
<ThemeConfigProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ThemeConfigProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import SettingsSidebar from '../components/SettingsSidebar';
|
import SettingsTabs from '../components/SettingsSidebar';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
|
||||||
const SettingsLayout = () => {
|
const SettingsLayout = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 lg:flex-row">
|
<div className="space-y-6">
|
||||||
<SettingsSidebar />
|
{/* Header */}
|
||||||
<div className="flex-1 min-w-0">
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Settings className="h-6 w-6" />
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage your platform configuration and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Navigation */}
|
||||||
|
<SettingsTabs />
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="min-w-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import React from 'react';
|
|||||||
import PublicNavbar from '../components/PublicNavbar';
|
import PublicNavbar from '../components/PublicNavbar';
|
||||||
import PublicFooter from '../components/PublicFooter';
|
import PublicFooter from '../components/PublicFooter';
|
||||||
import { Card } from '../components/ui/card';
|
import { Card } from '../components/ui/card';
|
||||||
|
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||||
|
|
||||||
const MissionValues = () => {
|
const MissionValues = () => {
|
||||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
const { getLogoUrl } = useThemeConfig();
|
||||||
|
const loafLogo = getLogoUrl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
|||||||
@@ -6,14 +6,9 @@ import { DEFAULT_MEMBER_TIERS } from '../../config/MemberTiers';
|
|||||||
const AdminMemberTiers = () => {
|
const AdminMemberTiers = () => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<p className="text-muted-foreground">
|
||||||
<h1 className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Member Tiers
|
|
||||||
</h1>
|
|
||||||
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Configure tier names, time ranges, and badges used in the members directory.
|
Configure tier names, time ranges, and badges used in the members directory.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -205,16 +205,13 @@ const AdminRoles = () => {
|
|||||||
const groupedPermissions = groupPermissionsByModule();
|
const groupedPermissions = groupPermissionsByModule();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 ">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Action Bar */}
|
||||||
<div className="flex justify-between items-center ">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<p className="text-muted-foreground">
|
||||||
<h1 className="text-3xl font-bold text-[var(--purple-ink)]">Role Management</h1>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Create and manage custom roles with specific permissions
|
Create and manage custom roles with specific permissions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<Button className="btn-lavender" onClick={() => setShowCreateModal(true)}>
|
||||||
<Button className="btn-lavender " onClick={() => setShowCreateModal(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Role
|
Create Role
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -126,18 +126,7 @@ export default function AdminSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 max-w-4xl">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
|
||||||
<SettingsIcon className="h-8 w-8 text-brand-purple" />
|
|
||||||
Settings
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-2">
|
|
||||||
Manage system configuration and integrations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stripe Integration Card */}
|
{/* Stripe Integration Card */}
|
||||||
<Card className="border-2 border-[var(--lavender-200)] shadow-sm">
|
<Card className="border-2 border-[var(--lavender-200)] shadow-sm">
|
||||||
<CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">
|
<CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">
|
||||||
|
|||||||
743
src/pages/admin/AdminTheme.js
Normal file
743
src/pages/admin/AdminTheme.js
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import api from '../../utils/api';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useThemeConfig } from '../../context/ThemeConfigContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Label } from '../../components/ui/label';
|
||||||
|
import { Textarea } from '../../components/ui/textarea';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '../../components/ui/alert-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Palette, Upload, Trash2, RotateCcw, Save, Image, Globe, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const AdminTheme = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { refreshTheme, DEFAULT_THEME } = useThemeConfig();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
const [themeData, setThemeData] = useState({
|
||||||
|
site_name: '',
|
||||||
|
site_short_name: '',
|
||||||
|
site_description: '',
|
||||||
|
logo_url: null,
|
||||||
|
favicon_url: null,
|
||||||
|
colors: {
|
||||||
|
primary: '280 47% 27%',
|
||||||
|
primary_foreground: '0 0% 100%',
|
||||||
|
accent: '24 86% 55%',
|
||||||
|
brand_purple: '256 35% 47%',
|
||||||
|
brand_orange: '24 86% 55%',
|
||||||
|
brand_lavender: '262 46% 80%'
|
||||||
|
},
|
||||||
|
meta_theme_color: '#664fa3'
|
||||||
|
});
|
||||||
|
const [originalData, setOriginalData] = useState(null);
|
||||||
|
const [metadata, setMetadata] = useState({
|
||||||
|
is_default: true,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSuperAdmin = user?.role === 'superadmin';
|
||||||
|
|
||||||
|
const fetchThemeSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.get('/admin/settings/theme');
|
||||||
|
const { config, is_default, updated_at, updated_by } = response.data;
|
||||||
|
|
||||||
|
setThemeData(config);
|
||||||
|
setOriginalData(config);
|
||||||
|
setMetadata({ is_default, updated_at, updated_by });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to fetch theme settings');
|
||||||
|
console.error('Fetch theme error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchThemeSettings();
|
||||||
|
}, [fetchThemeSettings]);
|
||||||
|
|
||||||
|
const handleInputChange = (field, value) => {
|
||||||
|
setThemeData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = (colorKey, value) => {
|
||||||
|
setThemeData(prev => ({
|
||||||
|
...prev,
|
||||||
|
colors: {
|
||||||
|
...prev.colors,
|
||||||
|
[colorKey]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Build update payload with only changed fields
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
if (themeData.site_name !== originalData?.site_name) {
|
||||||
|
payload.site_name = themeData.site_name;
|
||||||
|
}
|
||||||
|
if (themeData.site_short_name !== originalData?.site_short_name) {
|
||||||
|
payload.site_short_name = themeData.site_short_name;
|
||||||
|
}
|
||||||
|
if (themeData.site_description !== originalData?.site_description) {
|
||||||
|
payload.site_description = themeData.site_description;
|
||||||
|
}
|
||||||
|
if (JSON.stringify(themeData.colors) !== JSON.stringify(originalData?.colors)) {
|
||||||
|
payload.colors = themeData.colors;
|
||||||
|
}
|
||||||
|
if (themeData.meta_theme_color !== originalData?.meta_theme_color) {
|
||||||
|
payload.meta_theme_color = themeData.meta_theme_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
toast.info('No changes to save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put('/admin/settings/theme', payload);
|
||||||
|
|
||||||
|
toast.success('Theme settings saved successfully');
|
||||||
|
|
||||||
|
// Refresh theme context and re-fetch settings
|
||||||
|
await refreshTheme();
|
||||||
|
await fetchThemeSettings();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to save theme settings');
|
||||||
|
console.error('Save theme error:', error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
toast.error('Invalid file type. Please upload PNG, JPEG, WebP, or SVG.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error('File too large. Maximum size is 5MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploadingLogo(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await api.post('/admin/settings/theme/logo', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
setThemeData(prev => ({
|
||||||
|
...prev,
|
||||||
|
logo_url: response.data.logo_url
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success('Logo uploaded successfully');
|
||||||
|
await refreshTheme();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to upload logo');
|
||||||
|
console.error('Upload logo error:', error);
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
// Reset the input
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFaviconUpload = async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml'];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
toast.error('Invalid file type. Please upload ICO, PNG, or SVG.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (1MB)
|
||||||
|
if (file.size > 1 * 1024 * 1024) {
|
||||||
|
toast.error('File too large. Maximum size is 1MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploadingFavicon(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await api.post('/admin/settings/theme/favicon', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
setThemeData(prev => ({
|
||||||
|
...prev,
|
||||||
|
favicon_url: response.data.favicon_url
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success('Favicon uploaded successfully');
|
||||||
|
await refreshTheme();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to upload favicon');
|
||||||
|
console.error('Upload favicon error:', error);
|
||||||
|
} finally {
|
||||||
|
setUploadingFavicon(false);
|
||||||
|
// Reset the input
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLogo = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete('/admin/settings/theme/logo');
|
||||||
|
|
||||||
|
setThemeData(prev => ({
|
||||||
|
...prev,
|
||||||
|
logo_url: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success('Logo deleted successfully');
|
||||||
|
await refreshTheme();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to delete logo');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFavicon = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete('/admin/settings/theme/favicon');
|
||||||
|
|
||||||
|
setThemeData(prev => ({
|
||||||
|
...prev,
|
||||||
|
favicon_url: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success('Favicon deleted successfully');
|
||||||
|
await refreshTheme();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to delete favicon');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/admin/settings/theme/reset');
|
||||||
|
|
||||||
|
toast.success('Theme reset to defaults');
|
||||||
|
setShowResetDialog(false);
|
||||||
|
|
||||||
|
await refreshTheme();
|
||||||
|
await fetchThemeSettings();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to reset theme');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert HSL string to approximate hex for color picker
|
||||||
|
const hslToHex = (hslString) => {
|
||||||
|
if (!hslString) return '#000000';
|
||||||
|
|
||||||
|
const parts = hslString.split(' ');
|
||||||
|
if (parts.length !== 3) return '#000000';
|
||||||
|
|
||||||
|
const h = parseFloat(parts[0]) / 360;
|
||||||
|
const s = parseFloat(parts[1]) / 100;
|
||||||
|
const l = parseFloat(parts[2]) / 100;
|
||||||
|
|
||||||
|
const hue2rgb = (p, q, t) => {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1/2) return q;
|
||||||
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
let r, g, b;
|
||||||
|
if (s === 0) {
|
||||||
|
r = g = b = l;
|
||||||
|
} else {
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
r = hue2rgb(p, q, h + 1/3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1/3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toHex = (x) => {
|
||||||
|
const hex = Math.round(x * 255).toString(16);
|
||||||
|
return hex.length === 1 ? '0' + hex : hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert hex to HSL string
|
||||||
|
const hexToHsl = (hex) => {
|
||||||
|
if (!hex) return '0 0% 0%';
|
||||||
|
|
||||||
|
// Remove # if present
|
||||||
|
hex = hex.replace('#', '');
|
||||||
|
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
let h, s, l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max === min) {
|
||||||
|
h = s = 0;
|
||||||
|
} else {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||||
|
case g: h = ((b - r) / d + 2) / 6; break;
|
||||||
|
case b: h = ((r - g) / d + 4) / 6; break;
|
||||||
|
default: h = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFields = [
|
||||||
|
{ key: 'primary', label: 'Primary Color', description: 'Main brand color used for buttons and highlights' },
|
||||||
|
{ key: 'primary_foreground', label: 'Primary Foreground', description: 'Text color on primary backgrounds' },
|
||||||
|
{ key: 'accent', label: 'Accent Color', description: 'Secondary highlight color' },
|
||||||
|
{ key: 'brand_purple', label: 'Brand Purple', description: 'Purple brand color' },
|
||||||
|
{ key: 'brand_orange', label: 'Brand Orange', description: 'Orange brand color' },
|
||||||
|
{ key: 'brand_lavender', label: 'Brand Lavender', description: 'Lavender brand color' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Customize the appearance of your membership site
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowResetDialog(true)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSaveSettings} disabled={saving}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata Banner */}
|
||||||
|
{!metadata.is_default && metadata.updated_at && (
|
||||||
|
<div className="bg-muted/50 border rounded-lg px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
Last updated {new Date(metadata.updated_at).toLocaleDateString()}
|
||||||
|
{metadata.updated_by && ` by ${metadata.updated_by}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Branding Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
Branding
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure your site name and brand identity
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Site Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site_name">Site Name</Label>
|
||||||
|
<Input
|
||||||
|
id="site_name"
|
||||||
|
value={themeData.site_name}
|
||||||
|
onChange={(e) => handleInputChange('site_name', e.target.value)}
|
||||||
|
placeholder="LOAF - Lesbians Over Age Fifty"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Displayed in the browser title and navigation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Short Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site_short_name">Short Name</Label>
|
||||||
|
<Input
|
||||||
|
id="site_short_name"
|
||||||
|
value={themeData.site_short_name}
|
||||||
|
onChange={(e) => handleInputChange('site_short_name', e.target.value)}
|
||||||
|
placeholder="LOAF"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used for PWA home screen icon label
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site_description">Site Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="site_description"
|
||||||
|
value={themeData.site_description}
|
||||||
|
onChange={(e) => handleInputChange('site_description', e.target.value)}
|
||||||
|
placeholder="A community organization for lesbians over age fifty..."
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used for SEO meta description tag ({themeData.site_description?.length || 0}/500)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta Theme Color */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="meta_theme_color">Browser Theme Color</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="meta_theme_color"
|
||||||
|
value={themeData.meta_theme_color}
|
||||||
|
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
|
||||||
|
className="h-10 w-14 rounded border cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={themeData.meta_theme_color}
|
||||||
|
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
|
||||||
|
placeholder="#664fa3"
|
||||||
|
className="flex-1"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Color shown in mobile browser address bar (PWA)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Logo & Favicon Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Image className="h-5 w-5" />
|
||||||
|
Logo & Favicon
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Upload your organization's logo and favicon
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Logo Upload */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Logo</Label>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-24 h-24 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
|
||||||
|
{themeData.logo_url ? (
|
||||||
|
<img
|
||||||
|
src={themeData.logo_url}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image className="h-10 w-10 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
{uploadingLogo ? 'Uploading...' : 'Upload'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
{themeData.logo_url && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteLogo}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
PNG, JPEG, WebP, or SVG. Max 5MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Favicon Upload */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Favicon</Label>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-16 h-16 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
|
||||||
|
{themeData.favicon_url ? (
|
||||||
|
<img
|
||||||
|
src={themeData.favicon_url}
|
||||||
|
alt="Favicon"
|
||||||
|
className="w-8 h-8 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Globe className="h-6 w-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/x-icon,image/vnd.microsoft.icon,image/png,image/svg+xml"
|
||||||
|
onChange={handleFaviconUpload}
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploadingFavicon}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploadingFavicon}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
{uploadingFavicon ? 'Uploading...' : 'Upload'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
{themeData.favicon_url && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteFavicon}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
ICO, PNG, or SVG. Max 1MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Color Scheme Section */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Palette className="h-5 w-5" />
|
||||||
|
Color Scheme
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Customize the color palette used throughout the site. Colors are stored as HSL values.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{colorFields.map((field) => (
|
||||||
|
<div key={field.key} className="space-y-2">
|
||||||
|
<Label htmlFor={`color_${field.key}`}>{field.label}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id={`color_${field.key}`}
|
||||||
|
value={hslToHex(themeData.colors[field.key])}
|
||||||
|
onChange={(e) => handleColorChange(field.key, hexToHsl(e.target.value))}
|
||||||
|
className="h-10 w-14 rounded border cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={themeData.colors[field.key]}
|
||||||
|
onChange={(e) => handleColorChange(field.key, e.target.value)}
|
||||||
|
placeholder="280 47% 27%"
|
||||||
|
className="flex-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preview Section */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Preview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
See how your theme changes will look
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-6 space-y-4"
|
||||||
|
style={{
|
||||||
|
'--preview-primary': themeData.colors.primary,
|
||||||
|
'--preview-accent': themeData.colors.accent,
|
||||||
|
'--preview-brand-purple': themeData.colors.brand_purple,
|
||||||
|
'--preview-brand-orange': themeData.colors.brand_orange,
|
||||||
|
'--preview-brand-lavender': themeData.colors.brand_lavender,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{themeData.logo_url ? (
|
||||||
|
<img src={themeData.logo_url} alt="Preview Logo" className="h-16 w-16 object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="h-16 w-16 bg-muted rounded-lg flex items-center justify-center">
|
||||||
|
<Image className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold">{themeData.site_name || 'Site Name'}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{themeData.site_short_name || 'Short Name'}</p>
|
||||||
|
{themeData.site_description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 max-w-md">{themeData.site_description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-4">
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 rounded-lg text-white font-medium"
|
||||||
|
style={{ backgroundColor: `hsl(${themeData.colors.primary})` }}
|
||||||
|
>
|
||||||
|
Primary
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 rounded-lg text-white font-medium"
|
||||||
|
style={{ backgroundColor: `hsl(${themeData.colors.accent})` }}
|
||||||
|
>
|
||||||
|
Accent
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 rounded-lg text-white font-medium"
|
||||||
|
style={{ backgroundColor: `hsl(${themeData.colors.brand_purple})` }}
|
||||||
|
>
|
||||||
|
Brand Purple
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style={{ backgroundColor: `hsl(${themeData.colors.brand_orange})` }}
|
||||||
|
>
|
||||||
|
Brand Orange
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style={{ backgroundColor: `hsl(${themeData.colors.brand_lavender})` }}
|
||||||
|
>
|
||||||
|
Brand Lavender
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Reset Theme to Defaults?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete all custom theme settings including uploaded logo and favicon.
|
||||||
|
The site will revert to the default LOAF theme. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleResetToDefaults}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminTheme;
|
||||||
Reference in New Issue
Block a user