Add new features and enhancements to the WebUI and dashboard components

- Updated package.json to include new dependencies: `@radix-ui/react-dialog` and `cmdk`.
- Enhanced global styles in globals.css for improved visual depth in dark mode.
- Refactored dashboard page components to improve layout and accessibility, including new icons and responsive design adjustments.
- Implemented suspense handling in commands page for better loading states.
- Improved sidebar navigation with new icons and mobile responsiveness.
- Added FieldAnchor components across various forms for better accessibility and structure.
- Enhanced commands manager with search parameter handling for improved user experience.
- Updated multiple module forms to include FieldAnchor for consistent layout and accessibility.
This commit is contained in:
TheOnlyMace
2026-07-22 19:44:55 +02:00
parent f1d74f922d
commit 75ac91eecc
49 changed files with 2393 additions and 320 deletions

View File

@@ -0,0 +1,176 @@
'use client';
import { Search, Terminal, Settings2, LayoutGrid } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from '@/components/locale-provider';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command';
import {
OWNER_NAV_ENTRIES,
buildGuildSearchHref,
buildGuildSearchIndex,
type SearchIndexEntry,
type SearchItemKind
} from '@/lib/dashboard-search-index';
function kindIcon(kind: SearchItemKind) {
switch (kind) {
case 'command':
return Terminal;
case 'setting':
return Settings2;
default:
return LayoutGrid;
}
}
interface DashboardCommandPaletteProps {
guildId?: string;
mode?: 'guild' | 'owner';
}
export function DashboardCommandPalette({ guildId, mode = 'guild' }: DashboardCommandPaletteProps) {
const t = useTranslations();
const router = useRouter();
const [open, setOpen] = useState(false);
const entries = useMemo(() => {
if (mode === 'owner') {
return OWNER_NAV_ENTRIES;
}
return buildGuildSearchIndex();
}, [mode]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
setOpen((prev) => !prev);
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const resolveLabel = useCallback(
(entry: SearchIndexEntry) => {
if (entry.labelOverride) {
return entry.labelOverride;
}
if (entry.labelKey) {
return t(entry.labelKey);
}
return entry.id;
},
[t]
);
function navigateTo(entry: SearchIndexEntry) {
setOpen(false);
if (mode === 'owner') {
router.push(entry.href);
return;
}
if (!guildId) {
return;
}
const href = buildGuildSearchHref(guildId, entry);
router.push(href);
if (entry.hash) {
window.setTimeout(() => {
document.getElementById(entry.hash!)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 250);
}
}
const pages = entries.filter((e) => e.kind === 'page');
const commands = entries.filter((e) => e.kind === 'command');
const settings = entries.filter((e) => e.kind === 'setting');
const isMac =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 w-full max-w-xs items-center gap-2 rounded-md border border-input bg-background/60 px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Search className="size-4 shrink-0" />
<span className="flex-1 truncate text-left">{t('search.placeholder')}</span>
<kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:inline-flex">
{isMac ? '⌘' : 'Ctrl'}K
</kbd>
</button>
<CommandDialog open={open} onOpenChange={setOpen} title={t('search.title')}>
<CommandInput placeholder={t('search.placeholder')} />
<CommandList>
<CommandEmpty>{t('search.empty')}</CommandEmpty>
{pages.length > 0 ? (
<CommandGroup heading={t('search.groups.pages')}>
{pages.map((entry) => {
const Icon = kindIcon(entry.kind);
const label = resolveLabel(entry);
return (
<CommandItem
key={entry.id}
value={`${label} ${entry.keywords?.join(' ') ?? ''} ${entry.labelKey ?? ''}`}
onSelect={() => navigateTo(entry)}
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
{commands.length > 0 ? (
<CommandGroup heading={t('search.groups.commands')}>
{commands.map((entry) => {
const Icon = kindIcon(entry.kind);
const label = resolveLabel(entry);
return (
<CommandItem
key={entry.id}
value={`${label} ${entry.keywords?.join(' ') ?? ''}`}
onSelect={() => navigateTo(entry)}
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
{settings.length > 0 ? (
<CommandGroup heading={t('search.groups.settings')}>
{settings.map((entry) => {
const Icon = kindIcon(entry.kind);
const label = resolveLabel(entry);
return (
<CommandItem
key={entry.id}
value={`${label} ${entry.keywords?.join(' ') ?? ''} ${entry.labelKey ?? ''}`}
onSelect={() => navigateTo(entry)}
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
</CommandList>
</CommandDialog>
</>
);
}

View File

@@ -1,8 +1,16 @@
'use client';
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
import type { ReactNode } from 'react';
import { ServerSwitcher } from './server-switcher';
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
import { Menu } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
import { HashScroll } from '@/components/layout/field-anchor';
import { ServerSwitcher } from '@/components/layout/server-switcher';
import { Sidebar, SidebarNav } from '@/components/layout/sidebar';
import { UserMenu } from '@/components/layout/user-menu';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { useTranslations } from '@/components/locale-provider';
interface DashboardShellProps {
guildId: string;
@@ -21,15 +29,36 @@ export function DashboardShell({
showOwnerLink = false,
children
}: DashboardShellProps) {
const t = useTranslations();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="flex min-h-screen">
<div className="flex min-h-screen bg-background">
<HashScroll />
<Sidebar guildId={guildId} />
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b border-border px-6">
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-40 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:px-6">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden" aria-label={t('search.openNav')}>
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0">
<SheetTitle className="sr-only">{t('nav.dashboard')}</SheetTitle>
<SidebarNav guildId={guildId} onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
<UserMenu user={user} showOwnerLink={showOwnerLink} />
<div className="ml-auto flex min-w-0 items-center gap-3">
<div className="min-w-0 flex-1 sm:flex-initial">
<DashboardCommandPalette guildId={guildId} />
</div>
<UserMenu user={user} showOwnerLink={showOwnerLink} />
</div>
</header>
<main className="flex-1 px-6 py-8">
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
</main>
</div>

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
/** Stable scroll target for command-palette deep links (`#field-…`). */
export function FieldAnchor({
id,
children,
className
}: {
id: string;
children: ReactNode;
className?: string;
}) {
return (
<div id={id} className={cn('scroll-mt-24', className)}>
{children}
</div>
);
}
/** Scrolls to `location.hash` after navigation from the command palette. */
export function HashScroll() {
useEffect(() => {
function scrollToHash() {
const hash = window.location.hash.replace(/^#/, '');
if (!hash) {
return;
}
const el = document.getElementById(hash);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
scrollToHash();
window.addEventListener('hashchange', scrollToHash);
const timer = window.setTimeout(scrollToHash, 120);
return () => {
window.removeEventListener('hashchange', scrollToHash);
window.clearTimeout(timer);
};
}, []);
return null;
}

View File

@@ -1,7 +1,35 @@
'use client';
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react';
import { DASHBOARD_MODULES, type DashboardModuleGroup, type DashboardModuleId } from '@nexumi/shared';
import {
Archive,
BarChart3,
Cake,
CalendarClock,
Coins,
Gamepad2,
Gift,
Hash,
LayoutGrid,
MessageSquareQuote,
MessagesSquare,
PartyPopper,
Radio,
Rss,
Settings,
Shield,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Star,
Terminal,
Ticket,
UserCheck,
Volume2,
type LucideIcon
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from '@/components/locale-provider';
@@ -15,19 +43,55 @@ const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
'integrations'
];
interface SidebarProps {
const MODULE_ICONS: Record<DashboardModuleId, LucideIcon> = {
moderation: Shield,
automod: ShieldAlert,
logging: Hash,
welcome: PartyPopper,
verification: UserCheck,
leveling: Sparkles,
economy: Coins,
fun: Gamepad2,
giveaways: Gift,
tickets: Ticket,
selfroles: UserCheck,
tags: MessageSquareQuote,
starboard: Star,
suggestions: MessagesSquare,
birthdays: Cake,
tempvoice: Volume2,
stats: BarChart3,
feeds: Rss,
scheduler: CalendarClock,
guildbackup: Archive
};
interface SidebarNavProps {
guildId: string;
onNavigate?: () => void;
className?: string;
}
function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
function NavLink({
href,
active,
onNavigate,
children
}: {
href: string;
active: boolean;
onNavigate?: () => void;
children: React.ReactNode;
}) {
return (
<Link
href={href}
onClick={onNavigate}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
: 'border-l-2 border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)}
>
{children}
@@ -35,7 +99,7 @@ function NavLink({ href, active, children }: { href: string; active: boolean; ch
);
}
export function Sidebar({ guildId }: SidebarProps) {
export function SidebarNav({ guildId, onNavigate, className }: SidebarNavProps) {
const pathname = usePathname();
const t = useTranslations();
const base = `/dashboard/${guildId}`;
@@ -46,39 +110,50 @@ export function Sidebar({ guildId }: SidebarProps) {
}));
return (
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
<div className="flex flex-col gap-1">
<NavLink href={base} active={pathname === base}>
<LayoutGrid className="size-4" />
<nav className={cn('flex h-full flex-col gap-5 overflow-y-auto px-3 py-4', className)}>
<Link
href={base}
onClick={onNavigate}
className="mb-1 flex items-center gap-2.5 rounded-md px-2.5 py-1.5 transition-opacity hover:opacity-90"
>
<Image src="/nexumi-logo.svg" alt="" width={28} height={28} className="size-7" />
<span className="text-base font-semibold tracking-tight">Nexumi</span>
</Link>
<div className="flex flex-col gap-0.5">
<NavLink href={base} active={pathname === base} onNavigate={onNavigate}>
<LayoutGrid className="size-4 shrink-0" />
{t('nav.overview')}
</NavLink>
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}>
<Settings className="size-4" />
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`} onNavigate={onNavigate}>
<Settings className="size-4 shrink-0" />
{t('nav.settings')}
</NavLink>
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}>
<SlidersHorizontal className="size-4" />
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`} onNavigate={onNavigate}>
<SlidersHorizontal className="size-4 shrink-0" />
{t('nav.modules')}
</NavLink>
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}>
<ShieldCheck className="size-4" />
<NavLink href={`${base}/access`} active={pathname === `${base}/access`} onNavigate={onNavigate}>
<ShieldCheck className="size-4 shrink-0" />
{t('nav.access')}
</NavLink>
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`}>
<Terminal className="size-4" />
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`} onNavigate={onNavigate}>
<Terminal className="size-4 shrink-0" />
{t('nav.commands')}
</NavLink>
</div>
{groups.map(({ group, modules }) => (
<div key={group} className="flex flex-col gap-1">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<div key={group} className="flex flex-col gap-0.5">
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
{t(`moduleGroups.${group}`)}
</p>
{modules.map((module) => {
const href = `${base}/${module.href}`;
const Icon = MODULE_ICONS[module.id] ?? Radio;
return (
<NavLink key={module.id} href={href} active={pathname === href}>
<NavLink key={module.id} href={href} active={pathname === href} onNavigate={onNavigate}>
<Icon className="size-4 shrink-0" />
{t(`modules.${module.id}.label`)}
</NavLink>
);
@@ -88,3 +163,12 @@ export function Sidebar({ guildId }: SidebarProps) {
</nav>
);
}
/** Desktop sidebar chrome. */
export function Sidebar({ guildId }: { guildId: string }) {
return (
<aside className="hidden h-screen w-60 shrink-0 border-r border-border bg-card/40 lg:sticky lg:top-0 lg:block">
<SidebarNav guildId={guildId} />
</aside>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import type { AutomodConfigDashboard } 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';
import { Input } from '@/components/ui/input';
@@ -44,19 +45,22 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.enabledHint')}</p>
<FieldAnchor id="field-automod-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
<FieldAnchor id="field-anti-raid">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
@@ -97,7 +101,9 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-anti-nuke">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
@@ -146,18 +152,21 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.lockdownActive')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
<FieldAnchor id="field-lockdown">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.lockdownActive')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
</div>
<Switch
checked={value.lockdownActive}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
/>
</div>
<Switch
checked={value.lockdownActive}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
</FieldAnchor>
</div>
)}
</SettingsForm>

View File

@@ -2,6 +2,7 @@
import type { BirthdayConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -48,28 +49,38 @@ export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-birthdays-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-birthdays-channel">
<div className="space-y-2">
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label>
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<FieldAnchor id="field-birthdays-role">
<div className="space-y-2">
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label>
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<FieldAnchor id="field-birthdays-timezone">
<div className="space-y-2">
<Label htmlFor={timezoneId}>{t('modulePages.birthdays.timezone')}</Label>
<Input id={timezoneId} value={value.timezone} onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))} placeholder="Europe/Berlin" />
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-birthdays-message">
<div className="space-y-2">
<Label>{t('modulePages.birthdays.message')}</Label>
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -3,6 +3,7 @@
import type { CaseDashboard } from '@nexumi/shared';
import { Search } from 'lucide-react';
import { useCallback, useState } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -48,6 +49,7 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
}, [action, guildId, search, t]);
return (
<FieldAnchor id="field-mod-cases">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
@@ -126,5 +128,6 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
)}
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -2,7 +2,8 @@
import type { CommandOverrideDashboard } from '@nexumi/shared';
import { RotateCcw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
@@ -32,9 +33,17 @@ interface CommandsManagerProps {
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
const t = useTranslations();
const searchParams = useSearchParams();
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
const [search, setSearch] = useState('');
useEffect(() => {
const q = searchParams.get('q');
if (q) {
setSearch(q);
}
}, [searchParams]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) {
@@ -64,7 +73,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
cooldownSeconds: entry.cooldownSeconds
})
});
const body = (await response.json().catch(() => null)) as (CommandOverrideDashboard & { error?: string }) | null;
const body = (await response.json().catch(() => null)) as
| (CommandOverrideDashboard & { error?: string })
| null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
@@ -81,7 +92,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
async function handleReset(entry: LocalOverride) {
patchLocal(entry.commandName, { saving: true });
try {
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, { method: 'DELETE' });
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'));
@@ -147,7 +160,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
<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) })}
onChange={(event) =>
patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
@@ -155,7 +170,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
<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) })}
onChange={(event) =>
patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
@@ -164,7 +181,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
<Input
value={toCsv(entry.allowedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, { allowedChannelIds: fromCsv(event.target.value) })
patchLocal(entry.commandName, {
allowedChannelIds: fromCsv(event.target.value)
})
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
@@ -174,18 +193,32 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
<Input
value={toCsv(entry.deniedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, { deniedChannelIds: fromCsv(event.target.value) })
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)}>
<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)}>
<Button
type="button"
variant="outline"
size="sm"
disabled={entry.saving}
onClick={() => handleSave(entry)}
>
{entry.saving ? t('common.saving') : t('common.save')}
</Button>
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import type { EconomyConfigDashboard } 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';
import { Input } from '@/components/ui/input';
@@ -44,6 +45,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-economy-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.economy.enabledLabel')}</Label>
<Switch
@@ -51,7 +53,9 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-economy-currency-name">
<div className="space-y-2">
<Label>{t('modulePages.economy.currencyName')}</Label>
<Input
@@ -59,6 +63,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-currency-symbol">
<div className="space-y-2">
<Label>{t('modulePages.economy.currencySymbol')}</Label>
<Input
@@ -66,6 +72,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
/>
</div>
</FieldAnchor>
</div>
</CardContent>
</Card>
@@ -75,6 +82,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-economy-daily">
<div className="space-y-2">
<Label>{t('modulePages.economy.dailyAmount')}</Label>
<Input
@@ -84,6 +92,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-weekly">
<div className="space-y-2">
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
<Input
@@ -93,6 +103,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-work-min">
<div className="space-y-2">
<Label>{t('modulePages.economy.workMin')}</Label>
<Input
@@ -102,6 +114,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-work-max">
<div className="space-y-2">
<Label>{t('modulePages.economy.workMax')}</Label>
<Input
@@ -111,6 +125,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-work-cooldown">
<div className="space-y-2">
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
<Input
@@ -122,6 +138,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-economy-starting">
<div className="space-y-2">
<Label>{t('modulePages.economy.startingBalance')}</Label>
<Input
@@ -133,6 +151,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
</div>

View File

@@ -4,6 +4,7 @@ import type { SocialFeedDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -152,6 +153,7 @@ export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) {
</div>
))}
<FieldAnchor id="field-feeds-add">
<div className="space-y-3 rounded-md border border-dashed border-border p-4">
<p className="text-sm font-medium">{t('modulePages.feeds.addFeed')}</p>
<div className="grid gap-3 sm:grid-cols-2">
@@ -208,6 +210,7 @@ export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) {
{t('modulePages.feeds.addFeed')}
</Button>
</div>
</FieldAnchor>
</CardContent>
</Card>
);

View File

@@ -1,6 +1,7 @@
'use client';
import type { FunConfigDashboard } 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';
import { Label } from '@/components/ui/label';
@@ -42,6 +43,7 @@ export function FunForm({ guildId, initialValue }: FunFormProps) {
<CardDescription>{t('modulePages.fun.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-fun-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.fun.enabledLabel')}</Label>
@@ -52,6 +54,8 @@ export function FunForm({ guildId, initialValue }: FunFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-fun-media">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.fun.mediaEnabledLabel')}</Label>
@@ -62,6 +66,7 @@ export function FunForm({ guildId, initialValue }: FunFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, mediaEnabled: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -4,6 +4,7 @@ import type { GiveawayDashboard } from '@nexumi/shared';
import { Pause, Play, Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -116,6 +117,7 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
return (
<div className="space-y-6">
<FieldAnchor id="field-giveaways-create">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.giveaways.createTitle')}</CardTitle>
@@ -187,6 +189,9 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
</Button>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-giveaways-list">
<Card>
<CardHeader>
@@ -281,6 +286,7 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
})}
</CardContent>
</Card>
</FieldAnchor>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import type { GuildBackupDashboard } from '@nexumi/shared';
import { Download, Info, Plus, RotateCcw, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -130,6 +131,7 @@ export function GuildBackupManager({ guildId, initialBackups, isOwner }: GuildBa
}
return (
<FieldAnchor id="field-backup">
<div className="space-y-6">
<Card>
<CardHeader>
@@ -211,5 +213,6 @@ export function GuildBackupManager({ guildId, initialBackups, isOwner }: GuildBa
</CardContent>
</Card>
</div>
</FieldAnchor>
);
}

View File

@@ -2,6 +2,7 @@
import type { LevelingConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -126,6 +127,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
<CardDescription>{t('modulePages.leveling.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-leveling-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
<Switch
@@ -133,8 +135,10 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-leveling-text-min">
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMin')}</Label>
<Input
@@ -144,6 +148,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-text-max">
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMax')}</Label>
<Input
@@ -153,6 +159,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-cooldown">
<div className="space-y-2">
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
<Input
@@ -164,8 +172,10 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
}
/>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-leveling-voice">
<div className="space-y-2">
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
<Input
@@ -178,7 +188,9 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-no-xp-channels">
<div className="space-y-2">
<Label htmlFor={noXpChannelsId}>{t('modulePages.leveling.noXpChannels')}</Label>
<Textarea
@@ -188,6 +200,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-no-xp-roles">
<div className="space-y-2">
<Label htmlFor={noXpRolesId}>{t('modulePages.leveling.noXpRoles')}</Label>
<Textarea
@@ -197,8 +211,10 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-leveling-role-multipliers">
<div className="space-y-2">
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
<Textarea
@@ -210,6 +226,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-channel-multipliers">
<div className="space-y-2">
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
<Textarea
@@ -221,10 +239,12 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div>
</FieldAnchor>
</div>
</CardContent>
</Card>
<FieldAnchor id="field-leveling-levelup">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
@@ -266,6 +286,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
placeholder="Congrats {user}, you reached level {level}!"
/>
</div>
<FieldAnchor id="field-leveling-stack">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.leveling.stackRewards')}</Label>
<Switch
@@ -273,9 +294,12 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, stackRewards: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-leveling-rank-card">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
@@ -313,6 +337,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
</div>
</CardContent>
</Card>
</FieldAnchor>
</div>
)}
</SettingsForm>

View File

@@ -3,6 +3,7 @@
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -129,6 +130,7 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
<CardDescription>{t('modulePages.logging.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-logging-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.logging.enabledLabel')}</Label>
<Switch
@@ -136,6 +138,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-logging-ignore-bots">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.logging.ignoreBotsLabel')}</Label>
<Switch
@@ -143,6 +147,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, ignoreBots: checked }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-logging-ignored-channels">
<div className="space-y-2">
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
<Textarea
@@ -153,6 +159,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-logging-ignored-roles">
<div className="space-y-2">
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label>
<Textarea
@@ -163,6 +171,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-logging-retention">
<div className="space-y-2">
<Label htmlFor="logging-retention">{t('modulePages.logging.retentionLabel')}</Label>
<Input
@@ -178,9 +188,11 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.retentionHint')}</p>
</div>
</FieldAnchor>
</CardContent>
</Card>
<FieldAnchor id="field-logging-channels">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.logging.channelsTitle')}</CardTitle>
@@ -268,6 +280,7 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
</Button>
</CardContent>
</Card>
</FieldAnchor>
</div>
)}
</SettingsForm>

View File

@@ -3,6 +3,7 @@
import type { EscalationAction, EscalationRuleDashboard, ModerationDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -160,71 +161,75 @@ export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
<CardDescription>{t('modulePages.moderation.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.moderation.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.enabledHint')}</p>
<FieldAnchor id="field-mod-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.moderation.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.enabledHint')}</p>
</div>
<Switch
checked={value.moderationEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/>
</div>
<Switch
checked={value.moderationEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle>
<CardDescription>{t('modulePages.moderation.escalationsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{value.escalations.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.noEscalations')}</p>
)}
{value.escalations.map((rule) => (
<EscalationRow
key={rule.clientKey}
rule={rule}
onChange={(updated) =>
<FieldAnchor id="field-mod-escalations">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle>
<CardDescription>{t('modulePages.moderation.escalationsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{value.escalations.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.noEscalations')}</p>
)}
{value.escalations.map((rule) => (
<EscalationRow
key={rule.clientKey}
rule={rule}
onChange={(updated) =>
setValue((prev) => ({
...prev,
escalations: prev.escalations.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry))
}))
}
onRemove={() =>
setValue((prev) => ({
...prev,
escalations: prev.escalations.filter((entry) => entry.clientKey !== rule.clientKey)
}))
}
/>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setValue((prev) => ({
...prev,
escalations: prev.escalations.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry))
escalations: [
...prev.escalations,
{
clientKey: `new-${Date.now()}-${prev.escalations.length}`,
warnCount: prev.escalations.length + 1,
action: 'TIMEOUT',
durationMs: 3_600_000,
reason: null,
enabled: true
}
]
}))
}
onRemove={() =>
setValue((prev) => ({
...prev,
escalations: prev.escalations.filter((entry) => entry.clientKey !== rule.clientKey)
}))
}
/>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setValue((prev) => ({
...prev,
escalations: [
...prev.escalations,
{
clientKey: `new-${Date.now()}-${prev.escalations.length}`,
warnCount: prev.escalations.length + 1,
action: 'TIMEOUT',
durationMs: 3_600_000,
reason: null,
enabled: true
}
]
}))
}
>
<Plus className="size-4" />
{t('modulePages.moderation.addEscalation')}
</Button>
</CardContent>
</Card>
>
<Plus className="size-4" />
{t('modulePages.moderation.addEscalation')}
</Button>
</CardContent>
</Card>
</FieldAnchor>
</div>
)}
</SettingsForm>

View File

@@ -4,6 +4,7 @@ import type { ScheduledMessageDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -97,6 +98,7 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
return (
<div className="space-y-6">
<FieldAnchor id="field-scheduler-create">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.scheduler.createTitle')}</CardTitle>
@@ -153,6 +155,7 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
</Button>
</CardContent>
</Card>
</FieldAnchor>
<Card>
<CardHeader>

View File

@@ -4,6 +4,7 @@ import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashbo
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -153,6 +154,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
return (
<div className="space-y-6">
<FieldAnchor id="field-selfroles-create">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
@@ -219,6 +221,9 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
</Button>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-selfroles-list">
<Card>
<CardHeader>
@@ -313,6 +318,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
))}
</CardContent>
</Card>
</FieldAnchor>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import type { StarboardConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -83,33 +84,45 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<CardDescription>{t('modulePages.starboard.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-starboard-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-starboard-channel">
<div className="space-y-2">
<Label htmlFor={channelId}>{t('modulePages.starboard.channelId')}</Label>
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-emoji">
<div className="space-y-2">
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-threshold">
<div className="space-y-2">
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-starboard-self">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-ignored">
<div className="space-y-2">
<Label htmlFor={ignoredId}>{t('modulePages.starboard.ignoredChannelIds')}</Label>
<Textarea id={ignoredId} value={value.ignoredChannelIdsText} onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))} />
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.idsHint')}</p>
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -2,6 +2,7 @@
import type { StatsConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -50,31 +51,39 @@ export function StatsForm({ guildId, initialValue }: StatsFormProps) {
<CardDescription>{t('modulePages.stats.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-stats-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.stats.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-stats-members">
<div className="space-y-2">
<Label htmlFor={membersId}>{t('modulePages.stats.membersChannelId')}</Label>
<Input id={membersId} value={value.membersChannelId} onChange={(event) => setValue((prev) => ({ ...prev, membersChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<div className="space-y-2">
<Label htmlFor={membersTemplateId}>{t('modulePages.stats.membersTemplate')}</Label>
<Input id={membersTemplateId} value={value.membersTemplate} onChange={(event) => setValue((prev) => ({ ...prev, membersTemplate: event.target.value }))} />
</div>
<FieldAnchor id="field-stats-online">
<div className="space-y-2">
<Label htmlFor={onlineId}>{t('modulePages.stats.onlineChannelId')}</Label>
<Input id={onlineId} value={value.onlineChannelId} onChange={(event) => setValue((prev) => ({ ...prev, onlineChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<div className="space-y-2">
<Label htmlFor={onlineTemplateId}>{t('modulePages.stats.onlineTemplate')}</Label>
<Input id={onlineTemplateId} value={value.onlineTemplate} onChange={(event) => setValue((prev) => ({ ...prev, onlineTemplate: event.target.value }))} />
</div>
<FieldAnchor id="field-stats-boosts">
<div className="space-y-2">
<Label htmlFor={boostsId}>{t('modulePages.stats.boostsChannelId')}</Label>
<Input id={boostsId} value={value.boostsChannelId} onChange={(event) => setValue((prev) => ({ ...prev, boostsChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<div className="space-y-2">
<Label htmlFor={boostsTemplateId}>{t('modulePages.stats.boostsTemplate')}</Label>
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} />

View File

@@ -2,6 +2,7 @@
import type { SuggestionConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -53,24 +54,33 @@ export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConf
<CardDescription>{t('modulePages.suggestions.configDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-suggestions-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.suggestions.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-suggestions-open">
<div className="space-y-2">
<Label htmlFor={openId}>{t('modulePages.suggestions.openChannelId')}</Label>
<Input id={openId} value={value.openChannelId} onChange={(event) => setValue((prev) => ({ ...prev, openChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<FieldAnchor id="field-suggestions-approved">
<div className="space-y-2">
<Label htmlFor={approvedId}>{t('modulePages.suggestions.approvedChannelId')}</Label>
<Input id={approvedId} value={value.approvedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, approvedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
</div>
</FieldAnchor>
<FieldAnchor id="field-suggestions-denied">
<div className="space-y-2">
<Label htmlFor={deniedId}>{t('modulePages.suggestions.deniedChannelId')}</Label>
<Input id={deniedId} value={value.deniedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, deniedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-suggestions-thread">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.suggestions.createThread')}</Label>
@@ -78,6 +88,7 @@ export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConf
</div>
<Switch checked={value.createThread} onCheckedChange={(createThread) => setValue((prev) => ({ ...prev, createThread }))} />
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -3,6 +3,7 @@
import type { SuggestionAction, SuggestionDashboard } from '@nexumi/shared';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -82,6 +83,7 @@ export function SuggestionsListManager({ guildId, initialSuggestions }: Suggesti
}
return (
<FieldAnchor id="field-suggestions-list">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -174,5 +176,6 @@ export function SuggestionsListManager({ guildId, initialSuggestions }: Suggesti
))}
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -4,6 +4,7 @@ import type { TagDashboard, TagResponseType } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -144,6 +145,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
return (
<div className="space-y-6">
<FieldAnchor id="field-tags-create">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.tags.createTitle')}</CardTitle>
@@ -195,6 +197,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
</Button>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-tags-list">
<Card>
<CardHeader>
@@ -256,6 +261,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
))}
</CardContent>
</Card>
</FieldAnchor>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import type { TempVoiceConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -48,27 +49,37 @@ export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
<CardDescription>{t('modulePages.tempvoice.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-tempvoice-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.tempvoice.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-tempvoice-hub">
<div className="space-y-2">
<Label htmlFor={hubId}>{t('modulePages.tempvoice.hubChannelId')}</Label>
<Input id={hubId} value={value.hubChannelId} onChange={(event) => setValue((prev) => ({ ...prev, hubChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<FieldAnchor id="field-tempvoice-category">
<div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tempvoice.categoryId')}</Label>
<Input id={categoryId} value={value.categoryId} onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div>
</FieldAnchor>
<FieldAnchor id="field-tempvoice-name">
<div className="space-y-2">
<Label htmlFor={nameId}>{t('modulePages.tempvoice.defaultName')}</Label>
<Input id={nameId} value={value.defaultName} onChange={(event) => setValue((prev) => ({ ...prev, defaultName: event.target.value }))} placeholder="{user}'s Channel" />
</div>
</FieldAnchor>
<FieldAnchor id="field-tempvoice-limit">
<div className="space-y-2">
<Label htmlFor={limitId}>{t('modulePages.tempvoice.defaultLimit')}</Label>
<Input id={limitId} type="number" min={0} max={99} value={value.defaultLimit} onChange={(event) => setValue((prev) => ({ ...prev, defaultLimit: Number(event.target.value) || 0 }))} />
</div>
</FieldAnchor>
</div>
<p className="text-xs text-muted-foreground">{t('modulePages.tempvoice.defaultLimitHint')}</p>
</CardContent>

View File

@@ -4,6 +4,7 @@ import type { TicketCategoryDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -120,6 +121,7 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
}
return (
<FieldAnchor id="field-tickets-categories">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.tickets.categoriesTitle')}</CardTitle>
@@ -235,5 +237,6 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
</div>
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -2,6 +2,7 @@
import type { TicketConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -51,6 +52,7 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
<CardDescription>{t('modulePages.tickets.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-tickets-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.tickets.enabledLabel')}</Label>
<Switch
@@ -58,8 +60,10 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-tickets-mode">
<div className="space-y-2">
<Label>{t('modulePages.tickets.mode')}</Label>
<Select value={value.mode} onValueChange={(mode) => setValue((prev) => ({ ...prev, mode: mode as TicketConfigDashboard['mode'] }))}>
@@ -72,7 +76,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
</SelectContent>
</Select>
</div>
</FieldAnchor>
<FieldAnchor id="field-tickets-category">
<div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tickets.categoryId')}</Label>
<Input
@@ -82,7 +88,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
placeholder="123456789012345678"
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-tickets-log">
<div className="space-y-2">
<Label htmlFor={logChannelId}>{t('modulePages.tickets.logChannelId')}</Label>
<Input
@@ -92,7 +100,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
placeholder="123456789012345678"
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-tickets-inactivity">
<div className="space-y-2">
<Label htmlFor={inactivityId}>{t('modulePages.tickets.inactivityHours')}</Label>
<Input
@@ -105,8 +115,10 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
}
/>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-tickets-transcript">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.tickets.transcriptDm')}</Label>
@@ -117,7 +129,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
onCheckedChange={(transcriptDm) => setValue((prev) => ({ ...prev, transcriptDm }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-tickets-rating">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.tickets.ratingEnabled')}</Label>
<Switch
@@ -125,6 +139,7 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
onCheckedChange={(ratingEnabled) => setValue((prev) => ({ ...prev, ratingEnabled }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -1,6 +1,7 @@
'use client';
import type { VerificationConfigDashboard } 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';
import { Input } from '@/components/ui/input';
@@ -47,6 +48,7 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-verify-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.verification.enabledLabel')}</Label>
<Switch
@@ -54,8 +56,10 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-verify-mode">
<div className="space-y-2">
<Label>{t('modulePages.verification.mode')}</Label>
<Select
@@ -73,6 +77,8 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
</SelectContent>
</Select>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-fail">
<div className="space-y-2">
<Label>{t('modulePages.verification.failAction')}</Label>
<Select
@@ -91,9 +97,11 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
</SelectContent>
</Select>
</div>
</FieldAnchor>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-verify-channel">
<div className="space-y-2">
<Label>{t('modulePages.verification.channelId')}</Label>
<Input
@@ -102,6 +110,8 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
placeholder="123456789012345678"
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-role">
<div className="space-y-2">
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
<Input
@@ -110,6 +120,8 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
placeholder="123456789012345678"
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-unverified">
<div className="space-y-2">
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
<Input
@@ -118,8 +130,10 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
placeholder="123456789012345678"
/>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-verify-age">
<div className="space-y-2">
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
<Input
@@ -132,6 +146,7 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -2,6 +2,7 @@
import type { WelcomeConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -116,6 +117,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<CardDescription>{t('modulePages.welcome.welcomeDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-welcome-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.welcome.welcomeEnabledLabel')}</Label>
<Switch
@@ -123,7 +125,9 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, welcomeEnabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-welcome-channel">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeChannelId')}</Label>
<Input
@@ -132,6 +136,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder="123456789012345678"
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-type">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeType')}</Label>
<Select
@@ -150,7 +156,9 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
</SelectContent>
</Select>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-welcome-content">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
<Textarea
@@ -160,6 +168,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.welcome.placeholdersHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-embed">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
<Textarea
@@ -170,6 +180,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder='{ "title": "Welcome!", "color": 6591981 }'
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-dm">
<div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.welcome.welcomeDmEnabled')}</Label>
@@ -184,6 +196,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder={t('modulePages.welcome.dmContentPlaceholder')}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-user-autoroles">
<div className="space-y-2">
<Label htmlFor={userAutorolesId}>{t('modulePages.welcome.userAutoroles')}</Label>
<Textarea
@@ -193,6 +207,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-bot-autoroles">
<div className="space-y-2">
<Label htmlFor={botAutorolesId}>{t('modulePages.welcome.botAutoroles')}</Label>
<Textarea
@@ -202,7 +218,9 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-welcome-image-title">
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageTitle')}</Label>
<Input
@@ -210,6 +228,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-image-subtitle">
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
<Input
@@ -217,6 +237,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
/>
</div>
</FieldAnchor>
</div>
</CardContent>
</Card>
@@ -227,6 +248,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<CardDescription>{t('modulePages.welcome.leaveDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-leave-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.welcome.leaveEnabledLabel')}</Label>
<Switch
@@ -234,6 +256,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, leaveEnabled: checked }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leave-channel">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveChannelId')}</Label>
<Input
@@ -242,6 +266,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder="123456789012345678"
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leave-content">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveContent')}</Label>
<Textarea
@@ -250,6 +276,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder={t('modulePages.welcome.contentPlaceholder')}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leave-embed">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
<Textarea
@@ -260,6 +288,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
placeholder='{ "title": "Goodbye", "color": 15158332 }'
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
</div>

View File

@@ -1,7 +1,14 @@
'use client';
import type { SessionUser } from '@nexumi/shared';
import type { ReactNode } from 'react';
import { OwnerSidebar } from '@/components/owner/owner-sidebar';
import { Menu } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
import { UserMenu } from '@/components/layout/user-menu';
import { OwnerSidebar, OwnerSidebarNav } from '@/components/owner/owner-sidebar';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { useTranslations } from '@/components/locale-provider';
export function OwnerShell({
user,
@@ -10,14 +17,33 @@ export function OwnerShell({
user: SessionUser;
children: ReactNode;
}) {
const t = useTranslations();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="flex min-h-screen">
<div className="flex min-h-screen bg-background">
<OwnerSidebar />
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-end border-b border-border px-6">
<UserMenu user={user} showOwnerLink />
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-40 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:px-6">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden" aria-label={t('search.openNav')}>
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0">
<SheetTitle className="sr-only">{t('owner.nav.title')}</SheetTitle>
<OwnerSidebarNav onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
<div className="ml-auto flex min-w-0 items-center gap-3">
<div className="min-w-0 flex-1 sm:flex-initial">
<DashboardCommandPalette mode="owner" />
</div>
<UserMenu user={user} showOwnerLink />
</div>
</header>
<main className="flex-1 px-6 py-8">
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
</main>
</div>

View File

@@ -13,12 +13,13 @@ import {
Users,
ArrowLeft
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from '@/components/locale-provider';
import { cn } from '@/lib/utils';
const LINKS = [
export const OWNER_LINKS = [
{ href: '/owner', icon: LayoutDashboard, key: 'overview' },
{ href: '/owner/guilds', icon: Server, key: 'guilds' },
{ href: '/owner/users', icon: Users, key: 'users' },
@@ -31,47 +32,61 @@ const LINKS = [
{ href: '/owner/audit', icon: ScrollText, key: 'audit' }
] as const;
export function OwnerSidebar() {
export function OwnerSidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const t = useTranslations();
return (
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
<nav className="flex h-full flex-col gap-5 overflow-y-auto px-3 py-4">
<div className="flex items-center gap-2.5 px-2.5 py-1.5">
<Image src="/nexumi-logo.svg" alt="" width={28} height={28} className="size-7" />
<span className="text-base font-semibold tracking-tight">Nexumi</span>
</div>
<Link
href="/dashboard"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent/60 hover:text-foreground"
onClick={onNavigate}
className="flex items-center gap-2 rounded-md px-2.5 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<ArrowLeft className="size-4" />
{t('owner.nav.backToDashboard')}
</Link>
<div className="flex flex-col gap-1">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<div className="flex flex-col gap-0.5">
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
{t('owner.nav.title')}
</p>
{LINKS.map((link) => {
{OWNER_LINKS.map((link) => {
const active = link.href === '/owner' ? pathname === '/owner' : pathname.startsWith(link.href);
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
onClick={onNavigate}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
: 'border-l-2 border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)}
>
<Icon className="size-4" />
<Icon className="size-4 shrink-0" />
{t(`owner.nav.${link.key}`)}
</Link>
);
})}
</div>
<div className="mt-auto px-3 text-xs text-muted-foreground">
<Activity className="mb-1 size-3.5" />
<div className="mt-auto flex items-center gap-2 px-2.5 text-xs text-muted-foreground">
<Activity className="size-3.5" />
Nexumi Owner
</div>
</nav>
);
}
export function OwnerSidebar() {
return (
<aside className="hidden h-screen w-60 shrink-0 border-r border-border bg-card/40 lg:sticky lg:top-0 lg:block">
<OwnerSidebarNav />
</aside>
);
}

View File

@@ -3,6 +3,7 @@
import { DASHBOARD_MODULES, type DashboardAccessRule, type DashboardModuleId } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -55,11 +56,13 @@ async function saveAccessRules(guildId: string, rules: LocalRule[]): Promise<Set
function AccessRuleRow({
rule,
onChange,
onRemove
onRemove,
anchorIds
}: {
rule: LocalRule;
onChange: (rule: LocalRule) => void;
onRemove: () => void;
anchorIds?: boolean;
}) {
const t = useTranslations();
const roleInputId = useId();
@@ -71,56 +74,63 @@ function AccessRuleRow({
onChange({ ...rule, selectedModules: next });
}
const roleBlock = (
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label>
<Input
id={roleInputId}
value={rule.roleId}
onChange={(event) => onChange({ ...rule, roleId: event.target.value.trim() })}
placeholder={t('access.roleIdPlaceholder')}
className="max-w-xs"
/>
<p className="text-xs text-muted-foreground">{t('access.roleIdHint')}</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label={t('access.removeRule')}>
<Trash2 className="size-4" />
</Button>
</div>
);
const modulesBlock = (
<div className="space-y-2">
<Label>{t('access.modulesLabel')}</Label>
<Select
value={rule.modulesMode}
onValueChange={(mode) => onChange({ ...rule, modulesMode: mode as 'all' | 'specific' })}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('access.allModulesOption')}</SelectItem>
<SelectItem value="specific">{t('access.specificModulesOption')}</SelectItem>
</SelectContent>
</Select>
{rule.modulesMode === 'specific' && (
<div className="grid grid-cols-2 gap-2 pt-2 sm:grid-cols-3">
{DASHBOARD_MODULES.map((module) => (
<label key={module.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-input accent-primary"
checked={rule.selectedModules.includes(module.id)}
onChange={(event) => toggleModule(module.id, event.target.checked)}
/>
{t(`modules.${module.id}.label`)}
</label>
))}
</div>
)}
</div>
);
return (
<div className="space-y-4 rounded-md border border-border p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label>
<Input
id={roleInputId}
value={rule.roleId}
onChange={(event) => onChange({ ...rule, roleId: event.target.value.trim() })}
placeholder={t('access.roleIdPlaceholder')}
className="max-w-xs"
/>
<p className="text-xs text-muted-foreground">{t('access.roleIdHint')}</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label={t('access.removeRule')}>
<Trash2 className="size-4" />
</Button>
</div>
<div className="space-y-2">
<Label>{t('access.modulesLabel')}</Label>
<Select
value={rule.modulesMode}
onValueChange={(mode) => onChange({ ...rule, modulesMode: mode as 'all' | 'specific' })}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('access.allModulesOption')}</SelectItem>
<SelectItem value="specific">{t('access.specificModulesOption')}</SelectItem>
</SelectContent>
</Select>
{rule.modulesMode === 'specific' && (
<div className="grid grid-cols-2 gap-2 pt-2 sm:grid-cols-3">
{DASHBOARD_MODULES.map((module) => (
<label key={module.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-input accent-primary"
checked={rule.selectedModules.includes(module.id)}
onChange={(event) => toggleModule(module.id, event.target.checked)}
/>
{t(`modules.${module.id}.label`)}
</label>
))}
</div>
)}
</div>
{anchorIds ? <FieldAnchor id="field-access-role">{roleBlock}</FieldAnchor> : roleBlock}
{anchorIds ? <FieldAnchor id="field-access-modules">{modulesBlock}</FieldAnchor> : modulesBlock}
</div>
);
}
@@ -145,12 +155,22 @@ export function AccessRulesForm({ guildId, initialRules }: AccessRulesFormProps)
<CardDescription>{t('access.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{value.length === 0 && <p className="text-sm text-muted-foreground">{t('access.noRules')}</p>}
{value.length === 0 && (
<>
<FieldAnchor id="field-access-role">
<p className="text-sm text-muted-foreground">{t('access.noRules')}</p>
</FieldAnchor>
<FieldAnchor id="field-access-modules">
<span className="sr-only">{t('access.modulesLabel')}</span>
</FieldAnchor>
</>
)}
{value.map((rule) => (
{value.map((rule, index) => (
<AccessRuleRow
key={rule.clientKey}
rule={rule}
anchorIds={index === 0}
onChange={(updated) =>
setValue((prev) => prev.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry)))
}

View File

@@ -1,6 +1,7 @@
'use client';
import type { GuildGeneralSettings } 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';
import { Input } from '@/components/ui/input';
@@ -47,58 +48,66 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
<CardDescription>{t('settings.general.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="general-locale">{t('settings.general.localeLabel')}</Label>
<Select
value={value.locale}
onValueChange={(locale) => setValue((prev) => ({ ...prev, locale: locale as 'de' | 'en' }))}
>
<SelectTrigger id="general-locale" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">{t('settings.general.localeHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="general-timezone">{t('settings.general.timezoneLabel')}</Label>
<Input
id="general-timezone"
className="w-72"
value={value.timezone}
onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))}
placeholder="Europe/Berlin"
/>
<p className="text-sm text-muted-foreground">{t('settings.general.timezoneHint')}</p>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label htmlFor="general-moderation">{t('settings.general.moderationLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('settings.general.moderationHint')}</p>
<FieldAnchor id="field-locale">
<div className="space-y-2">
<Label htmlFor="general-locale">{t('settings.general.localeLabel')}</Label>
<Select
value={value.locale}
onValueChange={(locale) => setValue((prev) => ({ ...prev, locale: locale as 'de' | 'en' }))}
>
<SelectTrigger id="general-locale" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">{t('settings.general.localeHint')}</p>
</div>
<Switch
id="general-moderation"
checked={value.moderationEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('settings.general.snipeHint')}</p>
<FieldAnchor id="field-timezone">
<div className="space-y-2">
<Label htmlFor="general-timezone">{t('settings.general.timezoneLabel')}</Label>
<Input
id="general-timezone"
className="w-72"
value={value.timezone}
onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))}
placeholder="Europe/Berlin"
/>
<p className="text-sm text-muted-foreground">{t('settings.general.timezoneHint')}</p>
</div>
<Switch
id="general-snipe"
checked={value.snipeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-moderation-toggle">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label htmlFor="general-moderation">{t('settings.general.moderationLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('settings.general.moderationHint')}</p>
</div>
<Switch
id="general-moderation"
checked={value.moderationEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-snipe">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('settings.general.snipeHint')}</p>
</div>
<Switch
id="general-snipe"
checked={value.snipeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
)}

View File

@@ -0,0 +1,135 @@
'use client';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import * as React from 'react';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
function CommandDialog({
children,
title,
open,
onOpenChange
}: {
children: React.ReactNode;
title: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 shadow-lg sm:max-w-xl [&>button]:hidden">
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-4">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
<Search className="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-80 overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50',
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
};

View File

@@ -0,0 +1,90 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/50', className)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
};

View File

@@ -0,0 +1,59 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/50', className)}
{...props}
/>
));
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { side?: 'left' | 'right' }
>(({ side = 'left', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 flex h-full flex-col gap-4 border-border bg-background shadow-lg',
side === 'left' && 'inset-y-0 left-0 w-72 border-r',
side === 'right' && 'inset-y-0 right-0 w-72 border-l',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} />
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetTitle };