import { BellRing, CalendarDays, LogIn, MessageSquareText, Plus, Save, ScrollText, ShieldCheck, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { RichTextEditor } from '../components/rich-text-editor';
import { variableAppliesToEvent } from '../components/rich-text-variables';
import { Badge, Button, Field, Input, PageHeader, Panel, ResourceState, Toast } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import type { ServerSettings } from '../types';
type MessageSection = 'join' | 'firstJoin' | 'daily' | 'rules' | 'maintenance';
export function MessagesPage() {
const { t } = useI18n();
const { api, session } = useServer();
const resource = useApiResource(() => api.settings(), [], ['settings']);
const catalogResource = useApiResource(() => api.triggerCatalog(), []);
const variableCatalog = catalogResource.data?.variables?.filter((variable) =>
variable.templateAllowed !== false && variable.sensitive !== true
&& variableAppliesToEvent(variable, 'player.join'));
const owner = session?.actor?.roles.includes('owner') === true;
const [value, setValue] = useState<ServerSettings>();
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string>();
const [error, setError] = useState<string>();
const [section, setSection] = useState<MessageSection>('join');
useEffect(() => setValue(resource.data), [resource.data]);
const toggle = (key: keyof ServerSettings, checked: boolean) => {
setValue((current) => current ? { ...current, [key]: checked } : current);
};
const text = (key: keyof ServerSettings, next: string) => {
setValue((current) => current ? { ...current, [key]: next } : current);
};
const save = useCallback(async () => {
if (!value || !owner) return;
setBusy(true); setError(undefined); setMessage(undefined);
try {
const operation = await api.updateMessageSettings({
joinWelcomeEnabled: value.joinWelcomeEnabled,
joinWelcomeMessage: value.joinWelcomeMessage,
firstJoinMessageEnabled: value.firstJoinMessageEnabled,
firstJoinMessage: value.firstJoinMessage,
dailyAnnouncementsEnabled: value.dailyAnnouncementsEnabled,
dailyAnnouncements: value.dailyAnnouncements,
announcementTimeZone: value.announcementTimeZone,
rulesReminderEnabled: value.rulesReminderEnabled,
rulesMessage: value.rulesMessage,
maintenanceJoinReminderEnabled: value.maintenanceJoinReminderEnabled,
});
setMessage(operation.message ?? t('messages.saved'));
resource.reload();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally { setBusy(false); }
}, [api, owner, resource, t, value]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
event.preventDefault();
if (!busy) void save();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [busy, save]);
const sectionEnabled = (id: MessageSection) => value ? ({
join: value.joinWelcomeEnabled,
firstJoin: value.firstJoinMessageEnabled,
daily: value.dailyAnnouncementsEnabled,
rules: value.rulesReminderEnabled,
maintenance: value.maintenanceJoinReminderEnabled,
})[id] : false;
const sections: Array<{ id: MessageSection; label: string; icon: ReactNode }> = [
{ id: 'join', label: t('messages.joinWelcome'), icon: <LogIn size={17} /> },
{ id: 'firstJoin', label: t('messages.firstJoin'), icon: <ShieldCheck size={17} /> },
{ id: 'daily', label: t('messages.daily'), icon: <CalendarDays size={17} /> },
{ id: 'rules', label: t('messages.rules'), icon: <ScrollText size={17} /> },
{ id: 'maintenance', label: t('messages.maintenanceReminder'), icon: <BellRing size={17} /> },
];
return <div className="page">
<PageHeader title={t('messages.title')} description={t('messages.description')} actions={value ? <Badge tone={value.features.joinExperienceEnabled ? 'good' : 'warn'}>{value.features.joinExperienceEnabled ? t('common.enabled') : t('common.disabled')}</Badge> : undefined} />
<ResourceState loading={resource.loading || !value} error={resource.error} onRetry={resource.reload}>
{value && <form className="form-stack" onSubmit={(event) => { event.preventDefault(); void save(); }}>
<div className="confirm-box"><MessageSquareText /><div><strong>{t('messages.richHint')}</strong><p>{t('messages.variablesHint')}</p></div></div>
<div className="message-workspace">
<nav className="message-section-nav" aria-label={t('messages.sections')}>
{sections.map((item) => <button type="button" key={item.id} className={section === item.id ? 'is-active' : ''} aria-selected={section === item.id} onClick={() => setSection(item.id)}>{item.icon}<span>{item.label}</span><i className={sectionEnabled(item.id) ? 'is-enabled' : ''} aria-label={sectionEnabled(item.id) ? t('common.enabled') : t('common.disabled')} /></button>)}
</nav>
<div className="message-section-content" role="tabpanel">
{section === 'join' && <MessagePanel icon={<LogIn size={18} />} title={t('messages.joinWelcome')} enabled={value.joinWelcomeEnabled} onEnabled={(checked) => toggle('joinWelcomeEnabled', checked)}>
<RichTextEditor value={value.joinWelcomeMessage} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => text('joinWelcomeMessage', next)} />
</MessagePanel>}
{section === 'firstJoin' && <MessagePanel icon={<ShieldCheck size={18} />} title={t('messages.firstJoin')} enabled={value.firstJoinMessageEnabled} onEnabled={(checked) => toggle('firstJoinMessageEnabled', checked)}>
<RichTextEditor value={value.firstJoinMessage} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => text('firstJoinMessage', next)} />
</MessagePanel>}
{section === 'rules' && <MessagePanel icon={<ScrollText size={18} />} title={t('messages.rules')} enabled={value.rulesReminderEnabled} onEnabled={(checked) => toggle('rulesReminderEnabled', checked)}>
<RichTextEditor value={value.rulesMessage} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => text('rulesMessage', next)} />
</MessagePanel>}
{section === 'daily' && <Panel title={<><CalendarDays size={18} />{t('messages.daily')}</>}>
<label className="toggle-row"><input type="checkbox" checked={value.dailyAnnouncementsEnabled} onChange={(event) => toggle('dailyAnnouncementsEnabled', event.target.checked)} /><span><strong>{value.dailyAnnouncementsEnabled ? t('common.enabled') : t('common.disabled')}</strong><small>{t('messages.onceDaily')}</small></span></label>
<Field label={t('messages.timeZone')}><Input value={value.announcementTimeZone} onChange={(event) => text('announcementTimeZone', event.target.value)} placeholder="Asia/Shanghai" required /></Field>
<div className="daily-announcement-list">
<div className="daily-announcement-list__heading"><strong>{t('messages.dailyList')}</strong><Button type="button" variant="ghost" disabled={value.dailyAnnouncements.length >= 20} onClick={() => setValue((current) => current ? { ...current, dailyAnnouncements: [...current.dailyAnnouncements, ''] } : current)}><Plus size={14} />{t('messages.add')}</Button></div>
{value.dailyAnnouncements.map((announcement, index) => <section className="daily-announcement" key={index}>
<div className="daily-announcement__heading"><span>{t('messages.item')} {index + 1}</span><Button type="button" variant="ghost" onClick={() => setValue((current) => current ? { ...current, dailyAnnouncements: current.dailyAnnouncements.filter((_, itemIndex) => itemIndex !== index) } : current)}><Trash2 size={14} />{t('common.delete')}</Button></div>
<RichTextEditor compact value={announcement} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => setValue((current) => current ? { ...current, dailyAnnouncements: current.dailyAnnouncements.map((item, itemIndex) => itemIndex === index ? next : item) } : current)} />
</section>)}
</div>
</Panel>}
{section === 'maintenance' && <Panel title={<><BellRing size={18} />{t('messages.behavior')}</>}>
<label className="toggle-row"><input type="checkbox" checked={value.maintenanceJoinReminderEnabled} onChange={(event) => toggle('maintenanceJoinReminderEnabled', event.target.checked)} /><span><strong>{t('messages.maintenanceReminder')}</strong><small>{t('messages.maintenanceHint')}</small></span></label>
</Panel>}
</div>
</div>
{!owner && <div className="form-error">{t('messages.ownerOnly')}</div>}
{error && <Toast message={error} tone="danger" onClose={() => setError(undefined)} />}
{message && <Toast message={message} tone="good" onClose={() => setMessage(undefined)} />}
<div className="button-row"><Button type="submit" disabled={!owner || busy}><Save size={15} />{t('messages.save')}</Button><span className="keyboard-hint"><kbd>Ctrl</kbd>+<kbd>S</kbd></span>{message && <Badge tone="good">{t('messages.live')}</Badge>}</div>
</form>}
</ResourceState>
</div>;
}
function MessagePanel({ icon, title, enabled, onEnabled, children }: {
icon: ReactNode;
title: string;
enabled: boolean;
onEnabled: (checked: boolean) => void;
children: ReactNode;
}) {
const { t } = useI18n();
return <Panel title={<>{icon}{title}</>}>
<label className="toggle-row"><input type="checkbox" checked={enabled} onChange={(event) => onEnabled(event.target.checked)} /><span><strong>{enabled ? t('common.enabled') : t('common.disabled')}</strong></span></label>
{children}
</Panel>;
}
import { BellRing, CalendarDays, LogIn, MessageSquareText, Plus, Save, ScrollText, ShieldCheck, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { RichTextEditor } from '../components/rich-text-editor';
import { variableAppliesToEvent } from '../components/rich-text-variables';
import { Badge, Button, Field, Input, PageHeader, Panel, ResourceState, Toast } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import type { ServerSettings } from '../types';
type MessageSection = 'join' | 'firstJoin' | 'daily' | 'rules' | 'maintenance';
export function MessagesPage() {
const { t } = useI18n();
const { api, session } = useServer();
const resource = useApiResource(() => api.settings(), [], ['settings']);
const catalogResource = useApiResource(() => api.triggerCatalog(), []);
const variableCatalog = catalogResource.data?.variables?.filter((variable) =>
variable.templateAllowed !== false && variable.sensitive !== true
&& variableAppliesToEvent(variable, 'player.join'));
const owner = session?.actor?.roles.includes('owner') === true;
const [value, setValue] = useState<ServerSettings>();
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string>();
const [error, setError] = useState<string>();
const [section, setSection] = useState<MessageSection>('join');
useEffect(() => setValue(resource.data), [resource.data]);
const toggle = (key: keyof ServerSettings, checked: boolean) => {
setValue((current) => current ? { ...current, [key]: checked } : current);
};
const text = (key: keyof ServerSettings, next: string) => {
setValue((current) => current ? { ...current, [key]: next } : current);
};
const save = useCallback(async () => {
if (!value || !owner) return;
setBusy(true); setError(undefined); setMessage(undefined);
try {
const operation = await api.updateMessageSettings({
joinWelcomeEnabled: value.joinWelcomeEnabled,
joinWelcomeMessage: value.joinWelcomeMessage,
firstJoinMessageEnabled: value.firstJoinMessageEnabled,
firstJoinMessage: value.firstJoinMessage,
dailyAnnouncementsEnabled: value.dailyAnnouncementsEnabled,
dailyAnnouncements: value.dailyAnnouncements,
announcementTimeZone: value.announcementTimeZone,
rulesReminderEnabled: value.rulesReminderEnabled,
rulesMessage: value.rulesMessage,
maintenanceJoinReminderEnabled: value.maintenanceJoinReminderEnabled,
});
setMessage(operation.message ?? t('messages.saved'));
resource.reload();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally { setBusy(false); }
}, [api, owner, resource, t, value]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
event.preventDefault();
if (!busy) void save();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [busy, save]);
const sectionEnabled = (id: MessageSection) => value ? ({
join: value.joinWelcomeEnabled,
firstJoin: value.firstJoinMessageEnabled,
daily: value.dailyAnnouncementsEnabled,
rules: value.rulesReminderEnabled,
maintenance: value.maintenanceJoinReminderEnabled,
})[id] : false;
const sections: Array<{ id: MessageSection; label: string; icon: ReactNode }> = [
{ id: 'join', label: t('messages.joinWelcome'), icon: <LogIn size={17} /> },
{ id: 'firstJoin', label: t('messages.firstJoin'), icon: <ShieldCheck size={17} /> },
{ id: 'daily', label: t('messages.daily'), icon: <CalendarDays size={17} /> },
{ id: 'rules', label: t('messages.rules'), icon: <ScrollText size={17} /> },
{ id: 'maintenance', label: t('messages.maintenanceReminder'), icon: <BellRing size={17} /> },
];
return <div className="page">
<PageHeader title={t('messages.title')} description={t('messages.description')} actions={value ? <Badge tone={value.features.joinExperienceEnabled ? 'good' : 'warn'}>{value.features.joinExperienceEnabled ? t('common.enabled') : t('common.disabled')}</Badge> : undefined} />
<ResourceState loading={resource.loading || !value} error={resource.error} onRetry={resource.reload}>
{value && <form className="form-stack" onSubmit={(event) => { event.preventDefault(); void save(); }}>
<div className="confirm-box"><MessageSquareText /><div><strong>{t('messages.richHint')}</strong><p>{t('messages.variablesHint')}</p></div></div>
<div className="message-workspace">
<nav className="message-section-nav" aria-label={t('messages.sections')}>
{sections.map((item) => <button type="button" key={item.id} className={section === item.id ? 'is-active' : ''} aria-selected={section === item.id} onClick={() => setSection(item.id)}>{item.icon}<span>{item.label}</span><i className={sectionEnabled(item.id) ? 'is-enabled' : ''} aria-label={sectionEnabled(item.id) ? t('common.enabled') : t('common.disabled')} /></button>)}
</nav>
<div className="message-section-content" role="tabpanel">
{section === 'join' && <MessagePanel icon={<LogIn size={18} />} title={t('messages.joinWelcome')} enabled={value.joinWelcomeEnabled} onEnabled={(checked) => toggle('joinWelcomeEnabled', checked)}>
<RichTextEditor value={value.joinWelcomeMessage} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => text('joinWelcomeMessage', next)} />
</MessagePanel>}
{section === 'firstJoin' && <MessagePanel icon={<ShieldCheck size={18} />} title={t('messages.firstJoin')} enabled={value.firstJoinMessageEnabled} onEnabled={(checked) => toggle('firstJoinMessageEnabled', checked)}>
<RichTextEditor value={value.firstJoinMessage} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => text('firstJoinMessage', next)} />
</MessagePanel>}
{section === 'rules' && <MessagePanel icon={<ScrollText size={18} />} title={t('messages.rules')} enabled={value.rulesReminderEnabled} onEnabled={(checked) => toggle('rulesReminderEnabled', checked)}>
<RichTextEditor value={value.rulesMessage} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => text('rulesMessage', next)} />
</MessagePanel>}
{section === 'daily' && <Panel title={<><CalendarDays size={18} />{t('messages.daily')}</>}>
<label className="toggle-row"><input type="checkbox" checked={value.dailyAnnouncementsEnabled} onChange={(event) => toggle('dailyAnnouncementsEnabled', event.target.checked)} /><span><strong>{value.dailyAnnouncementsEnabled ? t('common.enabled') : t('common.disabled')}</strong><small>{t('messages.onceDaily')}</small></span></label>
<Field label={t('messages.timeZone')}><Input value={value.announcementTimeZone} onChange={(event) => text('announcementTimeZone', event.target.value)} placeholder="Asia/Shanghai" required /></Field>
<div className="daily-announcement-list">
<div className="daily-announcement-list__heading"><strong>{t('messages.dailyList')}</strong><Button type="button" variant="ghost" disabled={value.dailyAnnouncements.length >= 20} onClick={() => setValue((current) => current ? { ...current, dailyAnnouncements: [...current.dailyAnnouncements, ''] } : current)}><Plus size={14} />{t('messages.add')}</Button></div>
{value.dailyAnnouncements.map((announcement, index) => <section className="daily-announcement" key={index}>
<div className="daily-announcement__heading"><span>{t('messages.item')} {index + 1}</span><Button type="button" variant="ghost" onClick={() => setValue((current) => current ? { ...current, dailyAnnouncements: current.dailyAnnouncements.filter((_, itemIndex) => itemIndex !== index) } : current)}><Trash2 size={14} />{t('common.delete')}</Button></div>
<RichTextEditor compact value={announcement} defaultSenderName={value.defaultMessageSender}
variableCatalog={variableCatalog} onChange={(next) => setValue((current) => current ? { ...current, dailyAnnouncements: current.dailyAnnouncements.map((item, itemIndex) => itemIndex === index ? next : item) } : current)} />
</section>)}
</div>
</Panel>}
{section === 'maintenance' && <Panel title={<><BellRing size={18} />{t('messages.behavior')}</>}>
<label className="toggle-row"><input type="checkbox" checked={value.maintenanceJoinReminderEnabled} onChange={(event) => toggle('maintenanceJoinReminderEnabled', event.target.checked)} /><span><strong>{t('messages.maintenanceReminder')}</strong><small>{t('messages.maintenanceHint')}</small></span></label>
</Panel>}
</div>
</div>
{!owner && <div className="form-error">{t('messages.ownerOnly')}</div>}
{error && <Toast message={error} tone="danger" onClose={() => setError(undefined)} />}
{message && <Toast message={message} tone="good" onClose={() => setMessage(undefined)} />}
<div className="button-row"><Button type="submit" disabled={!owner || busy}><Save size={15} />{t('messages.save')}</Button><span className="keyboard-hint"><kbd>Ctrl</kbd>+<kbd>S</kbd></span>{message && <Badge tone="good">{t('messages.live')}</Badge>}</div>
</form>}
</ResourceState>
</div>;
}
function MessagePanel({ icon, title, enabled, onEnabled, children }: {
icon: ReactNode;
title: string;
enabled: boolean;
onEnabled: (checked: boolean) => void;
children: ReactNode;
}) {
const { t } = useI18n();
return <Panel title={<>{icon}{title}</>}>
<label className="toggle-row"><input type="checkbox" checked={enabled} onChange={(event) => onEnabled(event.target.checked)} /><span><strong>{enabled ? t('common.enabled') : t('common.disabled')}</strong></span></label>
{children}
</Panel>;
}