- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout, QR-based check-in scanner, public event pages, ticket confirmation emails - Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle, ticket-gated meeting access, moderator JWT tokens, feature-flag guarded - Social engagement: challenges with scoring/leaderboards, referral tracking, volunteer spotlight, impact stories, campaign celebrations, wall of fame - Social calendar: personal calendar layers, shared calendar items with recurrence, scheduling polls, mobile day view - MCP server: events tool pack with full admin CRUD + meeting token generation - Unified calendar: eventFormat-aware tags, online event indicators - Updated docs site, pangolin configs, and various admin UI improvements Bunker Admin
1266 lines
46 KiB
TypeScript
1266 lines
46 KiB
TypeScript
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { useOutletContext, useLocation } from 'react-router-dom';
|
|
import {
|
|
Typography,
|
|
Tabs,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Switch,
|
|
Button,
|
|
ColorPicker,
|
|
Alert,
|
|
Divider,
|
|
Space,
|
|
Segmented,
|
|
Tag,
|
|
Descriptions,
|
|
Card,
|
|
Collapse,
|
|
Row,
|
|
Col,
|
|
Slider,
|
|
Progress,
|
|
Steps,
|
|
Modal,
|
|
Checkbox,
|
|
Timeline,
|
|
message,
|
|
Spin,
|
|
} from 'antd';
|
|
import {
|
|
SettingOutlined,
|
|
SaveOutlined,
|
|
ThunderboltOutlined,
|
|
SendOutlined,
|
|
CheckCircleOutlined,
|
|
CloseCircleOutlined,
|
|
InfoCircleOutlined,
|
|
AppstoreOutlined,
|
|
PlaySquareOutlined,
|
|
MessageOutlined,
|
|
TeamOutlined,
|
|
DollarOutlined,
|
|
AlertOutlined,
|
|
MailOutlined,
|
|
RetweetOutlined,
|
|
PhoneOutlined,
|
|
CloudSyncOutlined,
|
|
SyncOutlined,
|
|
BranchesOutlined,
|
|
HistoryOutlined,
|
|
RocketOutlined,
|
|
ExclamationCircleOutlined,
|
|
LoadingOutlined,
|
|
ReloadOutlined,
|
|
} from '@ant-design/icons';
|
|
import { useSettingsStore } from '@/stores/settings.store';
|
|
import { api } from '@/lib/api';
|
|
import type { AppOutletContext } from '@/components/AppLayout';
|
|
import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult } from '@/types/api';
|
|
|
|
const { Text, Paragraph } = Typography;
|
|
|
|
export default function SettingsPage() {
|
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
|
const locationState = useLocation().state as { activeTab?: string } | null;
|
|
const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
|
|
const [form] = Form.useForm();
|
|
|
|
const [testingConnection, setTestingConnection] = useState(false);
|
|
const [connectionResult, setConnectionResult] = useState<SmtpTestResult | null>(null);
|
|
const [sendingTest, setSendingTest] = useState(false);
|
|
const [sendResult, setSendResult] = useState<SmtpSendTestResult | null>(null);
|
|
|
|
useEffect(() => {
|
|
setPageHeader({ title: 'Settings' });
|
|
return () => setPageHeader(null);
|
|
}, [setPageHeader]);
|
|
|
|
useEffect(() => {
|
|
fetchAdminSettings();
|
|
}, [fetchAdminSettings]);
|
|
|
|
useEffect(() => {
|
|
if (settings) {
|
|
form.setFieldsValue(settings);
|
|
}
|
|
}, [settings, form]);
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
const values = form.getFieldsValue();
|
|
|
|
// Convert ColorPicker values to hex strings
|
|
const colorFields = [
|
|
'adminColorPrimary',
|
|
'adminColorBgBase',
|
|
'publicColorPrimary',
|
|
'publicColorBgBase',
|
|
'publicColorBgContainer',
|
|
] as const;
|
|
|
|
for (const field of colorFields) {
|
|
const val = values[field];
|
|
if (val && typeof val === 'object' && 'toHexString' in val) {
|
|
values[field] = val.toHexString();
|
|
}
|
|
}
|
|
|
|
await updateSettings(values);
|
|
setConnectionResult(null);
|
|
setSendResult(null);
|
|
message.success('Settings saved successfully');
|
|
} catch {
|
|
message.error('Failed to save settings');
|
|
}
|
|
};
|
|
|
|
const handleTestConnection = async () => {
|
|
setTestingConnection(true);
|
|
setConnectionResult(null);
|
|
try {
|
|
const { data } = await api.post<SmtpTestResult>('/settings/email/test-connection');
|
|
setConnectionResult(data);
|
|
} catch {
|
|
setConnectionResult({ success: false, message: 'Request failed' });
|
|
} finally {
|
|
setTestingConnection(false);
|
|
}
|
|
};
|
|
|
|
const handleSendTest = async () => {
|
|
setSendingTest(true);
|
|
setSendResult(null);
|
|
try {
|
|
const to = form.getFieldValue('testEmailRecipient');
|
|
const { data } = await api.post<SmtpSendTestResult>('/settings/email/test-send', { to });
|
|
setSendResult(data);
|
|
} catch {
|
|
setSendResult({ success: false, testMode: false, recipient: '' });
|
|
} finally {
|
|
setSendingTest(false);
|
|
}
|
|
};
|
|
|
|
const handleProviderToggle = async (value: string | number) => {
|
|
const provider = value as 'mailhog' | 'production';
|
|
try {
|
|
await updateSettings({ smtpActiveProvider: provider });
|
|
form.setFieldsValue({ smtpActiveProvider: provider });
|
|
message.success(`Switched to ${provider === 'mailhog' ? 'MailHog' : 'Production'} SMTP`);
|
|
setConnectionResult(null);
|
|
setSendResult(null);
|
|
} catch {
|
|
message.error('Failed to switch SMTP provider');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
|
<Spin size="large" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const items = [
|
|
{
|
|
key: 'organization',
|
|
label: 'Organization',
|
|
icon: <SettingOutlined />,
|
|
children: (
|
|
<div style={{ maxWidth: 600 }}>
|
|
<Form.Item label="Organization Name" name="organizationName">
|
|
<Input placeholder="Changemaker Lite" />
|
|
</Form.Item>
|
|
<Form.Item label="Short Name" name="organizationShortName" extra="Shown when sidebar is collapsed (max 10 chars)">
|
|
<Input placeholder="CML" maxLength={10} />
|
|
</Form.Item>
|
|
<Form.Item label="Logo URL" name="organizationLogoUrl">
|
|
<Input placeholder="https://example.com/logo.png" />
|
|
</Form.Item>
|
|
<Form.Item label="Favicon URL" name="organizationFaviconUrl">
|
|
<Input placeholder="https://example.com/favicon.ico" />
|
|
</Form.Item>
|
|
<Form.Item label="Footer Text" name="footerText">
|
|
<Input placeholder="Powered by Changemaker Lite" />
|
|
</Form.Item>
|
|
<Form.Item label="Login Subtitle" name="loginSubtitle" extra="Shown below the organization name on the login page">
|
|
<Input placeholder="Admin" />
|
|
</Form.Item>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'theme',
|
|
label: 'Theme Colors',
|
|
children: (
|
|
<div style={{ maxWidth: 600 }}>
|
|
<Text strong style={{ fontSize: 15 }}>Admin Theme</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<Form.Item label="Primary Color" name="adminColorPrimary">
|
|
<ColorPicker format="hex" showText />
|
|
</Form.Item>
|
|
<Form.Item label="Background Color" name="adminColorBgBase">
|
|
<ColorPicker format="hex" showText />
|
|
</Form.Item>
|
|
|
|
<div style={{ marginTop: 24 }}>
|
|
<Text strong style={{ fontSize: 15 }}>Public Theme</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<Form.Item label="Primary Color" name="publicColorPrimary">
|
|
<ColorPicker format="hex" showText />
|
|
</Form.Item>
|
|
<Form.Item label="Background Color" name="publicColorBgBase">
|
|
<ColorPicker format="hex" showText />
|
|
</Form.Item>
|
|
<Form.Item label="Container Color" name="publicColorBgContainer">
|
|
<ColorPicker format="hex" showText />
|
|
</Form.Item>
|
|
<Form.Item label="Header Gradient" name="publicHeaderGradient">
|
|
<GradientPicker />
|
|
</Form.Item>
|
|
</div>
|
|
|
|
{settings && (
|
|
<div style={{ marginTop: 24 }}>
|
|
<Text strong style={{ fontSize: 15 }}>Preview</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
<Swatch label="Admin Primary" color={settings.adminColorPrimary} />
|
|
<Swatch label="Admin BG" color={settings.adminColorBgBase} />
|
|
<Swatch label="Public Primary" color={settings.publicColorPrimary} />
|
|
<Swatch label="Public BG" color={settings.publicColorBgBase} />
|
|
<Swatch label="Public Container" color={settings.publicColorBgContainer} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'email',
|
|
label: 'Email',
|
|
children: (() => {
|
|
const eff = settings?._effective;
|
|
const isMailhog = (settings?.smtpActiveProvider || 'mailhog') === 'mailhog';
|
|
|
|
return (
|
|
<div style={{ maxWidth: 600 }}>
|
|
{/* Current Configuration Summary */}
|
|
{eff && (
|
|
<Card size="small" style={{ marginBottom: 24 }}>
|
|
<Descriptions
|
|
title={
|
|
<Space>
|
|
<InfoCircleOutlined />
|
|
<span>Current Email Configuration</span>
|
|
</Space>
|
|
}
|
|
column={1}
|
|
size="small"
|
|
>
|
|
<Descriptions.Item label="Provider">
|
|
<Tag color={eff.provider === 'mailhog' ? 'orange' : 'green'}>
|
|
{eff.provider === 'mailhog' ? 'MailHog' : 'Production'}
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Server">
|
|
<Text code>{eff.host}:{eff.port}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="From">
|
|
<Text>{`"${eff.fromName}" <${eff.fromAddress}>`}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Authentication">
|
|
{eff.user ? (
|
|
<Text><CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />{eff.user}</Text>
|
|
) : (
|
|
<Text type="secondary">None</Text>
|
|
)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Test Mode">
|
|
{eff.testMode ? (
|
|
<Text><Tag color="orange">ON</Tag> redirecting to {eff.testRecipient}</Text>
|
|
) : (
|
|
<Tag color="green">OFF</Tag>
|
|
)}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Sender */}
|
|
<Text strong style={{ fontSize: 15 }}>Sender</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<Form.Item label="From Name" name="emailFromName" extra="The sender name used in outgoing emails">
|
|
<Input placeholder="Changemaker Lite" />
|
|
</Form.Item>
|
|
<Form.Item label="From Address" name="smtpFromAddress" extra="The sender email address">
|
|
<Input placeholder="noreply@cmlite.org" />
|
|
</Form.Item>
|
|
|
|
{/* Active SMTP Provider */}
|
|
<div style={{ marginTop: 24 }}>
|
|
<Text strong style={{ fontSize: 15 }}>Active SMTP Provider</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Segmented
|
|
value={settings?.smtpActiveProvider || 'mailhog'}
|
|
options={[
|
|
{ label: 'MailHog', value: 'mailhog' },
|
|
{ label: 'Production', value: 'production' },
|
|
]}
|
|
onChange={handleProviderToggle}
|
|
size="large"
|
|
/>
|
|
<Tag
|
|
color="green"
|
|
style={{ marginLeft: 12, verticalAlign: 'middle' }}
|
|
>
|
|
{isMailhog ? 'MailHog Active' : 'Production Active'}
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
|
|
{/* MailHog Info */}
|
|
{isMailhog && (
|
|
<div style={{ marginTop: 24 }}>
|
|
<Text strong style={{ fontSize: 15 }}>MailHog (Testing)</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.04)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)' }}>
|
|
<div><Text type="secondary">Host:</Text> <Text code>mailhog-changemaker</Text></div>
|
|
<div style={{ marginTop: 4 }}><Text type="secondary">Port:</Text> <Text code>1025</Text></div>
|
|
<div style={{ marginTop: 4 }}><Text type="secondary">Auth:</Text> <Text type="secondary">None required</Text></div>
|
|
<div style={{ marginTop: 4 }}><Text type="secondary">Web UI:</Text> <Text code>http://localhost:8025</Text></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Production SMTP */}
|
|
<div style={{ marginTop: 24, opacity: isMailhog ? 0.45 : 1, pointerEvents: isMailhog ? 'none' : 'auto' }}>
|
|
<Collapse
|
|
defaultActiveKey={!isMailhog ? ['smtp'] : []}
|
|
items={[{
|
|
key: 'smtp',
|
|
label: (
|
|
<Space>
|
|
<Text strong>Production SMTP Credentials</Text>
|
|
{isMailhog && <Tag>Not active</Tag>}
|
|
</Space>
|
|
),
|
|
children: (
|
|
<div>
|
|
<Form.Item label="SMTP Host" name="smtpHost" extra="Production mail server hostname">
|
|
<Input placeholder="smtp.protonmail.ch" />
|
|
</Form.Item>
|
|
<Form.Item label="SMTP Port" name="smtpPort" extra="Common ports: 587 (STARTTLS), 465 (SSL)">
|
|
<InputNumber min={0} max={65535} style={{ width: '100%' }} placeholder="587" />
|
|
</Form.Item>
|
|
<Form.Item label="SMTP User" name="smtpUser">
|
|
<Input placeholder="user@example.com" />
|
|
</Form.Item>
|
|
<Form.Item label="SMTP Password" name="smtpPass">
|
|
<Input.Password placeholder="SMTP password or app-specific password" />
|
|
</Form.Item>
|
|
</div>
|
|
),
|
|
}]}
|
|
/>
|
|
</div>
|
|
|
|
{/* Test Mode */}
|
|
<div style={{ marginTop: 24 }}>
|
|
<Text strong style={{ fontSize: 15 }}>Test Mode</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<Form.Item
|
|
label="Enable Test Mode"
|
|
name="emailTestMode"
|
|
valuePropName="checked"
|
|
extra="When enabled, all emails are redirected to the test recipient"
|
|
>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Test Recipient" name="testEmailRecipient" extra="All emails will be sent to this address when test mode is on">
|
|
<Input placeholder="admin@cmlite.org" />
|
|
</Form.Item>
|
|
</div>
|
|
|
|
{/* Test Actions */}
|
|
<div style={{ marginTop: 24 }}>
|
|
<Text strong style={{ fontSize: 15 }}>Test Actions</Text>
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<Alert
|
|
type="info"
|
|
message={eff
|
|
? `Testing against ${eff.provider === 'mailhog' ? 'MailHog' : 'Production'} (${eff.host}:${eff.port})`
|
|
: 'Tests run against the active provider. Save production credentials before switching.'
|
|
}
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<Space>
|
|
<Button
|
|
icon={<ThunderboltOutlined />}
|
|
loading={testingConnection}
|
|
onClick={handleTestConnection}
|
|
>
|
|
Test Connection
|
|
</Button>
|
|
<Button
|
|
icon={<SendOutlined />}
|
|
loading={sendingTest}
|
|
onClick={handleSendTest}
|
|
>
|
|
Send Test Email
|
|
</Button>
|
|
</Space>
|
|
|
|
{connectionResult && (
|
|
<Alert
|
|
type={connectionResult.success ? 'success' : 'error'}
|
|
message={connectionResult.message}
|
|
icon={connectionResult.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
|
showIcon
|
|
style={{ marginTop: 12 }}
|
|
/>
|
|
)}
|
|
|
|
{sendResult && (
|
|
<Alert
|
|
type={sendResult.success ? 'success' : 'error'}
|
|
message={
|
|
sendResult.success
|
|
? `Test email sent to ${sendResult.recipient}${sendResult.testMode ? ' (test mode)' : ''}`
|
|
: 'Failed to send test email'
|
|
}
|
|
description={sendResult.messageId ? `Message ID: ${sendResult.messageId}` : undefined}
|
|
showIcon
|
|
style={{ marginTop: 12 }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})(),
|
|
},
|
|
{
|
|
key: 'features',
|
|
label: 'Feature Toggles',
|
|
children: (
|
|
<div>
|
|
<Alert
|
|
type="info"
|
|
message="Disabling a module hides it from navigation but does not delete data."
|
|
showIcon
|
|
style={{ marginBottom: 24 }}
|
|
/>
|
|
<Row gutter={[16, 16]}>
|
|
{/* Core Platform */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><AppstoreOutlined /> Core Platform</Space>}
|
|
>
|
|
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Map & Canvassing" name="enableMap" valuePropName="checked" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Newsletter (Listmonk)" name="enableNewsletter" valuePropName="checked" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Landing Pages" name="enableLandingPages" valuePropName="checked" style={{ marginBottom: 0 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Media & Content */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><PlaySquareOutlined /> Media & Content</Space>}
|
|
>
|
|
<Form.Item label="Media Library" name="enableMediaFeatures" valuePropName="checked" extra="Video library, public gallery, analytics, and scheduled publishing" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Gallery Ads" name="enableGalleryAds" valuePropName="checked" extra="Promotional cards inserted into the public video gallery" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Events (Gancio)" name="enableEvents" valuePropName="checked" extra="Event calendar integration (requires gancio container)" style={{ marginBottom: 0 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Communication */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><MessageOutlined /> Communication</Space>}
|
|
>
|
|
<Form.Item label="Team Chat (Rocket.Chat)" name="enableChat" valuePropName="checked" extra="Team coordination chat (requires rocketchat container)" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Video Meetings (Jitsi)" name="enableMeet" valuePropName="checked" extra="Self-hosted video calls — integrates with Rocket.Chat channels" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="SMS Campaigns" name="enableSms" valuePropName="checked" extra="Termux Android SMS for campaign texting and conversations" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Meeting Planner" name="enableMeetingPlanner" valuePropName="checked" extra="Scheduling polls for finding the best meeting time" style={{ marginBottom: 0 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* People & Engagement */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><TeamOutlined /> People & Engagement</Space>}
|
|
>
|
|
<Form.Item label="People CRM" name="enablePeople" valuePropName="checked" extra="Unified contacts, connections, and engagement tracking" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Social Connections" name="enableSocial" valuePropName="checked" extra="Volunteer friend connections, activity feeds, and discovery" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Social Calendar" name="enableSocialCalendar" valuePropName="checked" extra="Personal calendar with layers, shared views, and .ics integration" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Auto-sync People to Map" name="autoSyncPeopleToMap" valuePropName="checked" extra="Adding a contact address auto-creates a map location with geocoding" style={{ marginBottom: 0 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Commerce */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><DollarOutlined /> Commerce</Space>}
|
|
>
|
|
<Form.Item label="Payments (Stripe)" name="enablePayments" valuePropName="checked" extra="Subscriptions, products, and donations" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Ticketed Events" name="enableTicketedEvents" valuePropName="checked" extra="Create events with tiered tickets, QR check-in, and Stripe payments" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Require Event Approval" name="requireEventApproval" valuePropName="checked" extra="Non-admin users need admin approval before publishing events" style={{ marginBottom: 0 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'notifications',
|
|
label: 'Notifications',
|
|
children: (
|
|
<div>
|
|
<Alert
|
|
type="info"
|
|
message="Control which automated email notifications are sent. Disabling a notification stops future emails but does not affect existing queued jobs."
|
|
showIcon
|
|
style={{ marginBottom: 24 }}
|
|
/>
|
|
<Row gutter={[16, 16]}>
|
|
{/* Admin Alerts */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><AlertOutlined /> Admin Alerts</Space>}
|
|
>
|
|
<Form.Item label="New Shift Signup" name="notifyAdminShiftSignup" valuePropName="checked" extra="When a volunteer signs up for a shift" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Response Wall Submission" name="notifyAdminResponseSubmitted" valuePropName="checked" extra="When a new response is submitted for moderation" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Sign Request" name="notifyAdminSignRequested" valuePropName="checked" extra="When a resident requests a yard sign during canvassing" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Shift Cancellation" name="notifyAdminShiftCancellation" valuePropName="checked" extra="When a volunteer cancels their shift signup" style={{ marginBottom: 0 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Volunteer Emails */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><MailOutlined /> Volunteer Emails</Space>}
|
|
>
|
|
<Form.Item label="Canvass Session Summary" name="notifyVolunteerSessionSummary" valuePropName="checked" extra="Summary email after completing a canvassing session" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Signup Cancellation" name="notifyVolunteerCancellation" valuePropName="checked" extra="Confirmation when their shift signup is cancelled" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="24h Pre-Shift Reminder" name="notifyVolunteerShiftReminder" valuePropName="checked" extra="Reminder email 24 hours before their shift" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Post-Shift Thank You" name="notifyVolunteerShiftThankYou" valuePropName="checked" extra="Thank-you email 2 hours after their shift ends" style={{ marginBottom: 0 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Re-Engagement */}
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><RetweetOutlined /> Volunteer Re-Engagement</Space>}
|
|
>
|
|
<Form.Item label="Re-Engagement Emails" name="notifyVolunteerReengagement" valuePropName="checked" extra="Automatically email inactive volunteers" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Inactive After (days)" name="reengagementInactiveDays" extra="Days without activity before considered inactive" style={{ marginBottom: 12 }}>
|
|
<InputNumber min={1} max={365} />
|
|
</Form.Item>
|
|
<Form.Item label="Cooldown Period (days)" name="reengagementCooldownDays" extra="Minimum days between re-engagement emails" style={{ marginBottom: 0 }}>
|
|
<InputNumber min={1} max={365} />
|
|
</Form.Item>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* SMS Notifications — only show when enableSms is on */}
|
|
<Form.Item noStyle shouldUpdate={(prev: Record<string, unknown>, cur: Record<string, unknown>) => prev.enableSms !== cur.enableSms}>
|
|
{({ getFieldValue }: { getFieldValue: (name: string) => unknown }) =>
|
|
getFieldValue('enableSms') ? (
|
|
<Col xs={24} lg={12}>
|
|
<Card
|
|
size="small"
|
|
title={<Space><PhoneOutlined /> SMS Notifications</Space>}
|
|
>
|
|
<Form.Item label="Shift Reminders" name="smsShiftReminders" valuePropName="checked" extra="SMS reminder before a volunteer's shift. Variables: {name}, {shiftTitle}, {shiftTime}, {shiftLocation}" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Reminder Hours Before" name="smsShiftReminderHours" extra="Hours before shift to send the SMS reminder (1-72)" style={{ marginBottom: 12 }}>
|
|
<InputNumber min={1} max={72} />
|
|
</Form.Item>
|
|
<Form.Item label="Signup Confirmations" name="smsShiftSignupConfirm" valuePropName="checked" extra="SMS confirmation when a volunteer signs up. Variables: {name}, {shiftTitle}, {shiftDate}, {shiftTime}" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label="Volunteer Welcome" name="smsVolunteerWelcome" valuePropName="checked" extra="Welcome SMS when a new volunteer account is created. Variables: {name}" style={{ marginBottom: 12 }}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
|
All SMS notifications respect opt-outs. Requires an active phone connection.
|
|
</Text>
|
|
</Card>
|
|
</Col>
|
|
) : null
|
|
}
|
|
</Form.Item>
|
|
</Row>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'system',
|
|
label: 'System',
|
|
icon: <CloudSyncOutlined />,
|
|
children: <SystemUpgradeTab />,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Form form={form} layout="vertical">
|
|
<Tabs items={items} defaultActiveKey={locationState?.activeTab || 'organization'} />
|
|
<div style={{ marginTop: 24 }}>
|
|
<Button type="primary" icon={<SaveOutlined />} size="large" onClick={handleSave}>
|
|
Save Settings
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// System Upgrade Tab
|
|
// =============================================================================
|
|
|
|
const UPGRADE_PHASES = [
|
|
'Pre-flight Checks',
|
|
'Backup',
|
|
'Code Update',
|
|
'Container Rebuild',
|
|
'Service Restart',
|
|
'Verification',
|
|
];
|
|
|
|
function SystemUpgradeTab() {
|
|
const [status, setStatus] = useState<UpgradeStatus | null>(null);
|
|
const [progress, setProgress] = useState<UpgradeProgress | null>(null);
|
|
const [result, setResult] = useState<UpgradeResult | null>(null);
|
|
const [running, setRunning] = useState(false);
|
|
const [checking, setChecking] = useState(false);
|
|
const [upgrading, setUpgrading] = useState(false);
|
|
const [apiOffline, setApiOffline] = useState(false);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [skipBackup, setSkipBackup] = useState(false);
|
|
const [pullServices, setPullServices] = useState(false);
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const checkStartRef = useRef<string | null>(null);
|
|
|
|
const fetchStatus = useCallback(async () => {
|
|
try {
|
|
const { data } = await api.get<UpgradeStatusResponse>('/upgrade/status');
|
|
setStatus(data.status);
|
|
setProgress(data.progress);
|
|
setResult(data.result);
|
|
setRunning(data.running);
|
|
setApiOffline(false);
|
|
return data;
|
|
} catch {
|
|
setApiOffline(true);
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
// Initial fetch on mount
|
|
useEffect(() => {
|
|
fetchStatus().then((data) => {
|
|
// If already running when we load, start polling
|
|
if (data?.running) {
|
|
setUpgrading(true);
|
|
startUpgradePoll();
|
|
}
|
|
});
|
|
return () => stopPoll();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const stopPoll = () => {
|
|
if (pollRef.current) {
|
|
clearInterval(pollRef.current);
|
|
pollRef.current = null;
|
|
}
|
|
};
|
|
|
|
const startCheckPoll = () => {
|
|
stopPoll();
|
|
checkStartRef.current = status?.checkedAt || null;
|
|
pollRef.current = setInterval(async () => {
|
|
const data = await fetchStatus();
|
|
if (!data) return;
|
|
// Check complete when checkedAt changes
|
|
if (data.status && data.status.checkedAt !== checkStartRef.current) {
|
|
setChecking(false);
|
|
stopPoll();
|
|
message.success('Update check complete');
|
|
}
|
|
}, 2000);
|
|
// Auto-stop after 30s
|
|
setTimeout(() => {
|
|
if (checking) {
|
|
setChecking(false);
|
|
stopPoll();
|
|
}
|
|
}, 30000);
|
|
};
|
|
|
|
const startUpgradePoll = () => {
|
|
stopPoll();
|
|
pollRef.current = setInterval(async () => {
|
|
const data = await fetchStatus();
|
|
// If API is offline, show restarting message (expected during upgrade)
|
|
if (!data) return;
|
|
// Upgrade complete when result appears or not running anymore
|
|
if (data.result && !data.running) {
|
|
setUpgrading(false);
|
|
stopPoll();
|
|
if (data.result.success) {
|
|
message.success('Upgrade completed successfully');
|
|
} else {
|
|
message.error('Upgrade failed');
|
|
}
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
const handleCheckForUpdates = async () => {
|
|
setChecking(true);
|
|
try {
|
|
await api.post('/upgrade/check');
|
|
startCheckPoll();
|
|
} catch {
|
|
setChecking(false);
|
|
message.error('Failed to trigger update check');
|
|
}
|
|
};
|
|
|
|
const handleStartUpgrade = async () => {
|
|
setConfirmOpen(false);
|
|
setUpgrading(true);
|
|
setResult(null);
|
|
try {
|
|
await api.post('/upgrade/start', { skipBackup, pullServices });
|
|
startUpgradePoll();
|
|
} catch {
|
|
setUpgrading(false);
|
|
message.error('Failed to trigger upgrade');
|
|
}
|
|
};
|
|
|
|
const handleClearResult = async () => {
|
|
try {
|
|
await api.post('/upgrade/clear-result');
|
|
setResult(null);
|
|
} catch {
|
|
message.error('Failed to clear result');
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
try {
|
|
return new Date(dateStr).toLocaleString();
|
|
} catch {
|
|
return dateStr;
|
|
}
|
|
};
|
|
|
|
const formatDuration = (seconds: number) => {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
};
|
|
|
|
const formatRelativeTime = (dateStr: string) => {
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return 'just now';
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
};
|
|
|
|
const isUpgrading = upgrading || running;
|
|
|
|
return (
|
|
<div style={{ maxWidth: 800 }}>
|
|
{/* Current Version */}
|
|
<Card
|
|
size="small"
|
|
title={<Space><BranchesOutlined /> Current Version</Space>}
|
|
style={{ marginBottom: 16 }}
|
|
>
|
|
{status ? (
|
|
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
|
<Descriptions.Item label="Branch">
|
|
<Tag color="blue">{status.branch}</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Commit">
|
|
<Text code>{status.currentCommit}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Last Commit Message" span={2}>
|
|
<Text>{status.currentMessage}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Commit Date">
|
|
{status.currentDate ? formatDate(status.currentDate) : '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Last Checked">
|
|
{status.checkedAt ? (
|
|
<Text type="secondary">{formatRelativeTime(status.checkedAt)}</Text>
|
|
) : (
|
|
<Text type="secondary">Never</Text>
|
|
)}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
) : (
|
|
<Paragraph type="secondary">
|
|
No version information available. Click "Check for Updates" to fetch the current status.
|
|
</Paragraph>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Available Updates */}
|
|
{status && status.commitsBehind > 0 && (
|
|
<Card
|
|
size="small"
|
|
title={
|
|
<Space>
|
|
<HistoryOutlined />
|
|
Available Updates
|
|
<Tag color="orange">{status.commitsBehind} commit{status.commitsBehind !== 1 ? 's' : ''} behind</Tag>
|
|
</Space>
|
|
}
|
|
style={{ marginBottom: 16 }}
|
|
>
|
|
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
|
|
<Timeline
|
|
items={status.changelog.map((entry) => ({
|
|
color: 'blue',
|
|
children: (
|
|
<div>
|
|
<Space size={4}>
|
|
<Text code style={{ fontSize: 12 }}>{entry.hash}</Text>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>{entry.author}</Text>
|
|
</Space>
|
|
<div><Text style={{ fontSize: 13 }}>{entry.message}</Text></div>
|
|
</div>
|
|
),
|
|
}))}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{status && status.commitsBehind === 0 && status.checkedAt && !status.error && (
|
|
<Alert
|
|
type="success"
|
|
message="Up to date"
|
|
description={`Your installation is on the latest commit (${status.currentCommit}) of the ${status.branch} branch.`}
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
)}
|
|
|
|
{status?.error && (
|
|
<Alert
|
|
type="error"
|
|
message="Update check failed"
|
|
description={status.error}
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<Space style={{ marginBottom: 16 }}>
|
|
<Button
|
|
icon={checking ? <LoadingOutlined /> : <SyncOutlined />}
|
|
loading={checking}
|
|
onClick={handleCheckForUpdates}
|
|
disabled={isUpgrading}
|
|
>
|
|
Check for Updates
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<RocketOutlined />}
|
|
onClick={() => setConfirmOpen(true)}
|
|
disabled={isUpgrading || !status || status.commitsBehind === 0}
|
|
>
|
|
Start Upgrade
|
|
</Button>
|
|
</Space>
|
|
|
|
{/* Upgrade Progress */}
|
|
{isUpgrading && (
|
|
<Card
|
|
size="small"
|
|
title={<Space><LoadingOutlined spin /> Upgrade in Progress</Space>}
|
|
style={{ marginBottom: 16 }}
|
|
>
|
|
{apiOffline ? (
|
|
<Alert
|
|
type="warning"
|
|
message="Services restarting..."
|
|
description="The API is temporarily offline while containers are being rebuilt. This is expected. The page will automatically reconnect."
|
|
showIcon
|
|
icon={<ReloadOutlined spin />}
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
) : progress ? (
|
|
<>
|
|
<Steps
|
|
size="small"
|
|
current={progress.phase - 1}
|
|
items={UPGRADE_PHASES.map((name, i) => ({
|
|
title: name,
|
|
status: progress.phase - 1 > i ? 'finish' : progress.phase - 1 === i ? 'process' : 'wait',
|
|
}))}
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<Progress percent={progress.percentage} status="active" />
|
|
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
|
{progress.message}
|
|
</Paragraph>
|
|
</>
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
|
<Spin />
|
|
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
|
Waiting for upgrade to start...
|
|
</Paragraph>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{/* Last Result */}
|
|
{result && !isUpgrading && (
|
|
<Card
|
|
size="small"
|
|
title={<Space><HistoryOutlined /> Last Upgrade Result</Space>}
|
|
extra={
|
|
<Button size="small" type="text" onClick={handleClearResult}>
|
|
Dismiss
|
|
</Button>
|
|
}
|
|
style={{ marginBottom: 16 }}
|
|
>
|
|
<Alert
|
|
type={result.success ? 'success' : 'error'}
|
|
message={result.success ? 'Upgrade Successful' : 'Upgrade Failed'}
|
|
description={result.message}
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
|
<Descriptions.Item label="Previous">
|
|
<Text code>{result.previousCommit}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Current">
|
|
<Text code>{result.newCommit}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Commits">
|
|
{result.commitCount}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Duration">
|
|
{formatDuration(result.durationSeconds)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Completed">
|
|
{formatDate(result.completedAt)}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
{result.warnings.length > 0 && (
|
|
<Alert
|
|
type="warning"
|
|
message="Warnings"
|
|
description={
|
|
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
|
{result.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
|
</ul>
|
|
}
|
|
showIcon
|
|
style={{ marginTop: 12 }}
|
|
/>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{/* Systemd Setup Info */}
|
|
<Card
|
|
size="small"
|
|
title={<Space><InfoCircleOutlined /> Setup</Space>}
|
|
style={{ marginBottom: 16, opacity: 0.8 }}
|
|
>
|
|
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
|
The upgrade system requires a systemd path watcher on the host. This is normally
|
|
installed by <Text code>./config.sh</Text> during initial setup. To install or
|
|
reinstall manually:
|
|
</Paragraph>
|
|
<Paragraph code copyable style={{ fontSize: 12, marginBottom: 0 }}>
|
|
{`cd ~/changemaker.lite && sudo cp scripts/systemd/changemaker-upgrade.* /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now changemaker-upgrade.path`}
|
|
</Paragraph>
|
|
</Card>
|
|
|
|
{/* Upgrade Confirmation Modal */}
|
|
<Modal
|
|
title={
|
|
<Space>
|
|
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
|
Confirm System Upgrade
|
|
</Space>
|
|
}
|
|
open={confirmOpen}
|
|
onOk={handleStartUpgrade}
|
|
onCancel={() => setConfirmOpen(false)}
|
|
okText="Start Upgrade"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Paragraph>
|
|
This will pull the latest code, rebuild containers, and restart all services.
|
|
The admin panel will be briefly unavailable during the restart.
|
|
</Paragraph>
|
|
{status && (
|
|
<Paragraph>
|
|
<Text strong>{status.commitsBehind}</Text> commit{status.commitsBehind !== 1 ? 's' : ''} will
|
|
be applied from <Text code>{status.currentCommit}</Text> to <Text code>{status.remoteCommit}</Text>.
|
|
</Paragraph>
|
|
)}
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
<Space direction="vertical">
|
|
<Checkbox
|
|
checked={pullServices}
|
|
onChange={(e) => setPullServices(e.target.checked)}
|
|
>
|
|
Pull third-party images (PostgreSQL, Redis, etc.)
|
|
</Checkbox>
|
|
<Checkbox
|
|
checked={skipBackup}
|
|
onChange={(e) => setSkipBackup(e.target.checked)}
|
|
>
|
|
<Text type="danger">Skip backup (not recommended)</Text>
|
|
</Checkbox>
|
|
</Space>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Swatch({ label, color }: { label: string; color: string }) {
|
|
return (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div
|
|
style={{
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 8,
|
|
background: color,
|
|
border: '2px solid rgba(255,255,255,0.2)',
|
|
marginBottom: 4,
|
|
}}
|
|
/>
|
|
<Text style={{ fontSize: 11 }}>{label}</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Parse "linear-gradient(135deg, #005a9c 0%, #007acc 100%)" into parts */
|
|
function parseGradient(val: string): { angle: number; color1: string; color2: string } {
|
|
const defaults = { angle: 135, color1: '#005a9c', color2: '#007acc' };
|
|
if (!val || !val.includes('linear-gradient')) return defaults;
|
|
const inner = val.replace(/^linear-gradient\(/, '').replace(/\)$/, '');
|
|
// Match angle
|
|
const angleMatch = inner.match(/^(\d+)deg/);
|
|
const angle = angleMatch ? parseInt(angleMatch[1]!, 10) : defaults.angle;
|
|
// Match colors (hex, rgb, named)
|
|
const colorMatches = inner.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/g);
|
|
return {
|
|
angle,
|
|
color1: colorMatches?.[0] ?? defaults.color1,
|
|
color2: colorMatches?.[1] ?? colorMatches?.[0] ?? defaults.color2,
|
|
};
|
|
}
|
|
|
|
function buildGradient(angle: number, color1: string, color2: string): string {
|
|
return `linear-gradient(${angle}deg, ${color1} 0%, ${color2} 100%)`;
|
|
}
|
|
|
|
/** Custom form control — accepts value/onChange from Form.Item */
|
|
function GradientPicker({ value, onChange }: { value?: string; onChange?: (val: string) => void }) {
|
|
const parsed = parseGradient(value || '');
|
|
const [angle, setAngle] = useState(parsed.angle);
|
|
const [color1, setColor1] = useState(parsed.color1);
|
|
const [color2, setColor2] = useState(parsed.color2);
|
|
|
|
// Sync from external value changes (e.g., form reset)
|
|
useEffect(() => {
|
|
const p = parseGradient(value || '');
|
|
setAngle(p.angle);
|
|
setColor1(p.color1);
|
|
setColor2(p.color2);
|
|
}, [value]);
|
|
|
|
const emit = useCallback(
|
|
(a: number, c1: string, c2: string) => {
|
|
onChange?.(buildGradient(a, c1, c2));
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
const gradient = buildGradient(angle, color1, color2);
|
|
|
|
return (
|
|
<div>
|
|
{/* Live preview */}
|
|
<div
|
|
style={{
|
|
background: gradient,
|
|
borderRadius: 8,
|
|
padding: '16px 24px',
|
|
color: '#fff',
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
marginBottom: 16,
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
}}
|
|
>
|
|
Header Gradient Preview
|
|
</div>
|
|
|
|
{/* Color pickers row */}
|
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap', marginBottom: 12 }}>
|
|
<div>
|
|
<Text style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Start Color</Text>
|
|
<ColorPicker
|
|
value={color1}
|
|
format="hex"
|
|
showText
|
|
onChange={(_val, hex) => {
|
|
setColor1(hex);
|
|
emit(angle, hex, color2);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Text style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>End Color</Text>
|
|
<ColorPicker
|
|
value={color2}
|
|
format="hex"
|
|
showText
|
|
onChange={(_val, hex) => {
|
|
setColor2(hex);
|
|
emit(angle, color1, hex);
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button
|
|
size="small"
|
|
onClick={() => {
|
|
setColor1(color2);
|
|
setColor2(color1);
|
|
emit(angle, color2, color1);
|
|
}}
|
|
style={{ marginTop: 18 }}
|
|
>
|
|
Swap
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Angle slider */}
|
|
<div style={{ marginBottom: 8 }}>
|
|
<Text style={{ fontSize: 12 }}>Angle: {angle}°</Text>
|
|
<Slider
|
|
min={0}
|
|
max={360}
|
|
value={angle}
|
|
onChange={(val) => {
|
|
setAngle(val);
|
|
emit(val, color1, color2);
|
|
}}
|
|
tooltip={{ formatter: (v) => `${v}°` }}
|
|
styles={{ track: { background: gradient } }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Raw CSS output (editable) */}
|
|
<Input
|
|
size="small"
|
|
value={gradient}
|
|
onChange={(e) => {
|
|
const raw = e.target.value;
|
|
onChange?.(raw);
|
|
const p = parseGradient(raw);
|
|
setAngle(p.angle);
|
|
setColor1(p.color1);
|
|
setColor2(p.color2);
|
|
}}
|
|
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|