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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}: {
|
||||
|
||||
249
apps/webui/src/components/owner/presence-form.tsx
Normal file
249
apps/webui/src/components/owner/presence-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user