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:
@@ -120,6 +120,23 @@ export async function readPresenceConfig(context: BotContext): Promise<BotPresen
|
||||
|
||||
export async function applyBotPresence(context: BotContext): Promise<void> {
|
||||
const config = await readPresenceConfig(context);
|
||||
|
||||
if (config.maintenanceMode) {
|
||||
const text =
|
||||
config.maintenanceMessage?.trim() ||
|
||||
'Maintenance mode — please try again later.';
|
||||
await context.client.user?.setPresence({
|
||||
status: 'dnd',
|
||||
activities: [
|
||||
{
|
||||
name: text.slice(0, 128),
|
||||
type: ActivityType.Watching
|
||||
}
|
||||
]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const messages =
|
||||
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
|
||||
const index = Math.floor(Date.now() / 60_000) % messages.length;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { buildWelcomePreviewVars, WelcomeForm } from '@/components/modules/welcome-form';
|
||||
import { WelcomeForm } from '@/components/modules/welcome-form';
|
||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||
import { getManageableGuild } from '@/lib/guilds';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
|
||||
import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars';
|
||||
|
||||
interface WelcomePageProps {
|
||||
params: Promise<{ guildId: string }>;
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { PresenceForm } from '@/components/owner/owner-forms';
|
||||
import { ownerRoleAtLeast } from '@nexumi/shared';
|
||||
import { PresenceForm } from '@/components/owner/presence-form';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
|
||||
export default async function OwnerPresencePage() {
|
||||
const session = await requireOwnerOrRedirect('VIEWER');
|
||||
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
|
||||
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
|
||||
</div>
|
||||
<PresenceForm initialValue={config} />
|
||||
<PresenceForm initialValue={config} canEdit={canEdit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BotPresenceConfig
|
||||
} from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
import { getPresenceQueue } from './queues';
|
||||
import { redis } from './redis';
|
||||
|
||||
function mapRow(row: {
|
||||
@@ -63,5 +64,13 @@ export async function updatePresenceConfig(patch: unknown): Promise<BotPresenceC
|
||||
});
|
||||
const mapped = mapRow(row);
|
||||
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(mapped));
|
||||
await getPresenceQueue().add(
|
||||
'presenceRefresh',
|
||||
{},
|
||||
{
|
||||
removeOnComplete: 5,
|
||||
removeOnFail: 5
|
||||
}
|
||||
);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const suggestionsQueueName = 'suggestions';
|
||||
export const messagesQueueName = 'messages';
|
||||
export const verificationQueueName = 'verification';
|
||||
export const automodQueueName = 'automod';
|
||||
export const presenceQueueName = 'presence';
|
||||
|
||||
const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGiveawayQueue?: Queue;
|
||||
@@ -30,6 +31,7 @@ const globalForQueues = globalThis as unknown as {
|
||||
__nexumiMessagesQueue?: Queue;
|
||||
__nexumiVerificationQueue?: Queue;
|
||||
__nexumiAutomodQueue?: Queue;
|
||||
__nexumiPresenceQueue?: Queue;
|
||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||
@@ -94,6 +96,13 @@ export function getAutomodQueue(): Queue {
|
||||
return globalForQueues.__nexumiAutomodQueue;
|
||||
}
|
||||
|
||||
export function getPresenceQueue(): Queue {
|
||||
globalForQueues.__nexumiPresenceQueue ??= new Queue(presenceQueueName, {
|
||||
connection: queueConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiPresenceQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||
|
||||
40
apps/webui/src/lib/welcome-preview-vars.ts
Normal file
40
apps/webui/src/lib/welcome-preview-vars.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
|
||||
|
||||
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: '—'
|
||||
};
|
||||
}
|
||||
@@ -1034,12 +1034,35 @@
|
||||
"presence": {
|
||||
"title": "Bot-Präsenz",
|
||||
"subtitle": "Status, Aktivität und Wartungsmodus.",
|
||||
"editorTitle": "Präsenz bearbeiten",
|
||||
"editorDescription": "Änderungen gelten nach dem Speichern sofort für den Bot.",
|
||||
"previewTitle": "Vorschau",
|
||||
"previewRotatingHint": "Vorschau zeigt die erste rotierende Zeile; Discord wechselt ca. jede Minute.",
|
||||
"status": "Status",
|
||||
"activityType": "Aktivitätstyp",
|
||||
"activityText": "Aktivitätstext",
|
||||
"activityTextHint": "Fallback, wenn keine rotierenden Nachrichten gesetzt sind.",
|
||||
"rotating": "Rotierende Nachrichten (eine pro Zeile)",
|
||||
"rotatingHint": "Max. 20 Zeilen à 128 Zeichen. Wechsel ca. jede Minute.",
|
||||
"maintenanceMode": "Wartungsmodus aktiv",
|
||||
"maintenanceMessage": "Wartungsnachricht"
|
||||
"maintenanceMessage": "Wartungsnachricht",
|
||||
"maintenanceHint": "Slash-Commands antworten nur noch mit dem Wartungshinweis.",
|
||||
"maintenanceConfirm": "Wartungsmodus wirklich aktivieren? Nutzer können dann keine Commands mehr nutzen.",
|
||||
"maintenancePreviewBanner": "Wartungsmodus: Discord zeigt DND und den Wartungstext.",
|
||||
"maintenanceFallbackActivity": "Wartungsmodus",
|
||||
"readOnly": "Nur Ansicht – Speichern erfordert die Owner-Rolle Admin oder höher.",
|
||||
"statusLabel": {
|
||||
"online": "Online",
|
||||
"idle": "Abwesend",
|
||||
"dnd": "Bitte nicht stören",
|
||||
"invisible": "Unsichtbar"
|
||||
},
|
||||
"activityTypeLabel": {
|
||||
"Playing": "Spielt",
|
||||
"Listening": "Hört",
|
||||
"Watching": "Schaut",
|
||||
"Competing": "Tritt an in"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
|
||||
@@ -1034,12 +1034,35 @@
|
||||
"presence": {
|
||||
"title": "Bot presence",
|
||||
"subtitle": "Status, activity, and maintenance mode.",
|
||||
"editorTitle": "Edit presence",
|
||||
"editorDescription": "Changes apply to the bot immediately after saving.",
|
||||
"previewTitle": "Preview",
|
||||
"previewRotatingHint": "Preview shows the first rotating line; Discord rotates about every minute.",
|
||||
"status": "Status",
|
||||
"activityType": "Activity type",
|
||||
"activityText": "Activity text",
|
||||
"activityTextHint": "Fallback when no rotating messages are set.",
|
||||
"rotating": "Rotating messages (one per line)",
|
||||
"rotatingHint": "Max. 20 lines of 128 characters. Rotates about every minute.",
|
||||
"maintenanceMode": "Maintenance mode enabled",
|
||||
"maintenanceMessage": "Maintenance message"
|
||||
"maintenanceMessage": "Maintenance message",
|
||||
"maintenanceHint": "Slash commands will only reply with the maintenance notice.",
|
||||
"maintenanceConfirm": "Really enable maintenance mode? Users will not be able to use commands.",
|
||||
"maintenancePreviewBanner": "Maintenance mode: Discord shows DND and the maintenance text.",
|
||||
"maintenanceFallbackActivity": "Maintenance",
|
||||
"readOnly": "View only — saving requires Admin owner role or higher.",
|
||||
"statusLabel": {
|
||||
"online": "Online",
|
||||
"idle": "Idle",
|
||||
"dnd": "Do not disturb",
|
||||
"invisible": "Invisible"
|
||||
},
|
||||
"activityTypeLabel": {
|
||||
"Playing": "Playing",
|
||||
"Listening": "Listening to",
|
||||
"Watching": "Watching",
|
||||
"Competing": "Competing in"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
|
||||
@@ -320,6 +320,22 @@
|
||||
- [ ] Suche nach Servername filtert
|
||||
- [ ] Invite erstellen → Link im Toast/Clipboard; Bot braucht Create Instant Invite
|
||||
|
||||
## Post-Phase – Owner Presence WebUI Polish (Status: implementiert)
|
||||
|
||||
### Abgeschlossen (Code)
|
||||
|
||||
- PresenceForm: Live-Vorschau, i18n-Enums, Zeichenzähler, Switch+Confirm für Wartung, Hinweise
|
||||
- Sofort-Apply: WebUI enqueued `presenceRefresh` nach Save
|
||||
- Wartungsmodus: Discord DND + Wartungstext (keine Rotation)
|
||||
- VIEWER read-only auf `/owner/presence` (Save nur ADMIN+)
|
||||
|
||||
### Manuell testen
|
||||
|
||||
- [ ] Status/Text speichern → Discord-Präsenz innerhalb weniger Sekunden
|
||||
- [ ] Rotierende Zeilen → Vorschau erste Zeile; Job rotiert weiter
|
||||
- [ ] Wartung an → Confirm, Discord zeigt Wartungstext, kein Rotating
|
||||
- [ ] VIEWER sieht Formular, kann nicht speichern
|
||||
|
||||
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
||||
|
||||
### Abgeschlossen (Code)
|
||||
|
||||
Reference in New Issue
Block a user