deploy #2
@@ -120,6 +120,23 @@ export async function readPresenceConfig(context: BotContext): Promise<BotPresen
|
|||||||
|
|
||||||
export async function applyBotPresence(context: BotContext): Promise<void> {
|
export async function applyBotPresence(context: BotContext): Promise<void> {
|
||||||
const config = await readPresenceConfig(context);
|
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 =
|
const messages =
|
||||||
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
|
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
|
||||||
const index = Math.floor(Date.now() / 60_000) % messages.length;
|
const index = Math.floor(Date.now() / 60_000) % messages.length;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { notFound } from 'next/navigation';
|
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 { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||||
import { getManageableGuild } from '@/lib/guilds';
|
import { getManageableGuild } from '@/lib/guilds';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
|
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
|
||||||
|
import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars';
|
||||||
|
|
||||||
interface WelcomePageProps {
|
interface WelcomePageProps {
|
||||||
params: Promise<{ guildId: string }>;
|
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 { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||||
|
|
||||||
export default async function OwnerPresencePage() {
|
export default async function OwnerPresencePage() {
|
||||||
|
const session = await requireOwnerOrRedirect('VIEWER');
|
||||||
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
|
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
|
||||||
|
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
|
<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>
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<PresenceForm initialValue={config} />
|
<PresenceForm initialValue={config} canEdit={canEdit} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type {
|
import type { MessageComponentsV2, WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
|
||||||
DashboardGuild,
|
|
||||||
MessageComponentsV2,
|
|
||||||
SessionUser,
|
|
||||||
WelcomeConfigDashboard,
|
|
||||||
WelcomeEmbed
|
|
||||||
} from '@nexumi/shared';
|
|
||||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } 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<
|
interface LocalWelcomeValue extends Omit<
|
||||||
WelcomeConfigDashboard,
|
WelcomeConfigDashboard,
|
||||||
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'
|
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'
|
||||||
|
|||||||
@@ -3,115 +3,18 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMemo, useState, useTransition } from 'react';
|
import { useMemo, useState, useTransition } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 { useTranslations } from '@/components/locale-provider';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
|
|
||||||
async function readError(response: Response): Promise<string> {
|
async function readError(response: Response): Promise<string> {
|
||||||
const data = (await response.json().catch(() => null)) as { error?: string } | null;
|
const data = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
return data?.error ?? 'Request failed';
|
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({
|
export function UsersManager({
|
||||||
initialUsers
|
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>;
|
onSave: (value: T) => Promise<SettingsSaveResult>;
|
||||||
children: (props: SettingsFormRenderProps<T>) => ReactNode;
|
children: (props: SettingsFormRenderProps<T>) => ReactNode;
|
||||||
isEqual?: (a: T, b: T) => boolean;
|
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 {
|
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
|
* local draft against the last-saved baseline, shows a sticky save bar while
|
||||||
* dirty, and surfaces inline + toast errors on save failure.
|
* 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 t = useTranslations();
|
||||||
const [baseline, setBaseline] = useState(initialValue);
|
const [baseline, setBaseline] = useState(initialValue);
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const isDirty = !isEqual(baseline, value);
|
const isDirty = !readOnly && !isEqual(baseline, value);
|
||||||
|
|
||||||
const handleDiscard = useCallback(() => {
|
const handleDiscard = useCallback(() => {
|
||||||
setValue(baseline);
|
setValue(baseline);
|
||||||
@@ -46,6 +54,9 @@ export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defa
|
|||||||
}, [baseline]);
|
}, [baseline]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
|
if (readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -62,10 +73,10 @@ export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defa
|
|||||||
toast.error(t('common.saveError'));
|
toast.error(t('common.saveError'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [onSave, t, value]);
|
}, [onSave, readOnly, t, value]);
|
||||||
|
|
||||||
return (
|
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 })}
|
{children({ value, setValue, isDirty, isSaving: isPending, error })}
|
||||||
<SaveBar visible={isDirty} saving={isPending} onSave={handleSave} onDiscard={handleDiscard} />
|
<SaveBar visible={isDirty} saving={isPending} onSave={handleSave} onDiscard={handleDiscard} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type BotPresenceConfig
|
type BotPresenceConfig
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import { prisma } from './prisma';
|
import { prisma } from './prisma';
|
||||||
|
import { getPresenceQueue } from './queues';
|
||||||
import { redis } from './redis';
|
import { redis } from './redis';
|
||||||
|
|
||||||
function mapRow(row: {
|
function mapRow(row: {
|
||||||
@@ -63,5 +64,13 @@ export async function updatePresenceConfig(patch: unknown): Promise<BotPresenceC
|
|||||||
});
|
});
|
||||||
const mapped = mapRow(row);
|
const mapped = mapRow(row);
|
||||||
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(mapped));
|
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(mapped));
|
||||||
|
await getPresenceQueue().add(
|
||||||
|
'presenceRefresh',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
removeOnComplete: 5,
|
||||||
|
removeOnFail: 5
|
||||||
|
}
|
||||||
|
);
|
||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const suggestionsQueueName = 'suggestions';
|
|||||||
export const messagesQueueName = 'messages';
|
export const messagesQueueName = 'messages';
|
||||||
export const verificationQueueName = 'verification';
|
export const verificationQueueName = 'verification';
|
||||||
export const automodQueueName = 'automod';
|
export const automodQueueName = 'automod';
|
||||||
|
export const presenceQueueName = 'presence';
|
||||||
|
|
||||||
const globalForQueues = globalThis as unknown as {
|
const globalForQueues = globalThis as unknown as {
|
||||||
__nexumiGiveawayQueue?: Queue;
|
__nexumiGiveawayQueue?: Queue;
|
||||||
@@ -30,6 +31,7 @@ const globalForQueues = globalThis as unknown as {
|
|||||||
__nexumiMessagesQueue?: Queue;
|
__nexumiMessagesQueue?: Queue;
|
||||||
__nexumiVerificationQueue?: Queue;
|
__nexumiVerificationQueue?: Queue;
|
||||||
__nexumiAutomodQueue?: Queue;
|
__nexumiAutomodQueue?: Queue;
|
||||||
|
__nexumiPresenceQueue?: Queue;
|
||||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
@@ -94,6 +96,13 @@ export function getAutomodQueue(): Queue {
|
|||||||
return globalForQueues.__nexumiAutomodQueue;
|
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
|
* 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
|
* 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": {
|
"presence": {
|
||||||
"title": "Bot-Präsenz",
|
"title": "Bot-Präsenz",
|
||||||
"subtitle": "Status, Aktivität und Wartungsmodus.",
|
"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",
|
"status": "Status",
|
||||||
"activityType": "Aktivitätstyp",
|
"activityType": "Aktivitätstyp",
|
||||||
"activityText": "Aktivitätstext",
|
"activityText": "Aktivitätstext",
|
||||||
|
"activityTextHint": "Fallback, wenn keine rotierenden Nachrichten gesetzt sind.",
|
||||||
"rotating": "Rotierende Nachrichten (eine pro Zeile)",
|
"rotating": "Rotierende Nachrichten (eine pro Zeile)",
|
||||||
|
"rotatingHint": "Max. 20 Zeilen à 128 Zeichen. Wechsel ca. jede Minute.",
|
||||||
"maintenanceMode": "Wartungsmodus aktiv",
|
"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": {
|
"team": {
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
|
|||||||
@@ -1034,12 +1034,35 @@
|
|||||||
"presence": {
|
"presence": {
|
||||||
"title": "Bot presence",
|
"title": "Bot presence",
|
||||||
"subtitle": "Status, activity, and maintenance mode.",
|
"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",
|
"status": "Status",
|
||||||
"activityType": "Activity type",
|
"activityType": "Activity type",
|
||||||
"activityText": "Activity text",
|
"activityText": "Activity text",
|
||||||
|
"activityTextHint": "Fallback when no rotating messages are set.",
|
||||||
"rotating": "Rotating messages (one per line)",
|
"rotating": "Rotating messages (one per line)",
|
||||||
|
"rotatingHint": "Max. 20 lines of 128 characters. Rotates about every minute.",
|
||||||
"maintenanceMode": "Maintenance mode enabled",
|
"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": {
|
"team": {
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
|
|||||||
@@ -320,6 +320,22 @@
|
|||||||
- [ ] Suche nach Servername filtert
|
- [ ] Suche nach Servername filtert
|
||||||
- [ ] Invite erstellen → Link im Toast/Clipboard; Bot braucht Create Instant Invite
|
- [ ] 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)
|
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
||||||
|
|
||||||
### Abgeschlossen (Code)
|
### Abgeschlossen (Code)
|
||||||
|
|||||||
Reference in New Issue
Block a user