Implement maintenance mode and presence updates in bot and WebUI

- Added maintenance mode functionality in the bot, allowing for a custom message and DND status when enabled.
- Updated the OwnerPresencePage to include read-only access for VIEWER roles, with save permissions restricted to ADMIN roles.
- Enhanced the PresenceForm component to support immediate application of changes and added a confirmation for maintenance mode activation.
- Introduced localization updates for presence-related messages in both English and German, improving user guidance.
- Implemented a presence refresh queue to handle updates efficiently after configuration changes.
This commit is contained in:
smueller
2026-07-24 11:11:58 +02:00
parent 211403d54d
commit e45642b6f9
13 changed files with 414 additions and 153 deletions

View File

@@ -1,12 +1,6 @@
'use client';
import type {
DashboardGuild,
MessageComponentsV2,
SessionUser,
WelcomeConfigDashboard,
WelcomeEmbed
} from '@nexumi/shared';
import type { MessageComponentsV2, WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -31,45 +25,6 @@ import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
function discordDefaultAvatarUrl(userId: string): string {
try {
const index = Number((BigInt(userId) >> 22n) % 6n);
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
} catch {
return 'https://cdn.discordapp.com/embed/avatars/0.png';
}
}
function userAvatarUrl(user: SessionUser): string {
return user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=256`
: discordDefaultAvatarUrl(user.id);
}
function guildIconUrl(guild: DashboardGuild): string {
return guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128`
: discordDefaultAvatarUrl(guild.id);
}
/** Preview placeholders from the logged-in dashboard user and current guild. */
export function buildWelcomePreviewVars(
user: SessionUser,
guild: DashboardGuild
): Record<string, string> {
const displayName = user.globalName?.trim() || user.username;
return {
user: `@${displayName}`,
'user.name': displayName,
'user.tag': user.username,
'user.id': user.id,
'user.avatar': userAvatarUrl(user),
server: guild.name,
'server.icon': guildIconUrl(guild),
memberCount: '—'
};
}
interface LocalWelcomeValue extends Omit<
WelcomeConfigDashboard,
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'

View File

@@ -3,115 +3,18 @@
import { useRouter } from 'next/navigation';
import { useMemo, useState, useTransition } from 'react';
import { toast } from 'sonner';
import type { BotPresenceConfig, OwnerGuildListItem, OwnerRole } from '@nexumi/shared';
import type { OwnerGuildListItem, OwnerRole } from '@nexumi/shared';
import { useTranslations } from '@/components/locale-provider';
import { SettingsForm } from '@/components/settings/settings-form';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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 PresenceForm({ initialValue }: { initialValue: BotPresenceConfig }) {
const t = useTranslations();
return (
<SettingsForm
initialValue={initialValue}
onSave={async (value) => {
const response = await fetch('/api/owner/presence', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
if (!response.ok) {
return { ok: false, error: await readError(response) };
}
return { ok: true };
}}
>
{({ value, setValue, error }) => (
<Card>
<CardContent className="space-y-4 pt-6">
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('owner.presence.status')}</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={value.status}
onChange={(e) => setValue({ ...value, status: e.target.value as BotPresenceConfig['status'] })}
>
{['online', 'idle', 'dnd', 'invisible'].map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityType')}</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={value.activityType}
onChange={(e) =>
setValue({ ...value, activityType: e.target.value as BotPresenceConfig['activityType'] })
}
>
{['Playing', 'Listening', 'Watching', 'Competing'].map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityText')}</Label>
<Input value={value.activityText} onChange={(e) => setValue({ ...value, activityText: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('owner.presence.rotating')}</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={value.rotatingMessages.join('\n')}
onChange={(e) =>
setValue({
...value,
rotatingMessages: e.target.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
})
}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value.maintenanceMode}
onChange={(e) => setValue({ ...value, maintenanceMode: e.target.checked })}
/>
{t('owner.presence.maintenanceMode')}
</label>
<div className="space-y-2">
<Label>{t('owner.presence.maintenanceMessage')}</Label>
<Input
value={value.maintenanceMessage ?? ''}
onChange={(e) => setValue({ ...value, maintenanceMessage: e.target.value || null })}
/>
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}
export function UsersManager({
initialUsers
}: {

View File

@@ -0,0 +1,249 @@
'use client';
import type { BotPresenceConfig } 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 { Input } from '@/components/ui/input';
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';
import { cn } from '@/lib/utils';
const STATUS_OPTIONS = ['online', 'idle', 'dnd', 'invisible'] as const;
const ACTIVITY_OPTIONS = ['Playing', 'Listening', 'Watching', 'Competing'] as const;
const ACTIVITY_TEXT_MAX = 128;
const MAINTENANCE_MESSAGE_MAX = 500;
const ROTATING_MAX = 20;
const STATUS_DOT: Record<BotPresenceConfig['status'], string> = {
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-red-500',
invisible: 'bg-zinc-500'
};
async function readError(response: Response): Promise<string> {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
return data?.error ?? 'Request failed';
}
function PresencePreview({ value }: { value: BotPresenceConfig }) {
const t = useTranslations();
const previewStatus = value.maintenanceMode ? 'dnd' : value.status;
const previewType = value.maintenanceMode
? t('owner.presence.activityTypeLabel.Watching')
: t(`owner.presence.activityTypeLabel.${value.activityType}`);
const previewText = value.maintenanceMode
? value.maintenanceMessage?.trim() || t('owner.presence.maintenanceFallbackActivity')
: value.rotatingMessages[0] ?? value.activityText;
return (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('owner.presence.previewTitle')}
</p>
{value.maintenanceMode ? (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
{t('owner.presence.maintenancePreviewBanner')}
</p>
) : null}
<div className="flex items-start gap-3">
<div className="relative mt-0.5 size-10 shrink-0 rounded-full bg-zinc-700">
<span
className={cn(
'absolute bottom-0 right-0 size-3 rounded-full border-2 border-zinc-900',
STATUS_DOT[previewStatus]
)}
title={t(`owner.presence.statusLabel.${previewStatus}`)}
/>
</div>
<div className="min-w-0">
<p className="font-medium">Nexumi</p>
<p className="truncate text-sm text-muted-foreground">
{previewType} {previewText}
</p>
{!value.maintenanceMode && value.rotatingMessages.length > 1 ? (
<p className="mt-1 text-xs text-muted-foreground">{t('owner.presence.previewRotatingHint')}</p>
) : null}
</div>
</div>
</div>
);
}
export function PresenceForm({
initialValue,
canEdit
}: {
initialValue: BotPresenceConfig;
canEdit: boolean;
}) {
const t = useTranslations();
const readOnly = !canEdit;
return (
<SettingsForm
initialValue={initialValue}
readOnly={readOnly}
onSave={async (value) => {
if (readOnly) {
return { ok: false, error: t('owner.presence.readOnly') };
}
const response = await fetch('/api/owner/presence', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
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.presence.readOnly')}
</p>
) : null}
<Card>
<CardHeader>
<CardTitle>{t('owner.presence.editorTitle')}</CardTitle>
<CardDescription>{t('owner.presence.editorDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<PresencePreview value={value} />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('owner.presence.status')}</Label>
<Select
value={value.status}
disabled={readOnly || value.maintenanceMode}
onValueChange={(status) =>
setValue((prev) => ({ ...prev, status: status as BotPresenceConfig['status'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{t(`owner.presence.statusLabel.${status}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityType')}</Label>
<Select
value={value.activityType}
disabled={readOnly || value.maintenanceMode}
onValueChange={(activityType) =>
setValue((prev) => ({
...prev,
activityType: activityType as BotPresenceConfig['activityType']
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVITY_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{t(`owner.presence.activityTypeLabel.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('owner.presence.activityText')}</Label>
<span className="text-xs text-muted-foreground">
{value.activityText.length}/{ACTIVITY_TEXT_MAX}
</span>
</div>
<Input
value={value.activityText}
disabled={readOnly || value.maintenanceMode}
maxLength={ACTIVITY_TEXT_MAX}
onChange={(e) => setValue((prev) => ({ ...prev, activityText: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">{t('owner.presence.activityTextHint')}</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('owner.presence.rotating')}</Label>
<span className="text-xs text-muted-foreground">
{value.rotatingMessages.length}/{ROTATING_MAX}
</span>
</div>
<Textarea
className="min-h-24"
disabled={readOnly || value.maintenanceMode}
value={value.rotatingMessages.join('\n')}
onChange={(e) =>
setValue((prev) => ({
...prev,
rotatingMessages: e.target.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.slice(0, ACTIVITY_TEXT_MAX))
.slice(0, ROTATING_MAX)
}))
}
/>
<p className="text-xs text-muted-foreground">{t('owner.presence.rotatingHint')}</p>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-1 pr-4">
<Label>{t('owner.presence.maintenanceMode')}</Label>
<p className="text-xs text-muted-foreground">{t('owner.presence.maintenanceHint')}</p>
</div>
<Switch
checked={value.maintenanceMode}
disabled={readOnly}
onCheckedChange={(checked) => {
if (checked && !window.confirm(t('owner.presence.maintenanceConfirm'))) {
return;
}
setValue((prev) => ({ ...prev, maintenanceMode: checked }));
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('owner.presence.maintenanceMessage')}</Label>
<span className="text-xs text-muted-foreground">
{(value.maintenanceMessage ?? '').length}/{MAINTENANCE_MESSAGE_MAX}
</span>
</div>
<Input
value={value.maintenanceMessage ?? ''}
disabled={readOnly || !value.maintenanceMode}
maxLength={MAINTENANCE_MESSAGE_MAX}
onChange={(e) =>
setValue((prev) => ({
...prev,
maintenanceMessage: e.target.value || null
}))
}
/>
</div>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -20,6 +20,8 @@ interface SettingsFormProps<T> {
onSave: (value: T) => Promise<SettingsSaveResult>;
children: (props: SettingsFormRenderProps<T>) => ReactNode;
isEqual?: (a: T, b: T) => boolean;
/** When true, draft editing UI still works locally but the sticky save bar is hidden. */
readOnly?: boolean;
}
function defaultIsEqual<T>(a: T, b: T): boolean {
@@ -31,14 +33,20 @@ function defaultIsEqual<T>(a: T, b: T): boolean {
* local draft against the last-saved baseline, shows a sticky save bar while
* dirty, and surfaces inline + toast errors on save failure.
*/
export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defaultIsEqual }: SettingsFormProps<T>) {
export function SettingsForm<T>({
initialValue,
onSave,
children,
isEqual = defaultIsEqual,
readOnly = false
}: SettingsFormProps<T>) {
const t = useTranslations();
const [baseline, setBaseline] = useState(initialValue);
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const isDirty = !isEqual(baseline, value);
const isDirty = !readOnly && !isEqual(baseline, value);
const handleDiscard = useCallback(() => {
setValue(baseline);
@@ -46,6 +54,9 @@ export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defa
}, [baseline]);
const handleSave = useCallback(() => {
if (readOnly) {
return;
}
setError(null);
startTransition(async () => {
try {
@@ -62,10 +73,10 @@ export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defa
toast.error(t('common.saveError'));
}
});
}, [onSave, t, value]);
}, [onSave, readOnly, t, value]);
return (
<div className="space-y-6 pb-20">
<div className={readOnly ? 'space-y-6' : 'space-y-6 pb-20'}>
{children({ value, setValue, isDirty, isSaving: isPending, error })}
<SaveBar visible={isDirty} saving={isPending} onSave={handleSave} onDiscard={handleDiscard} />
</div>