Enhance dashboard overview with activity summary and sidebar commands
- Integrated activity summary into the Guild Overview page, displaying message count, voice minutes, and active user count for the last 7 days. - Updated sidebar to include a new "Commands" link for easier navigation. - Added corresponding localization keys for activity metrics in both English and German JSON files.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
|
||||
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal } from 'lucide-react';
|
||||
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
@@ -64,6 +64,10 @@ export function Sidebar({ guildId }: SidebarProps) {
|
||||
<ShieldCheck className="size-4" />
|
||||
{t('nav.access')}
|
||||
</NavLink>
|
||||
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`}>
|
||||
<Terminal className="size-4" />
|
||||
{t('nav.commands')}
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
{groups.map(({ group, modules }) => (
|
||||
|
||||
198
apps/webui/src/components/modules/commands-manager.tsx
Normal file
198
apps/webui/src/components/modules/commands-manager.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import type { CommandOverrideDashboard } from '@nexumi/shared';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
function toCsv(ids: string[]): string {
|
||||
return ids.join(', ');
|
||||
}
|
||||
|
||||
function fromCsv(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
interface LocalOverride extends CommandOverrideDashboard {
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
interface CommandsManagerProps {
|
||||
guildId: string;
|
||||
initialCommands: CommandOverrideDashboard[];
|
||||
}
|
||||
|
||||
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
|
||||
const t = useTranslations();
|
||||
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return commands;
|
||||
}
|
||||
return commands.filter((entry) => entry.commandName.toLowerCase().includes(query));
|
||||
}, [commands, search]);
|
||||
|
||||
function patchLocal(commandName: string, patch: Partial<LocalOverride>) {
|
||||
setCommands((prev) =>
|
||||
prev.map((entry) => (entry.commandName === commandName ? { ...entry, ...patch } : entry))
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave(entry: LocalOverride) {
|
||||
patchLocal(entry.commandName, { saving: true });
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: entry.enabled,
|
||||
allowedRoleIds: entry.allowedRoleIds,
|
||||
deniedRoleIds: entry.deniedRoleIds,
|
||||
allowedChannelIds: entry.allowedChannelIds,
|
||||
deniedChannelIds: entry.deniedChannelIds,
|
||||
cooldownSeconds: entry.cooldownSeconds
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as (CommandOverrideDashboard & { error?: string }) | null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
patchLocal(entry.commandName, body);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
} finally {
|
||||
patchLocal(entry.commandName, { saving: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(entry: LocalOverride) {
|
||||
patchLocal(entry.commandName, { saving: true });
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, { method: 'DELETE' });
|
||||
const body = (await response.json().catch(() => null)) as CommandOverrideDashboard | null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
patchLocal(entry.commandName, body);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
} finally {
|
||||
patchLocal(entry.commandName, { saving: false });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t('modulePages.commands.searchPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
|
||||
)}
|
||||
{filtered.map((entry) => (
|
||||
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={entry.enabled}
|
||||
disabled={entry.saving}
|
||||
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{entry.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entry.cooldownSeconds ?? ''}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, {
|
||||
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
|
||||
})
|
||||
}
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.allowedRoleIds)}
|
||||
onChange={(event) => patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.deniedRoleIds)}
|
||||
onChange={(event) => patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.allowedChannelIds)}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, { allowedChannelIds: fromCsv(event.target.value) })
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.deniedChannelIds)}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, { deniedChannelIds: fromCsv(event.target.value) })
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" disabled={entry.saving} onClick={() => handleReset(entry)}>
|
||||
<RotateCcw className="size-4" />
|
||||
{t('modulePages.commands.reset')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={entry.saving} onClick={() => handleSave(entry)}>
|
||||
{entry.saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user