Enhance guild management features in the owner panel

- Updated the guilds API to support searching by both guild name and ID, improving user experience.
- Increased the number of guilds retrieved from the database from 100 to 200 for better visibility.
- Implemented invite creation functionality, allowing owners to generate invites directly from the guild management interface.
- Enhanced the UI to display guild names, icons, and member counts, providing a more informative overview.
- Updated localization files to reflect new search capabilities and invite-related messages in both English and German.
This commit is contained in:
smueller
2026-07-24 10:59:07 +02:00
parent 0ebe540c3f
commit 211403d54d
9 changed files with 359 additions and 76 deletions

View File

@@ -3,9 +3,10 @@
import { useRouter } from 'next/navigation';
import { useMemo, useState, useTransition } from 'react';
import { toast } from 'sonner';
import type { BotPresenceConfig, OwnerRole } from '@nexumi/shared';
import type { BotPresenceConfig, 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';
@@ -195,16 +196,23 @@ export function UsersManager({
export function GuildsManager({
initialGuilds
}: {
initialGuilds: Array<{ id: string; locale: string; createdAt: string; blacklisted: boolean }>;
initialGuilds: OwnerGuildListItem[];
}) {
const t = useTranslations();
const router = useRouter();
const [query, setQuery] = useState('');
const [pending, startTransition] = useTransition();
const filtered = useMemo(
() => initialGuilds.filter((guild) => guild.id.includes(query.trim())),
[initialGuilds, query]
);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) {
return initialGuilds;
}
return initialGuilds.filter(
(guild) =>
guild.id.includes(needle) ||
(guild.name?.toLowerCase().includes(needle) ?? false)
);
}, [initialGuilds, query]);
async function postAction(action: string, guildId: string, reason?: string) {
const response = await fetch('/api/owner/guilds', {
@@ -215,6 +223,7 @@ export function GuildsManager({
if (!response.ok) {
throw new Error(await readError(response));
}
return (await response.json()) as { ok?: boolean; url?: string };
}
return (
@@ -225,58 +234,103 @@ export function GuildsManager({
{filtered.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : (
filtered.map((guild) => (
<div key={guild.id} className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">{guild.id}</p>
<p className="text-muted-foreground">
{guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
</p>
filtered.map((guild) => {
const displayName = guild.name ?? t('owner.guilds.unknownName');
const initials = displayName.slice(0, 2).toUpperCase();
return (
<div
key={guild.id}
className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-center gap-3">
<Avatar className="h-10 w-10 shrink-0">
{guild.iconUrl ? <AvatarImage src={guild.iconUrl} alt="" /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{displayName}</p>
<p className="truncate text-muted-foreground">
<span className="font-mono text-xs">{guild.id}</span>
{' · '}
{guild.memberCount != null
? t('owner.guilds.members', { count: String(guild.memberCount) })
: t('owner.guilds.membersUnknown')}
{' · '}
{guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() =>
startTransition(async () => {
try {
const result = await postAction('createInvite', guild.id);
if (result.url) {
try {
await navigator.clipboard.writeText(result.url);
toast.success(t('owner.guilds.inviteCopied', { url: result.url }));
} catch {
toast.success(t('owner.guilds.inviteCreated', { url: result.url }));
}
} else {
toast.success(t('common.saveSuccess'));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t('common.saveError'));
}
})
}
>
{t('owner.guilds.createInvite')}
</Button>
<Button
size="sm"
variant="secondary"
disabled={pending}
onClick={() =>
startTransition(async () => {
try {
await postAction(guild.blacklisted ? 'unblacklist' : 'blacklist', guild.id, 'Owner panel');
toast.success(t('common.saveSuccess'));
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('common.saveError'));
}
})
}
>
{guild.blacklisted ? t('owner.guilds.unblacklist') : t('owner.guilds.blacklist')}
</Button>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
if (!window.confirm(t('owner.guilds.leaveConfirm'))) {
return;
}
try {
await postAction('leave', guild.id);
toast.success(t('common.saveSuccess'));
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('common.saveError'));
}
})
}
>
{t('owner.guilds.leave')}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
disabled={pending}
onClick={() =>
startTransition(async () => {
try {
await postAction(guild.blacklisted ? 'unblacklist' : 'blacklist', guild.id, 'Owner panel');
toast.success(t('common.saveSuccess'));
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('common.saveError'));
}
})
}
>
{guild.blacklisted ? t('owner.guilds.unblacklist') : t('owner.guilds.blacklist')}
</Button>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
if (!window.confirm(t('owner.guilds.leaveConfirm'))) {
return;
}
try {
await postAction('leave', guild.id);
toast.success(t('common.saveSuccess'));
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('common.saveError'));
}
})
}
>
{t('owner.guilds.leave')}
</Button>
</div>
</div>
))
);
})
)}
</CardContent>
</Card>