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:
36
apps/webui/src/app/api/owner/about/route.ts
Normal file
36
apps/webui/src/app/api/owner/about/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { BotAboutConfigPatchSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { getAboutConfig, updateAboutConfig } from '@/lib/owner-about';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
return NextResponse.json(await getAboutConfig());
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = BotAboutConfigPatchSchema.parse(await request.json());
|
||||
const before = await getAboutConfig();
|
||||
const after = await updateAboutConfig(body);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'about.update',
|
||||
targetType: 'BotAboutConfig',
|
||||
targetId: 'singleton',
|
||||
before,
|
||||
after
|
||||
});
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
21
apps/webui/src/app/owner/about/page.tsx
Normal file
21
apps/webui/src/app/owner/about/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ownerRoleAtLeast } from '@nexumi/shared';
|
||||
import { AboutForm } from '@/components/owner/about-form';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getAboutConfig } from '@/lib/owner-about';
|
||||
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||
|
||||
export default async function OwnerAboutPage() {
|
||||
const session = await requireOwnerOrRedirect('VIEWER');
|
||||
const [locale, config] = await Promise.all([getLocale(), getAboutConfig()]);
|
||||
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.about.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.about.subtitle')}</p>
|
||||
</div>
|
||||
<AboutForm initialValue={config} canEdit={canEdit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
apps/webui/src/components/owner/about-form.tsx
Normal file
176
apps/webui/src/components/owner/about-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -940,6 +940,7 @@ export const OWNER_NAV_ENTRIES: SearchIndexEntry[] = [
|
||||
{ id: 'owner-flags', kind: 'page', labelKey: 'owner.nav.flags', href: '/owner/flags' },
|
||||
{ id: 'owner-premium', kind: 'page', labelKey: 'owner.nav.premium', href: '/owner/premium' },
|
||||
{ id: 'owner-presence', kind: 'page', labelKey: 'owner.nav.presence', href: '/owner/presence' },
|
||||
{ id: 'owner-about', kind: 'page', labelKey: 'owner.nav.about', href: '/owner/about' },
|
||||
{ id: 'owner-team', kind: 'page', labelKey: 'owner.nav.team', href: '/owner/team' },
|
||||
{ id: 'owner-jobs', kind: 'page', labelKey: 'owner.nav.jobs', href: '/owner/jobs' },
|
||||
{ id: 'owner-changelog', kind: 'page', labelKey: 'owner.nav.changelog', href: '/owner/changelog' },
|
||||
|
||||
78
apps/webui/src/lib/owner-about.ts
Normal file
78
apps/webui/src/lib/owner-about.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
ABOUT_REDIS_KEY,
|
||||
BotAboutConfigPatchSchema,
|
||||
BotAboutConfigSchema,
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
type BotAboutConfig,
|
||||
type MessageComponentsV2,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from './prisma';
|
||||
import { redis } from './redis';
|
||||
|
||||
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
const parsed = WelcomeEmbedSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function mapRow(row: {
|
||||
enabled: boolean;
|
||||
messageType: string;
|
||||
content: string | null;
|
||||
embed: unknown;
|
||||
components: unknown;
|
||||
}): BotAboutConfig {
|
||||
return BotAboutConfigSchema.parse({
|
||||
enabled: row.enabled,
|
||||
messageType: row.messageType,
|
||||
content: row.content,
|
||||
embed: parseEmbed(row.embed),
|
||||
components: parseComponents(row.components)
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAboutConfig(): Promise<BotAboutConfig> {
|
||||
const row = await prisma.botAboutConfig.upsert({
|
||||
where: { id: 'singleton' },
|
||||
create: { id: 'singleton' },
|
||||
update: {}
|
||||
});
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
export async function updateAboutConfig(patch: unknown): Promise<BotAboutConfig> {
|
||||
const data = BotAboutConfigPatchSchema.parse(patch);
|
||||
const current = await getAboutConfig();
|
||||
const next = { ...current, ...data };
|
||||
|
||||
const row = await prisma.botAboutConfig.upsert({
|
||||
where: { id: 'singleton' },
|
||||
create: {
|
||||
id: 'singleton',
|
||||
enabled: next.enabled,
|
||||
messageType: next.messageType,
|
||||
content: next.content,
|
||||
embed: next.embed === null ? Prisma.JsonNull : (next.embed as Prisma.InputJsonValue),
|
||||
components:
|
||||
next.components === null ? Prisma.JsonNull : (next.components as Prisma.InputJsonValue)
|
||||
},
|
||||
update: {
|
||||
enabled: next.enabled,
|
||||
messageType: next.messageType,
|
||||
content: next.content,
|
||||
embed: next.embed === null ? Prisma.JsonNull : (next.embed as Prisma.InputJsonValue),
|
||||
components:
|
||||
next.components === null ? Prisma.JsonNull : (next.components as Prisma.InputJsonValue)
|
||||
}
|
||||
});
|
||||
const mapped = mapRow(row);
|
||||
await redis.set(ABOUT_REDIS_KEY, JSON.stringify(mapped));
|
||||
return mapped;
|
||||
}
|
||||
@@ -968,6 +968,7 @@
|
||||
"flags": "Feature-Flags",
|
||||
"premium": "Premium",
|
||||
"presence": "Präsenz",
|
||||
"about": "About",
|
||||
"team": "Team",
|
||||
"jobs": "Jobs",
|
||||
"changelog": "Changelog",
|
||||
@@ -1070,6 +1071,26 @@
|
||||
"Competing": "Tritt an in"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "About /about",
|
||||
"subtitle": "Inhalt der globalen /about-Antwort im Discord.",
|
||||
"editorTitle": "About-Nachricht",
|
||||
"editorDescription": "TEXT, Embed oder Components V2 – erscheint bei /about auf allen Servern.",
|
||||
"enabled": "About aktiv",
|
||||
"enabledHint": "Wenn aus, antwortet /about mit einem Deaktiviert-Hinweis.",
|
||||
"messageType": "Nachrichtentyp",
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"content": "Textinhalt",
|
||||
"contentPlaceholder": "Kurztext über Nexumi…",
|
||||
"embed": "Embed",
|
||||
"components": "Components V2",
|
||||
"componentsHint": "Rollen-Aktionen wirken im Server, in dem /about ausgeführt wird. Ohne Guild-Kontext keine Rollen-Auswahl hier.",
|
||||
"readOnly": "Nur Ansicht – Speichern erfordert die Owner-Rolle Admin oder höher."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"subtitle": "Weitere Owner/Admins mit abgestuften Rechten.",
|
||||
|
||||
@@ -968,6 +968,7 @@
|
||||
"flags": "Feature flags",
|
||||
"premium": "Premium",
|
||||
"presence": "Presence",
|
||||
"about": "About",
|
||||
"team": "Team",
|
||||
"jobs": "Jobs",
|
||||
"changelog": "Changelog",
|
||||
@@ -1070,6 +1071,26 @@
|
||||
"Competing": "Competing in"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "About /about",
|
||||
"subtitle": "Content of the global /about reply in Discord.",
|
||||
"editorTitle": "About message",
|
||||
"editorDescription": "TEXT, embed, or Components V2 — shown for /about on every server.",
|
||||
"enabled": "About enabled",
|
||||
"enabledHint": "When off, /about replies with a disabled notice.",
|
||||
"messageType": "Message type",
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"content": "Text content",
|
||||
"contentPlaceholder": "Short text about Nexumi…",
|
||||
"embed": "Embed",
|
||||
"components": "Components V2",
|
||||
"componentsHint": "Role actions apply in the server where /about is used. No role picker here without a guild context.",
|
||||
"readOnly": "View only — saving requires Admin owner role or higher."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"subtitle": "Additional owners/admins with graded permissions.",
|
||||
|
||||
Reference in New Issue
Block a user