Add global /about command and configuration

- Introduced `BotAboutConfig` model for managing the global `/about` message in the owner panel.
- Implemented the `/about` command with localization support and integrated it into the help catalog.
- Enhanced the dashboard with a new owner section for configuring the `/about` message type and content.
- Updated components to handle the new command and its interactions, including support for different message formats (TEXT, EMBED, COMPONENTS_V2).
- Added localization entries for the `/about` command in English and German, improving accessibility for users.
- Documented the implementation in the phase tracking documentation for future reference.
This commit is contained in:
smueller
2026-07-24 12:26:00 +02:00
parent 20593b7173
commit c7fb3d7041
22 changed files with 572 additions and 4 deletions

View File

@@ -0,0 +1,176 @@
'use client';
import type { BotAboutConfig, MessageComponentsV2, WelcomeEmbed } from '@nexumi/shared';
import { useTranslations } from '@/components/locale-provider';
import { SettingsForm } from '@/components/settings/settings-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
DiscordComponentsV2Builder,
componentsV2FromUnknown,
normalizeComponentsV2
} from '@/components/ui/discord-components-v2-builder';
import {
DiscordEmbedBuilder,
embedFromUnknown,
normalizeEmbed
} from '@/components/ui/discord-embed-builder';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
const ABOUT_PREVIEW_VARS = {
server: 'Nexumi',
'server.icon': 'https://cdn.discordapp.com/embed/avatars/1.png'
};
interface LocalAboutValue {
enabled: boolean;
messageType: BotAboutConfig['messageType'];
content: string | null;
embed: WelcomeEmbed | null;
components: MessageComponentsV2 | null;
}
function toLocal(config: BotAboutConfig): LocalAboutValue {
return {
enabled: config.enabled,
messageType: config.messageType,
content: config.content,
embed: embedFromUnknown(config.embed),
components: componentsV2FromUnknown(config.components)
};
}
async function readError(response: Response): Promise<string> {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
return data?.error ?? 'Request failed';
}
export function AboutForm({
initialValue,
canEdit
}: {
initialValue: BotAboutConfig;
canEdit: boolean;
}) {
const t = useTranslations();
const readOnly = !canEdit;
return (
<SettingsForm
initialValue={toLocal(initialValue)}
readOnly={readOnly}
onSave={async (value) => {
if (readOnly) {
return { ok: false, error: t('owner.about.readOnly') };
}
const payload: BotAboutConfig = {
enabled: value.enabled,
messageType: value.messageType,
content: value.content,
embed: normalizeEmbed(value.embed),
components: normalizeComponentsV2(value.components)
};
const response = await fetch('/api/owner/about', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
return { ok: false, error: await readError(response) };
}
return { ok: true };
}}
>
{({ value, setValue, error }) => (
<div className="space-y-6">
{!canEdit ? (
<p className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t('owner.about.readOnly')}
</p>
) : null}
<Card>
<CardHeader>
<CardTitle>{t('owner.about.editorTitle')}</CardTitle>
<CardDescription>{t('owner.about.editorDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-1 pr-4">
<Label>{t('owner.about.enabled')}</Label>
<p className="text-xs text-muted-foreground">{t('owner.about.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
disabled={readOnly}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="space-y-2">
<Label>{t('owner.about.messageType')}</Label>
<Select
value={value.messageType}
disabled={readOnly}
onValueChange={(messageType) =>
setValue((prev) => ({
...prev,
messageType: messageType as BotAboutConfig['messageType']
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TEXT">{t('owner.about.type.TEXT')}</SelectItem>
<SelectItem value="EMBED">{t('owner.about.type.EMBED')}</SelectItem>
<SelectItem value="COMPONENTS_V2">{t('owner.about.type.COMPONENTS_V2')}</SelectItem>
</SelectContent>
</Select>
</div>
{value.messageType === 'TEXT' ? (
<div className="space-y-2">
<Label>{t('owner.about.content')}</Label>
<Textarea
className="min-h-28"
disabled={readOnly}
value={value.content ?? ''}
onChange={(event) =>
setValue((prev) => ({ ...prev, content: event.target.value || null }))
}
placeholder={t('owner.about.contentPlaceholder')}
/>
</div>
) : null}
{value.messageType === 'EMBED' ? (
<div className="space-y-2">
<Label>{t('owner.about.embed')}</Label>
<DiscordEmbedBuilder
value={value.embed}
onChange={(embed) => setValue((prev) => ({ ...prev, embed }))}
previewVars={ABOUT_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
) : null}
{value.messageType === 'COMPONENTS_V2' ? (
<div className="space-y-2">
<Label>{t('owner.about.components')}</Label>
<p className="text-xs text-muted-foreground">{t('owner.about.componentsHint')}</p>
<DiscordComponentsV2Builder
value={value.components}
onChange={(components) => setValue((prev) => ({ ...prev, components }))}
previewVars={ABOUT_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
) : null}
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -5,6 +5,7 @@ import {
BookOpen,
Crown,
Flag,
Info,
LayoutDashboard,
ListTodo,
Radio,
@@ -26,6 +27,7 @@ export const OWNER_LINKS = [
{ href: '/owner/flags', icon: Flag, key: 'flags' },
{ href: '/owner/premium', icon: Crown, key: 'premium' },
{ href: '/owner/presence', icon: Radio, key: 'presence' },
{ href: '/owner/about', icon: Info, key: 'about' },
{ href: '/owner/team', icon: Users, key: 'team' },
{ href: '/owner/jobs', icon: ListTodo, key: 'jobs' },
{ href: '/owner/changelog', icon: BookOpen, key: 'changelog' },