Files
rosterchirp/frontend/src/components/BrandingModal.jsx

334 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
const DEFAULT_TITLE_COLOR = '#1a73e8';
const DEFAULT_PUBLIC_COLOR = '#1a73e8';
const DEFAULT_DM_COLOR = '#a142f4';
const COLOUR_SUGGESTIONS = [
'#1a73e8', '#a142f4', '#e53935', '#fa7b17', '#fdd835', '#34a853',
];
// A single colour picker card: shows suggestions by default, Custom button opens native picker
function ColourPicker({ label, value, onChange, preview }) {
const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom'
const inputRef = useRef(null);
// Auto-close custom mode as soon as a new colour is committed
const handleCustomChange = (e) => {
onChange(e.target.value);
// 'change' fires on close of native picker on most platforms;
// we also listen to 'input' for live preview but only close on 'change'
};
const handleCustomInput = (e) => {
onChange(e.target.value);
};
const handleCustomChangeClose = (e) => {
onChange(e.target.value);
setMode('suggestions');
};
return (
<div>
<div className="settings-section-label">{label}</div>
{/* Current colour preview */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
{preview
? preview(value)
: <div style={{ width: 36, height: 36, borderRadius: 8, background: value, border: '2px solid var(--border)', flexShrink: 0 }} />
}
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{value}</span>
</div>
{mode === 'suggestions' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{COLOUR_SUGGESTIONS.map(hex => (
<button
key={hex}
onClick={() => onChange(hex)}
style={{
width: 32, height: 32, borderRadius: 8,
background: hex, border: hex === value ? '3px solid var(--text-primary)' : '2px solid var(--border)',
cursor: 'pointer', flexShrink: 0,
boxShadow: hex === value ? '0 0 0 2px var(--surface), 0 0 0 4px var(--text-primary)' : 'none',
transition: 'box-shadow 0.15s',
}}
title={hex}
/>
))}
</div>
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>
Custom
</button>
</>
)}
{mode === 'custom' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Colour input rendered as a large clickable swatch — works on mobile and desktop */}
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
<span style={{
display: 'inline-block', width: 56, height: 44,
borderRadius: 8, background: value,
border: '2px solid var(--border)',
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
flexShrink: 0,
}} />
<input
ref={inputRef}
type="color"
value={value}
onInput={handleCustomInput}
onChange={handleCustomChangeClose}
style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{value}</span>
</label>
<div>
<button className="btn btn-secondary btn-sm" onClick={() => setMode('suggestions')}>
Back
</button>
</div>
</div>
)}
</div>
);
}
export default function BrandingModal({ onClose }) {
const toast = useToast();
const [tab, setTab] = useState('general'); // 'general' | 'colours'
const [settings, setSettings] = useState({});
const [appName, setAppName] = useState('');
const [loading, setLoading] = useState(false);
const [resetting, setResetting] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [colourTitle, setColourTitle] = useState(DEFAULT_TITLE_COLOR);
const [colourPublic, setColourPublic] = useState(DEFAULT_PUBLIC_COLOR);
const [colourDm, setColourDm] = useState(DEFAULT_DM_COLOR);
const [savingColours, setSavingColours] = useState(false);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setSettings(settings);
setAppName(settings.app_name || 'jama');
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
setColourDm(settings.color_avatar_dm || DEFAULT_DM_COLOR);
}).catch(() => {});
}, []);
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
const handleSaveName = async () => {
if (!appName.trim()) return;
setLoading(true);
try {
await api.updateAppName(appName.trim());
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
toast('App name updated', 'success');
notifySidebarRefresh();
} catch (e) {
toast(e.message, 'error');
} finally {
setLoading(false);
}
};
const handleLogoUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
try {
const { logoUrl } = await api.uploadLogo(file);
setSettings(prev => ({ ...prev, logo_url: logoUrl }));
toast('Logo updated', 'success');
notifySidebarRefresh();
} catch (e) {
toast(e.message, 'error');
}
};
const handleSaveColours = async () => {
setSavingColours(true);
try {
await api.updateColors({
colorTitle: colourTitle,
colorAvatarPublic: colourPublic,
colorAvatarDm: colourDm,
});
setSettings(prev => ({
...prev,
color_title: colourTitle,
color_avatar_public: colourPublic,
color_avatar_dm: colourDm,
}));
toast('Colours updated', 'success');
notifySidebarRefresh();
} catch (e) {
toast(e.message, 'error');
} finally {
setSavingColours(false);
}
};
const handleReset = async () => {
setResetting(true);
try {
await api.resetSettings();
const { settings: fresh } = await api.getSettings();
setSettings(fresh);
setAppName(fresh.app_name || 'jama');
setColourTitle(DEFAULT_TITLE_COLOR);
setColourPublic(DEFAULT_PUBLIC_COLOR);
setColourDm(DEFAULT_DM_COLOR);
toast('Settings reset to defaults', 'success');
notifySidebarRefresh();
setShowResetConfirm(false);
} catch (e) {
toast(e.message, 'error');
} finally {
setResetting(false);
}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 460 }}>
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Branding</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* Tabs */}
<div className="flex gap-2" style={{ marginBottom: 24 }}>
<button className={`btn btn-sm ${tab === 'general' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('general')}>General</button>
<button className={`btn btn-sm ${tab === 'colours' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('colours')}>Colours</button>
</div>
{tab === 'general' && (
<>
{/* App Logo */}
<div style={{ marginBottom: 24 }}>
<div className="settings-section-label">App Logo</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: 72, height: 72, borderRadius: 16, background: 'var(--background)',
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}>
<img src={settings.logo_url || '/icons/jama.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
</div>
<div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
Upload Logo
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
</label>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
Square format, max 1MB. Used in sidebar, login page and browser tab.
</p>
</div>
</div>
</div>
{/* App Name */}
<div style={{ marginBottom: 24 }}>
<div className="settings-section-label">App Name</div>
<div style={{ display: 'flex', gap: 8 }}>
<input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>{loading ? '...' : 'Save'}</button>
</div>
</div>
{/* Reset */}
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
<div className="settings-section-label">Reset</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
{!showResetConfirm ? (
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>Reset All to Defaults</button>
) : (
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '12px 14px' }}>
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
This will reset the app name, logo and all colours to their install defaults. This cannot be undone.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
</div>
</div>
)}
{settings.app_version && (
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>v{settings.app_version}</span>
)}
</div>
</div>
{settings.pw_reset_active === 'true' && (
<div className="warning-banner">
<span></span>
<span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
</div>
)}
</>
)}
{tab === 'colours' && (
<div className="flex-col gap-3">
<ColourPicker
label="App Title Colour"
value={colourTitle}
onChange={setColourTitle}
/>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<ColourPicker
label="Public Message Avatar Colour"
value={colourPublic}
onChange={setColourPublic}
preview={(val) => (
<div style={{
width: 36, height: 36, borderRadius: '50%', background: val,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
}}>A</div>
)}
/>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<ColourPicker
label="Direct Message Avatar Colour"
value={colourDm}
onChange={setColourDm}
preview={(val) => (
<div style={{
width: 36, height: 36, borderRadius: '50%', background: val,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
}}>B</div>
)}
/>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<button className="btn btn-primary" onClick={handleSaveColours} disabled={savingColours}>
{savingColours ? 'Saving...' : 'Save Colours'}
</button>
</div>
</div>
)}
</div>
</div>
);
}