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

@@ -15,6 +15,7 @@
"@nexumi/shared": "workspace:^", "@nexumi/shared": "workspace:^",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.2.3", "@radix-ui/react-avatar": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.21", "@radix-ui/react-dropdown-menu": "^2.1.21",
"@radix-ui/react-label": "^2.1.12", "@radix-ui/react-label": "^2.1.12",
"@radix-ui/react-select": "^2.3.4", "@radix-ui/react-select": "^2.3.4",
@@ -24,6 +25,7 @@
"bullmq": "^5.34.0", "bullmq": "^5.34.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"lucide-react": "^1.25.0", "lucide-react": "^1.25.0",

View File

@@ -1,3 +1,4 @@
import { Suspense } from 'react';
import { CommandsManager } from '@/components/modules/commands-manager'; import { CommandsManager } from '@/components/modules/commands-manager';
import { getLocale, t } from '@/lib/i18n'; import { getLocale, t } from '@/lib/i18n';
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands'; import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
@@ -16,7 +17,9 @@ export default async function CommandsPage({ params }: CommandsPageProps) {
<h1 className="text-2xl font-semibold">{t(locale, 'modulePages.commands.title')}</h1> <h1 className="text-2xl font-semibold">{t(locale, 'modulePages.commands.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p> <p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
</div> </div>
<Suspense fallback={null}>
<CommandsManager guildId={guildId} initialCommands={commands} /> <CommandsManager guildId={guildId} initialCommands={commands} />
</Suspense>
</div> </div>
); );
} }

View File

@@ -1,7 +1,9 @@
import { DASHBOARD_MODULES } from '@nexumi/shared';
import { Activity, MessageSquare, Mic, Users } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { getActivitySummary } from '@/lib/activity'; import { getActivitySummary } from '@/lib/activity';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { listRecentDashboardAudit } from '@/lib/audit'; import { listRecentDashboardAudit } from '@/lib/audit';
import { getLocale, t } from '@/lib/i18n'; import { getLocale, t } from '@/lib/i18n';
import { getModuleStatuses } from '@/lib/modules'; import { getModuleStatuses } from '@/lib/modules';
@@ -17,6 +19,10 @@ function formatDate(iso: string, locale: string): string {
}); });
} }
function moduleHref(moduleId: string): string {
return DASHBOARD_MODULES.find((module) => module.id === moduleId)?.href ?? moduleId;
}
export default async function GuildOverviewPage({ params }: OverviewPageProps) { export default async function GuildOverviewPage({ params }: OverviewPageProps) {
const { guildId } = await params; const { guildId } = await params;
const locale = await getLocale(); const locale = await getLocale();
@@ -26,55 +32,108 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
getActivitySummary(guildId) getActivitySummary(guildId)
]); ]);
const enabledCount = modules.filter((module) => module.enabled).length;
const numberLocale = locale === 'de' ? 'de-DE' : 'en-US';
return ( return (
<div className="space-y-8"> <div className="space-y-10">
<h1 className="text-2xl font-semibold">{t(locale, 'dashboard.overview.title')}</h1> <div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">{t(locale, 'dashboard.overview.title')}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.activityHint')}
</p>
</div>
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center gap-2">
<Activity className="size-4 text-primary" />
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2> <h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2>
<div className="grid gap-3 sm:grid-cols-3"> </div>
<Card> <div className="grid gap-4 sm:grid-cols-3">
<CardContent className="p-4"> <Card className="border-border/80 bg-card/80">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityMessages')}</p> <CardContent className="flex items-start gap-3 p-5">
<p className="text-2xl font-semibold">{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p> <div className="rounded-md bg-primary/10 p-2 text-primary">
<MessageSquare className="size-4" />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, 'dashboard.overview.activityMessages')}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{activitySummary.messageCount.toLocaleString(numberLocale)}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="border-border/80 bg-card/80">
<CardContent className="p-4"> <CardContent className="flex items-start gap-3 p-5">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityVoiceMinutes')}</p> <div className="rounded-md bg-primary/10 p-2 text-primary">
<p className="text-2xl font-semibold">{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p> <Mic className="size-4" />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, 'dashboard.overview.activityVoiceMinutes')}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{activitySummary.voiceMinutes.toLocaleString(numberLocale)}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="border-border/80 bg-card/80">
<CardContent className="p-4"> <CardContent className="flex items-start gap-3 p-5">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityActiveUsers')}</p> <div className="rounded-md bg-primary/10 p-2 text-primary">
<p className="text-2xl font-semibold">{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p> <Users className="size-4" />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, 'dashboard.overview.activityActiveUsers')}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{activitySummary.activeUserCount.toLocaleString(numberLocale)}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityHint')}</p>
</section> </section>
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2> <h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
<Link href={`/dashboard/${guildId}/modules`} className="text-sm text-primary hover:underline"> {modules.length > 0 ? (
<p className="text-sm text-muted-foreground">
{enabledCount}/{modules.length} {t(locale, 'common.enabled').toLowerCase()}
</p>
) : null}
</div>
<Link
href={`/dashboard/${guildId}/modules`}
className="text-sm font-medium text-primary hover:underline"
>
{t(locale, 'dashboard.overview.viewAllModules')} {t(locale, 'dashboard.overview.viewAllModules')}
</Link> </Link>
</div> </div>
{modules.length === 0 ? ( {modules.length === 0 ? (
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.moduleStatusEmpty')}</p> <Card className="border-dashed">
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.moduleStatusEmpty')}
</CardContent>
</Card>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => ( {modules.map((module) => (
<Card key={module.id}> <Link key={module.id} href={`/dashboard/${guildId}/${moduleHref(module.id)}`}>
<CardContent className="flex items-center justify-between p-4"> <Card className="h-full border-border/80 transition-colors hover:border-primary/40 hover:bg-accent/30">
<CardContent className="flex items-center justify-between gap-3 p-4">
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span> <span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
<Badge variant={module.enabled ? 'success' : 'secondary'}> <Badge variant={module.enabled ? 'success' : 'secondary'}>
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')} {t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
</Badge> </Badge>
</CardContent> </CardContent>
</Card> </Card>
</Link>
))} ))}
</div> </div>
)} )}
@@ -82,22 +141,23 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2> <h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
<Card> <Card className="border-border/80">
<CardHeader> <CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground"> <CardTitle className="text-base">{t(locale, 'dashboard.overview.recentAuditTitle')}</CardTitle>
{t(locale, 'dashboard.overview.recentAuditTitle')} <CardDescription>{t(locale, 'dashboard.overview.auditPath')}</CardDescription>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent>
{auditEntries.length === 0 ? ( {auditEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.auditEmpty')}</p> <p className="py-6 text-center text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.auditEmpty')}
</p>
) : ( ) : (
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{auditEntries.map((entry) => ( {auditEntries.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-4 py-2 text-sm"> <li key={entry.id} className="flex items-center justify-between gap-4 py-3 text-sm">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{entry.action}</p> <p className="truncate font-medium">{entry.action}</p>
{entry.path && <p className="truncate text-xs text-muted-foreground">{entry.path}</p>} {entry.path ? <p className="truncate text-xs text-muted-foreground">{entry.path}</p> : null}
</div> </div>
<span className="whitespace-nowrap text-xs text-muted-foreground"> <span className="whitespace-nowrap text-xs text-muted-foreground">
{formatDate(entry.createdAt, locale)} {formatDate(entry.createdAt, locale)}

View File

@@ -81,4 +81,11 @@ body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Subtle page depth without full-bleed gradients (SPEC). */
.dark body {
background-image: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.08), transparent);
background-attachment: fixed;
} }

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 { DashboardGuild, SessionUser } from '@nexumi/shared';
import type { ReactNode } from 'react'; import { Menu } from 'lucide-react';
import { ServerSwitcher } from './server-switcher'; import { useState, type ReactNode } from 'react';
import { Sidebar } from './sidebar'; import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
import { UserMenu } from './user-menu'; 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 { interface DashboardShellProps {
guildId: string; guildId: string;
@@ -21,15 +29,36 @@ export function DashboardShell({
showOwnerLink = false, showOwnerLink = false,
children children
}: DashboardShellProps) { }: DashboardShellProps) {
const t = useTranslations();
const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen bg-background">
<HashScroll />
<Sidebar guildId={guildId} /> <Sidebar guildId={guildId} />
<div className="flex flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b border-border px-6"> <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} /> <ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
<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} /> <UserMenu user={user} showOwnerLink={showOwnerLink} />
</div>
</header> </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> <div className="mx-auto w-full max-w-[1200px]">{children}</div>
</main> </main>
</div> </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'; 'use client';
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; import { DASHBOARD_MODULES, type DashboardModuleGroup, type DashboardModuleId } from '@nexumi/shared';
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react'; 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 Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
@@ -15,19 +43,55 @@ const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
'integrations' '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; 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 ( return (
<Link <Link
href={href} href={href}
onClick={onNavigate}
className={cn( 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 active
? 'bg-accent text-accent-foreground' ? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground' : 'border-l-2 border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)} )}
> >
{children} {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 pathname = usePathname();
const t = useTranslations(); const t = useTranslations();
const base = `/dashboard/${guildId}`; const base = `/dashboard/${guildId}`;
@@ -46,39 +110,50 @@ export function Sidebar({ guildId }: SidebarProps) {
})); }));
return ( 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={cn('flex h-full flex-col gap-5 overflow-y-auto px-3 py-4', className)}>
<div className="flex flex-col gap-1"> <Link
<NavLink href={base} active={pathname === base}> href={base}
<LayoutGrid className="size-4" /> 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')} {t('nav.overview')}
</NavLink> </NavLink>
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}> <NavLink href={`${base}/settings`} active={pathname === `${base}/settings`} onNavigate={onNavigate}>
<Settings className="size-4" /> <Settings className="size-4 shrink-0" />
{t('nav.settings')} {t('nav.settings')}
</NavLink> </NavLink>
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}> <NavLink href={`${base}/modules`} active={pathname === `${base}/modules`} onNavigate={onNavigate}>
<SlidersHorizontal className="size-4" /> <SlidersHorizontal className="size-4 shrink-0" />
{t('nav.modules')} {t('nav.modules')}
</NavLink> </NavLink>
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}> <NavLink href={`${base}/access`} active={pathname === `${base}/access`} onNavigate={onNavigate}>
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4 shrink-0" />
{t('nav.access')} {t('nav.access')}
</NavLink> </NavLink>
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`}> <NavLink href={`${base}/commands`} active={pathname === `${base}/commands`} onNavigate={onNavigate}>
<Terminal className="size-4" /> <Terminal className="size-4 shrink-0" />
{t('nav.commands')} {t('nav.commands')}
</NavLink> </NavLink>
</div> </div>
{groups.map(({ group, modules }) => ( {groups.map(({ group, modules }) => (
<div key={group} className="flex flex-col gap-1"> <div key={group} className="flex flex-col gap-0.5">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
{t(`moduleGroups.${group}`)} {t(`moduleGroups.${group}`)}
</p> </p>
{modules.map((module) => { {modules.map((module) => {
const href = `${base}/${module.href}`; const href = `${base}/${module.href}`;
const Icon = MODULE_ICONS[module.id] ?? Radio;
return ( 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`)} {t(`modules.${module.id}.label`)}
</NavLink> </NavLink>
); );
@@ -88,3 +163,12 @@ export function Sidebar({ guildId }: SidebarProps) {
</nav> </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'; 'use client';
import type { AutomodConfigDashboard } from '@nexumi/shared'; import type { AutomodConfigDashboard } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -44,6 +45,7 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
<CardDescription>{t('modulePages.automod.description')}</CardDescription> <CardDescription>{t('modulePages.automod.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FieldAnchor id="field-automod-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label>{t('modulePages.automod.enabledLabel')}</Label> <Label>{t('modulePages.automod.enabledLabel')}</Label>
@@ -54,9 +56,11 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))} onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/> />
</div> </div>
</FieldAnchor>
</CardContent> </CardContent>
</Card> </Card>
<FieldAnchor id="field-anti-raid">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle> <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> <p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
</CardContent> </CardContent>
</Card> </Card>
</FieldAnchor>
<FieldAnchor id="field-anti-nuke">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle> <CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
@@ -146,6 +152,7 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
/> />
</div> </div>
</div> </div>
<FieldAnchor id="field-lockdown">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label>{t('modulePages.automod.lockdownActive')}</Label> <Label>{t('modulePages.automod.lockdownActive')}</Label>
@@ -156,8 +163,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))} onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
/> />
</div> </div>
</FieldAnchor>
</CardContent> </CardContent>
</Card> </Card>
</FieldAnchor>
</div> </div>
)} )}
</SettingsForm> </SettingsForm>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import type { EscalationAction, EscalationRuleDashboard, ModerationDashboard } from '@nexumi/shared'; import type { EscalationAction, EscalationRuleDashboard, ModerationDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react'; import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -160,6 +161,7 @@ export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
<CardDescription>{t('modulePages.moderation.description')}</CardDescription> <CardDescription>{t('modulePages.moderation.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FieldAnchor id="field-mod-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label>{t('modulePages.moderation.enabledLabel')}</Label> <Label>{t('modulePages.moderation.enabledLabel')}</Label>
@@ -170,9 +172,11 @@ export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))} onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/> />
</div> </div>
</FieldAnchor>
</CardContent> </CardContent>
</Card> </Card>
<FieldAnchor id="field-mod-escalations">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle> <CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle>
@@ -225,6 +229,7 @@ export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
</FieldAnchor>
</div> </div>
)} )}
</SettingsForm> </SettingsForm>

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
import type { StatsConfigDashboard } from '@nexumi/shared'; import type { StatsConfigDashboard } from '@nexumi/shared';
import { useId } from 'react'; import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -50,31 +51,39 @@ export function StatsForm({ guildId, initialValue }: StatsFormProps) {
<CardDescription>{t('modulePages.stats.description')}</CardDescription> <CardDescription>{t('modulePages.stats.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FieldAnchor id="field-stats-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.stats.enabledLabel')}</Label> <Label>{t('modulePages.stats.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} /> <Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div> </div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-stats-members">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={membersId}>{t('modulePages.stats.membersChannelId')}</Label> <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" /> <Input id={membersId} value={value.membersChannelId} onChange={(event) => setValue((prev) => ({ ...prev, membersChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div> </div>
</FieldAnchor>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={membersTemplateId}>{t('modulePages.stats.membersTemplate')}</Label> <Label htmlFor={membersTemplateId}>{t('modulePages.stats.membersTemplate')}</Label>
<Input id={membersTemplateId} value={value.membersTemplate} onChange={(event) => setValue((prev) => ({ ...prev, membersTemplate: event.target.value }))} /> <Input id={membersTemplateId} value={value.membersTemplate} onChange={(event) => setValue((prev) => ({ ...prev, membersTemplate: event.target.value }))} />
</div> </div>
<FieldAnchor id="field-stats-online">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={onlineId}>{t('modulePages.stats.onlineChannelId')}</Label> <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" /> <Input id={onlineId} value={value.onlineChannelId} onChange={(event) => setValue((prev) => ({ ...prev, onlineChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div> </div>
</FieldAnchor>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={onlineTemplateId}>{t('modulePages.stats.onlineTemplate')}</Label> <Label htmlFor={onlineTemplateId}>{t('modulePages.stats.onlineTemplate')}</Label>
<Input id={onlineTemplateId} value={value.onlineTemplate} onChange={(event) => setValue((prev) => ({ ...prev, onlineTemplate: event.target.value }))} /> <Input id={onlineTemplateId} value={value.onlineTemplate} onChange={(event) => setValue((prev) => ({ ...prev, onlineTemplate: event.target.value }))} />
</div> </div>
<FieldAnchor id="field-stats-boosts">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={boostsId}>{t('modulePages.stats.boostsChannelId')}</Label> <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" /> <Input id={boostsId} value={value.boostsChannelId} onChange={(event) => setValue((prev) => ({ ...prev, boostsChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div> </div>
</FieldAnchor>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={boostsTemplateId}>{t('modulePages.stats.boostsTemplate')}</Label> <Label htmlFor={boostsTemplateId}>{t('modulePages.stats.boostsTemplate')}</Label>
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} /> <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 type { SuggestionConfigDashboard } from '@nexumi/shared';
import { useId } from 'react'; import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -53,24 +54,33 @@ export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConf
<CardDescription>{t('modulePages.suggestions.configDescription')}</CardDescription> <CardDescription>{t('modulePages.suggestions.configDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FieldAnchor id="field-suggestions-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.suggestions.enabledLabel')}</Label> <Label>{t('modulePages.suggestions.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} /> <Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div> </div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-suggestions-open">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={openId}>{t('modulePages.suggestions.openChannelId')}</Label> <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" /> <Input id={openId} value={value.openChannelId} onChange={(event) => setValue((prev) => ({ ...prev, openChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
</div> </div>
</FieldAnchor>
<FieldAnchor id="field-suggestions-approved">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={approvedId}>{t('modulePages.suggestions.approvedChannelId')}</Label> <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')} /> <Input id={approvedId} value={value.approvedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, approvedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
</div> </div>
</FieldAnchor>
<FieldAnchor id="field-suggestions-denied">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={deniedId}>{t('modulePages.suggestions.deniedChannelId')}</Label> <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')} /> <Input id={deniedId} value={value.deniedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, deniedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
</div> </div>
</FieldAnchor>
</div> </div>
<FieldAnchor id="field-suggestions-thread">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label>{t('modulePages.suggestions.createThread')}</Label> <Label>{t('modulePages.suggestions.createThread')}</Label>
@@ -78,6 +88,7 @@ export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConf
</div> </div>
<Switch checked={value.createThread} onCheckedChange={(createThread) => setValue((prev) => ({ ...prev, createThread }))} /> <Switch checked={value.createThread} onCheckedChange={(createThread) => setValue((prev) => ({ ...prev, createThread }))} />
</div> </div>
</FieldAnchor>
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,14 @@
'use client';
import type { SessionUser } from '@nexumi/shared'; import type { SessionUser } from '@nexumi/shared';
import type { ReactNode } from 'react'; import { Menu } from 'lucide-react';
import { OwnerSidebar } from '@/components/owner/owner-sidebar'; import { useState, type ReactNode } from 'react';
import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
import { UserMenu } from '@/components/layout/user-menu'; 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({ export function OwnerShell({
user, user,
@@ -10,14 +17,33 @@ export function OwnerShell({
user: SessionUser; user: SessionUser;
children: ReactNode; children: ReactNode;
}) { }) {
const t = useTranslations();
const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen bg-background">
<OwnerSidebar /> <OwnerSidebar />
<div className="flex flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center justify-end border-b border-border px-6"> <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 /> <UserMenu user={user} showOwnerLink />
</div>
</header> </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> <div className="mx-auto w-full max-w-[1200px]">{children}</div>
</main> </main>
</div> </div>

View File

@@ -13,12 +13,13 @@ import {
Users, Users,
ArrowLeft ArrowLeft
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const LINKS = [ export const OWNER_LINKS = [
{ href: '/owner', icon: LayoutDashboard, key: 'overview' }, { href: '/owner', icon: LayoutDashboard, key: 'overview' },
{ href: '/owner/guilds', icon: Server, key: 'guilds' }, { href: '/owner/guilds', icon: Server, key: 'guilds' },
{ href: '/owner/users', icon: Users, key: 'users' }, { href: '/owner/users', icon: Users, key: 'users' },
@@ -31,47 +32,61 @@ const LINKS = [
{ href: '/owner/audit', icon: ScrollText, key: 'audit' } { href: '/owner/audit', icon: ScrollText, key: 'audit' }
] as const; ] as const;
export function OwnerSidebar() { export function OwnerSidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
const t = useTranslations(); const t = useTranslations();
return ( 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 <Link
href="/dashboard" 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" /> <ArrowLeft className="size-4" />
{t('owner.nav.backToDashboard')} {t('owner.nav.backToDashboard')}
</Link> </Link>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
{t('owner.nav.title')} {t('owner.nav.title')}
</p> </p>
{LINKS.map((link) => { {OWNER_LINKS.map((link) => {
const active = link.href === '/owner' ? pathname === '/owner' : pathname.startsWith(link.href); const active = link.href === '/owner' ? pathname === '/owner' : pathname.startsWith(link.href);
const Icon = link.icon; const Icon = link.icon;
return ( return (
<Link <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={onNavigate}
className={cn( 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 active
? 'bg-accent text-accent-foreground' ? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover: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}`)} {t(`owner.nav.${link.key}`)}
</Link> </Link>
); );
})} })}
</div> </div>
<div className="mt-auto px-3 text-xs text-muted-foreground"> <div className="mt-auto flex items-center gap-2 px-2.5 text-xs text-muted-foreground">
<Activity className="mb-1 size-3.5" /> <Activity className="size-3.5" />
Nexumi Owner Nexumi Owner
</div> </div>
</nav> </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 { DASHBOARD_MODULES, type DashboardAccessRule, type DashboardModuleId } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react'; import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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({ function AccessRuleRow({
rule, rule,
onChange, onChange,
onRemove onRemove,
anchorIds
}: { }: {
rule: LocalRule; rule: LocalRule;
onChange: (rule: LocalRule) => void; onChange: (rule: LocalRule) => void;
onRemove: () => void; onRemove: () => void;
anchorIds?: boolean;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const roleInputId = useId(); const roleInputId = useId();
@@ -71,8 +74,7 @@ function AccessRuleRow({
onChange({ ...rule, selectedModules: next }); onChange({ ...rule, selectedModules: next });
} }
return ( const roleBlock = (
<div className="space-y-4 rounded-md border border-border p-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label> <Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label>
@@ -89,7 +91,9 @@ function AccessRuleRow({
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
</div> </div>
);
const modulesBlock = (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('access.modulesLabel')}</Label> <Label>{t('access.modulesLabel')}</Label>
<Select <Select
@@ -121,6 +125,12 @@ function AccessRuleRow({
</div> </div>
)} )}
</div> </div>
);
return (
<div className="space-y-4 rounded-md border border-border p-4">
{anchorIds ? <FieldAnchor id="field-access-role">{roleBlock}</FieldAnchor> : roleBlock}
{anchorIds ? <FieldAnchor id="field-access-modules">{modulesBlock}</FieldAnchor> : modulesBlock}
</div> </div>
); );
} }
@@ -145,12 +155,22 @@ export function AccessRulesForm({ guildId, initialRules }: AccessRulesFormProps)
<CardDescription>{t('access.description')}</CardDescription> <CardDescription>{t('access.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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 <AccessRuleRow
key={rule.clientKey} key={rule.clientKey}
rule={rule} rule={rule}
anchorIds={index === 0}
onChange={(updated) => onChange={(updated) =>
setValue((prev) => prev.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry))) setValue((prev) => prev.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry)))
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { GuildGeneralSettings } from '@nexumi/shared'; import type { GuildGeneralSettings } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -47,6 +48,7 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
<CardDescription>{t('settings.general.description')}</CardDescription> <CardDescription>{t('settings.general.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<FieldAnchor id="field-locale">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="general-locale">{t('settings.general.localeLabel')}</Label> <Label htmlFor="general-locale">{t('settings.general.localeLabel')}</Label>
<Select <Select
@@ -63,7 +65,9 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
</Select> </Select>
<p className="text-sm text-muted-foreground">{t('settings.general.localeHint')}</p> <p className="text-sm text-muted-foreground">{t('settings.general.localeHint')}</p>
</div> </div>
</FieldAnchor>
<FieldAnchor id="field-timezone">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="general-timezone">{t('settings.general.timezoneLabel')}</Label> <Label htmlFor="general-timezone">{t('settings.general.timezoneLabel')}</Label>
<Input <Input
@@ -75,7 +79,9 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
/> />
<p className="text-sm text-muted-foreground">{t('settings.general.timezoneHint')}</p> <p className="text-sm text-muted-foreground">{t('settings.general.timezoneHint')}</p>
</div> </div>
</FieldAnchor>
<FieldAnchor id="field-moderation-toggle">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="general-moderation">{t('settings.general.moderationLabel')}</Label> <Label htmlFor="general-moderation">{t('settings.general.moderationLabel')}</Label>
@@ -87,7 +93,9 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))} onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/> />
</div> </div>
</FieldAnchor>
<FieldAnchor id="field-snipe">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label> <Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label>
@@ -99,6 +107,7 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))} onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))}
/> />
</div> </div>
</FieldAnchor>
</CardContent> </CardContent>
</Card> </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 };

View File

@@ -0,0 +1,53 @@
import { DASHBOARD_MODULES, KNOWN_COMMAND_NAMES } from '@nexumi/shared';
import { describe, expect, it } from 'vitest';
import {
SETTINGS_SEARCH_FIELDS,
buildGuildSearchHref,
buildGuildSearchIndex
} from './dashboard-search-index';
describe('dashboard-search-index', () => {
it('includes core pages, all modules, all commands, and settings fields', () => {
const index = buildGuildSearchIndex();
const pages = index.filter((entry) => entry.kind === 'page');
const commands = index.filter((entry) => entry.kind === 'command');
const settings = index.filter((entry) => entry.kind === 'setting');
expect(pages.length).toBe(5 + DASHBOARD_MODULES.length);
expect(commands.length).toBe(KNOWN_COMMAND_NAMES.length);
expect(settings.length).toBe(SETTINGS_SEARCH_FIELDS.length);
expect(settings.length).toBeGreaterThan(40);
});
it('builds correct hrefs for pages, commands, and settings', () => {
const guildId = '123';
expect(buildGuildSearchHref(guildId, { id: 'p', kind: 'page', href: '' })).toBe('/dashboard/123');
expect(buildGuildSearchHref(guildId, { id: 'p', kind: 'page', href: 'moderation' })).toBe(
'/dashboard/123/moderation'
);
expect(
buildGuildSearchHref(guildId, {
id: 'c',
kind: 'command',
href: 'commands?q=ban'
})
).toBe('/dashboard/123/commands?q=ban');
expect(
buildGuildSearchHref(guildId, {
id: 's',
kind: 'setting',
href: 'logging',
hash: 'field-logging-retention'
})
).toBe('/dashboard/123/logging#field-logging-retention');
});
it('assigns unique ids and hashes for settings entries', () => {
const ids = new Set(SETTINGS_SEARCH_FIELDS.map((entry) => entry.id));
expect(ids.size).toBe(SETTINGS_SEARCH_FIELDS.length);
for (const entry of SETTINGS_SEARCH_FIELDS) {
expect(entry.hash).toMatch(/^field-/);
expect(entry.href.length).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,847 @@
import { DASHBOARD_MODULES, KNOWN_COMMAND_NAMES } from '@nexumi/shared';
export type SearchItemKind = 'page' | 'command' | 'setting';
export interface SearchIndexEntry {
id: string;
kind: SearchItemKind;
/** i18n key for display label; for commands use raw name via labelOverride */
labelKey?: string;
labelOverride?: string;
keywords?: string[];
/** Path relative to `/dashboard/{guildId}` (no leading slash), or absolute for owner */
href: string;
hash?: string;
}
const CORE_PAGES: SearchIndexEntry[] = [
{ id: 'page-overview', kind: 'page', labelKey: 'nav.overview', href: '' },
{ id: 'page-settings', kind: 'page', labelKey: 'nav.settings', href: 'settings' },
{ id: 'page-modules', kind: 'page', labelKey: 'nav.modules', href: 'modules' },
{ id: 'page-access', kind: 'page', labelKey: 'nav.access', href: 'access' },
{ id: 'page-commands', kind: 'page', labelKey: 'nav.commands', href: 'commands' }
];
const MODULE_PAGES: SearchIndexEntry[] = DASHBOARD_MODULES.map((module) => ({
id: `page-${module.id}`,
kind: 'page' as const,
labelKey: `modules.${module.id}.label`,
keywords: [module.id, module.href],
href: module.href
}));
const COMMAND_ENTRIES: SearchIndexEntry[] = KNOWN_COMMAND_NAMES.map((name) => ({
id: `cmd-${name}`,
kind: 'command' as const,
labelOverride: `/${name}`,
keywords: [name],
href: `commands?q=${encodeURIComponent(name)}`
}));
/** All searchable settings fields with deep-link hashes. */
export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
// General settings
{
id: 'setting-locale',
kind: 'setting',
labelKey: 'settings.general.localeLabel',
href: 'settings',
hash: 'field-locale',
keywords: ['language', 'sprache']
},
{
id: 'setting-timezone',
kind: 'setting',
labelKey: 'settings.general.timezoneLabel',
href: 'settings',
hash: 'field-timezone',
keywords: ['timezone', 'zeitzone']
},
{
id: 'setting-moderation-toggle',
kind: 'setting',
labelKey: 'settings.general.moderationLabel',
href: 'settings',
hash: 'field-moderation-toggle',
keywords: ['moderation']
},
{
id: 'setting-snipe',
kind: 'setting',
labelKey: 'settings.general.snipeLabel',
href: 'settings',
hash: 'field-snipe',
keywords: ['snipe', 'editsnipe']
},
// Access
{
id: 'setting-access-role',
kind: 'setting',
labelKey: 'access.roleIdLabel',
href: 'access',
hash: 'field-access-role',
keywords: ['access', 'role']
},
{
id: 'setting-access-modules',
kind: 'setting',
labelKey: 'access.modulesLabel',
href: 'access',
hash: 'field-access-modules',
keywords: ['access']
},
// Moderation
{
id: 'setting-mod-enabled',
kind: 'setting',
labelKey: 'modulePages.moderation.enabledLabel',
href: 'moderation',
hash: 'field-mod-enabled'
},
{
id: 'setting-mod-escalations',
kind: 'setting',
labelKey: 'modulePages.moderation.escalationsTitle',
href: 'moderation',
hash: 'field-mod-escalations',
keywords: ['escalation', 'warn']
},
{
id: 'setting-mod-cases',
kind: 'setting',
labelKey: 'modulePages.moderation.casesTitle',
href: 'moderation',
hash: 'field-mod-cases',
keywords: ['cases', 'fälle']
},
// Automod
{
id: 'setting-automod-enabled',
kind: 'setting',
labelKey: 'modulePages.automod.enabledLabel',
href: 'automod',
hash: 'field-automod-enabled'
},
{
id: 'setting-anti-raid',
kind: 'setting',
labelKey: 'modulePages.automod.antiRaidTitle',
href: 'automod',
hash: 'field-anti-raid',
keywords: ['raid']
},
{
id: 'setting-anti-nuke',
kind: 'setting',
labelKey: 'modulePages.automod.antiNukeTitle',
href: 'automod',
hash: 'field-anti-nuke',
keywords: ['nuke']
},
{
id: 'setting-lockdown',
kind: 'setting',
labelKey: 'modulePages.automod.lockdownActive',
href: 'automod',
hash: 'field-lockdown',
keywords: ['lockdown']
},
// Logging
{
id: 'setting-logging-enabled',
kind: 'setting',
labelKey: 'modulePages.logging.enabledLabel',
href: 'logging',
hash: 'field-logging-enabled'
},
{
id: 'setting-logging-ignore-bots',
kind: 'setting',
labelKey: 'modulePages.logging.ignoreBotsLabel',
href: 'logging',
hash: 'field-logging-ignore-bots'
},
{
id: 'setting-logging-ignored-channels',
kind: 'setting',
labelKey: 'modulePages.logging.ignoredChannelsLabel',
href: 'logging',
hash: 'field-logging-ignored-channels'
},
{
id: 'setting-logging-ignored-roles',
kind: 'setting',
labelKey: 'modulePages.logging.ignoredRolesLabel',
href: 'logging',
hash: 'field-logging-ignored-roles'
},
{
id: 'setting-logging-retention',
kind: 'setting',
labelKey: 'modulePages.logging.retentionLabel',
href: 'logging',
hash: 'field-logging-retention',
keywords: ['retention', 'aufbewahrung']
},
{
id: 'setting-logging-channels',
kind: 'setting',
labelKey: 'modulePages.logging.channelsTitle',
href: 'logging',
hash: 'field-logging-channels'
},
// Welcome
{
id: 'setting-welcome-enabled',
kind: 'setting',
labelKey: 'modulePages.welcome.welcomeEnabledLabel',
href: 'welcome',
hash: 'field-welcome-enabled'
},
{
id: 'setting-welcome-channel',
kind: 'setting',
labelKey: 'modulePages.welcome.welcomeChannelId',
href: 'welcome',
hash: 'field-welcome-channel'
},
{
id: 'setting-welcome-type',
kind: 'setting',
labelKey: 'modulePages.welcome.welcomeType',
href: 'welcome',
hash: 'field-welcome-type'
},
{
id: 'setting-welcome-content',
kind: 'setting',
labelKey: 'modulePages.welcome.welcomeContent',
href: 'welcome',
hash: 'field-welcome-content'
},
{
id: 'setting-welcome-embed',
kind: 'setting',
labelKey: 'modulePages.welcome.welcomeEmbed',
href: 'welcome',
hash: 'field-welcome-embed'
},
{
id: 'setting-welcome-dm',
kind: 'setting',
labelKey: 'modulePages.welcome.welcomeDmEnabled',
href: 'welcome',
hash: 'field-welcome-dm'
},
{
id: 'setting-welcome-user-autoroles',
kind: 'setting',
labelKey: 'modulePages.welcome.userAutoroles',
href: 'welcome',
hash: 'field-welcome-user-autoroles',
keywords: ['autorole']
},
{
id: 'setting-welcome-bot-autoroles',
kind: 'setting',
labelKey: 'modulePages.welcome.botAutoroles',
href: 'welcome',
hash: 'field-welcome-bot-autoroles'
},
{
id: 'setting-welcome-image-title',
kind: 'setting',
labelKey: 'modulePages.welcome.imageTitle',
href: 'welcome',
hash: 'field-welcome-image-title'
},
{
id: 'setting-welcome-image-subtitle',
kind: 'setting',
labelKey: 'modulePages.welcome.imageSubtitle',
href: 'welcome',
hash: 'field-welcome-image-subtitle'
},
{
id: 'setting-leave-enabled',
kind: 'setting',
labelKey: 'modulePages.welcome.leaveEnabledLabel',
href: 'welcome',
hash: 'field-leave-enabled'
},
{
id: 'setting-leave-channel',
kind: 'setting',
labelKey: 'modulePages.welcome.leaveChannelId',
href: 'welcome',
hash: 'field-leave-channel'
},
{
id: 'setting-leave-content',
kind: 'setting',
labelKey: 'modulePages.welcome.leaveContent',
href: 'welcome',
hash: 'field-leave-content'
},
{
id: 'setting-leave-embed',
kind: 'setting',
labelKey: 'modulePages.welcome.leaveEmbed',
href: 'welcome',
hash: 'field-leave-embed'
},
// Verification
{
id: 'setting-verify-enabled',
kind: 'setting',
labelKey: 'modulePages.verification.enabledLabel',
href: 'verification',
hash: 'field-verify-enabled'
},
{
id: 'setting-verify-mode',
kind: 'setting',
labelKey: 'modulePages.verification.mode',
href: 'verification',
hash: 'field-verify-mode'
},
{
id: 'setting-verify-fail',
kind: 'setting',
labelKey: 'modulePages.verification.failAction',
href: 'verification',
hash: 'field-verify-fail'
},
{
id: 'setting-verify-channel',
kind: 'setting',
labelKey: 'modulePages.verification.channelId',
href: 'verification',
hash: 'field-verify-channel'
},
{
id: 'setting-verify-role',
kind: 'setting',
labelKey: 'modulePages.verification.verifiedRoleId',
href: 'verification',
hash: 'field-verify-role'
},
{
id: 'setting-verify-unverified',
kind: 'setting',
labelKey: 'modulePages.verification.unverifiedRoleId',
href: 'verification',
hash: 'field-verify-unverified'
},
{
id: 'setting-verify-age',
kind: 'setting',
labelKey: 'modulePages.verification.minAccountAgeDays',
href: 'verification',
hash: 'field-verify-age'
},
// Leveling
{
id: 'setting-leveling-enabled',
kind: 'setting',
labelKey: 'modulePages.leveling.enabledLabel',
href: 'leveling',
hash: 'field-leveling-enabled'
},
{
id: 'setting-leveling-text-min',
kind: 'setting',
labelKey: 'modulePages.leveling.textXpMin',
href: 'leveling',
hash: 'field-leveling-text-min'
},
{
id: 'setting-leveling-text-max',
kind: 'setting',
labelKey: 'modulePages.leveling.textXpMax',
href: 'leveling',
hash: 'field-leveling-text-max'
},
{
id: 'setting-leveling-cooldown',
kind: 'setting',
labelKey: 'modulePages.leveling.textCooldownSeconds',
href: 'leveling',
hash: 'field-leveling-cooldown'
},
{
id: 'setting-leveling-voice',
kind: 'setting',
labelKey: 'modulePages.leveling.voiceXpPerMinute',
href: 'leveling',
hash: 'field-leveling-voice'
},
{
id: 'setting-leveling-no-xp-channels',
kind: 'setting',
labelKey: 'modulePages.leveling.noXpChannels',
href: 'leveling',
hash: 'field-leveling-no-xp-channels'
},
{
id: 'setting-leveling-no-xp-roles',
kind: 'setting',
labelKey: 'modulePages.leveling.noXpRoles',
href: 'leveling',
hash: 'field-leveling-no-xp-roles'
},
{
id: 'setting-leveling-role-multipliers',
kind: 'setting',
labelKey: 'modulePages.leveling.roleMultipliers',
href: 'leveling',
hash: 'field-leveling-role-multipliers'
},
{
id: 'setting-leveling-channel-multipliers',
kind: 'setting',
labelKey: 'modulePages.leveling.channelMultipliers',
href: 'leveling',
hash: 'field-leveling-channel-multipliers'
},
{
id: 'setting-leveling-levelup',
kind: 'setting',
labelKey: 'modulePages.leveling.levelUpTitle',
href: 'leveling',
hash: 'field-leveling-levelup'
},
{
id: 'setting-leveling-stack',
kind: 'setting',
labelKey: 'modulePages.leveling.stackRewards',
href: 'leveling',
hash: 'field-leveling-stack'
},
{
id: 'setting-leveling-rank-card',
kind: 'setting',
labelKey: 'modulePages.leveling.rankCardTitle',
href: 'leveling',
hash: 'field-leveling-rank-card'
},
// Economy
{
id: 'setting-economy-enabled',
kind: 'setting',
labelKey: 'modulePages.economy.enabledLabel',
href: 'economy',
hash: 'field-economy-enabled'
},
{
id: 'setting-economy-currency-name',
kind: 'setting',
labelKey: 'modulePages.economy.currencyName',
href: 'economy',
hash: 'field-economy-currency-name'
},
{
id: 'setting-economy-currency-symbol',
kind: 'setting',
labelKey: 'modulePages.economy.currencySymbol',
href: 'economy',
hash: 'field-economy-currency-symbol'
},
{
id: 'setting-economy-daily',
kind: 'setting',
labelKey: 'modulePages.economy.dailyAmount',
href: 'economy',
hash: 'field-economy-daily'
},
{
id: 'setting-economy-weekly',
kind: 'setting',
labelKey: 'modulePages.economy.weeklyAmount',
href: 'economy',
hash: 'field-economy-weekly'
},
{
id: 'setting-economy-work-min',
kind: 'setting',
labelKey: 'modulePages.economy.workMin',
href: 'economy',
hash: 'field-economy-work-min'
},
{
id: 'setting-economy-work-max',
kind: 'setting',
labelKey: 'modulePages.economy.workMax',
href: 'economy',
hash: 'field-economy-work-max'
},
{
id: 'setting-economy-work-cooldown',
kind: 'setting',
labelKey: 'modulePages.economy.workCooldownSeconds',
href: 'economy',
hash: 'field-economy-work-cooldown'
},
{
id: 'setting-economy-starting',
kind: 'setting',
labelKey: 'modulePages.economy.startingBalance',
href: 'economy',
hash: 'field-economy-starting'
},
// Fun
{
id: 'setting-fun-enabled',
kind: 'setting',
labelKey: 'modulePages.fun.enabledLabel',
href: 'fun',
hash: 'field-fun-enabled'
},
{
id: 'setting-fun-media',
kind: 'setting',
labelKey: 'modulePages.fun.mediaEnabledLabel',
href: 'fun',
hash: 'field-fun-media'
},
// Tickets
{
id: 'setting-tickets-enabled',
kind: 'setting',
labelKey: 'modulePages.tickets.enabledLabel',
href: 'tickets',
hash: 'field-tickets-enabled'
},
{
id: 'setting-tickets-mode',
kind: 'setting',
labelKey: 'modulePages.tickets.mode',
href: 'tickets',
hash: 'field-tickets-mode'
},
{
id: 'setting-tickets-category',
kind: 'setting',
labelKey: 'modulePages.tickets.categoryId',
href: 'tickets',
hash: 'field-tickets-category'
},
{
id: 'setting-tickets-log',
kind: 'setting',
labelKey: 'modulePages.tickets.logChannelId',
href: 'tickets',
hash: 'field-tickets-log'
},
{
id: 'setting-tickets-inactivity',
kind: 'setting',
labelKey: 'modulePages.tickets.inactivityHours',
href: 'tickets',
hash: 'field-tickets-inactivity'
},
{
id: 'setting-tickets-transcript',
kind: 'setting',
labelKey: 'modulePages.tickets.transcriptDm',
href: 'tickets',
hash: 'field-tickets-transcript'
},
{
id: 'setting-tickets-rating',
kind: 'setting',
labelKey: 'modulePages.tickets.ratingEnabled',
href: 'tickets',
hash: 'field-tickets-rating'
},
{
id: 'setting-tickets-categories',
kind: 'setting',
labelKey: 'modulePages.tickets.categoriesTitle',
href: 'tickets',
hash: 'field-tickets-categories'
},
// Giveaways
{
id: 'setting-giveaways-create',
kind: 'setting',
labelKey: 'modulePages.giveaways.createTitle',
href: 'giveaways',
hash: 'field-giveaways-create'
},
{
id: 'setting-giveaways-list',
kind: 'setting',
labelKey: 'modulePages.giveaways.listTitle',
href: 'giveaways',
hash: 'field-giveaways-list'
},
// Selfroles
{
id: 'setting-selfroles-create',
kind: 'setting',
labelKey: 'modulePages.selfroles.createTitle',
href: 'selfroles',
hash: 'field-selfroles-create'
},
{
id: 'setting-selfroles-list',
kind: 'setting',
labelKey: 'modulePages.selfroles.listTitle',
href: 'selfroles',
hash: 'field-selfroles-list'
},
// Tags
{
id: 'setting-tags-create',
kind: 'setting',
labelKey: 'modulePages.tags.createTitle',
href: 'tags',
hash: 'field-tags-create'
},
{
id: 'setting-tags-list',
kind: 'setting',
labelKey: 'modulePages.tags.listTitle',
href: 'tags',
hash: 'field-tags-list'
},
// Starboard
{
id: 'setting-starboard-enabled',
kind: 'setting',
labelKey: 'modulePages.starboard.enabledLabel',
href: 'starboard',
hash: 'field-starboard-enabled'
},
{
id: 'setting-starboard-channel',
kind: 'setting',
labelKey: 'modulePages.starboard.channelId',
href: 'starboard',
hash: 'field-starboard-channel'
},
{
id: 'setting-starboard-emoji',
kind: 'setting',
labelKey: 'modulePages.starboard.emoji',
href: 'starboard',
hash: 'field-starboard-emoji'
},
{
id: 'setting-starboard-threshold',
kind: 'setting',
labelKey: 'modulePages.starboard.threshold',
href: 'starboard',
hash: 'field-starboard-threshold'
},
{
id: 'setting-starboard-self',
kind: 'setting',
labelKey: 'modulePages.starboard.allowSelfStar',
href: 'starboard',
hash: 'field-starboard-self'
},
{
id: 'setting-starboard-ignored',
kind: 'setting',
labelKey: 'modulePages.starboard.ignoredChannelIds',
href: 'starboard',
hash: 'field-starboard-ignored'
},
// Suggestions
{
id: 'setting-suggestions-enabled',
kind: 'setting',
labelKey: 'modulePages.suggestions.enabledLabel',
href: 'suggestions',
hash: 'field-suggestions-enabled'
},
{
id: 'setting-suggestions-open',
kind: 'setting',
labelKey: 'modulePages.suggestions.openChannelId',
href: 'suggestions',
hash: 'field-suggestions-open'
},
{
id: 'setting-suggestions-approved',
kind: 'setting',
labelKey: 'modulePages.suggestions.approvedChannelId',
href: 'suggestions',
hash: 'field-suggestions-approved'
},
{
id: 'setting-suggestions-denied',
kind: 'setting',
labelKey: 'modulePages.suggestions.deniedChannelId',
href: 'suggestions',
hash: 'field-suggestions-denied'
},
{
id: 'setting-suggestions-thread',
kind: 'setting',
labelKey: 'modulePages.suggestions.createThread',
href: 'suggestions',
hash: 'field-suggestions-thread'
},
{
id: 'setting-suggestions-list',
kind: 'setting',
labelKey: 'modulePages.suggestions.listTitle',
href: 'suggestions',
hash: 'field-suggestions-list'
},
// Birthdays
{
id: 'setting-birthdays-enabled',
kind: 'setting',
labelKey: 'modulePages.birthdays.enabledLabel',
href: 'birthdays',
hash: 'field-birthdays-enabled'
},
{
id: 'setting-birthdays-channel',
kind: 'setting',
labelKey: 'modulePages.birthdays.channelId',
href: 'birthdays',
hash: 'field-birthdays-channel'
},
{
id: 'setting-birthdays-role',
kind: 'setting',
labelKey: 'modulePages.birthdays.roleId',
href: 'birthdays',
hash: 'field-birthdays-role'
},
{
id: 'setting-birthdays-timezone',
kind: 'setting',
labelKey: 'modulePages.birthdays.timezone',
href: 'birthdays',
hash: 'field-birthdays-timezone'
},
{
id: 'setting-birthdays-message',
kind: 'setting',
labelKey: 'modulePages.birthdays.message',
href: 'birthdays',
hash: 'field-birthdays-message'
},
// Tempvoice
{
id: 'setting-tempvoice-enabled',
kind: 'setting',
labelKey: 'modulePages.tempvoice.enabledLabel',
href: 'tempvoice',
hash: 'field-tempvoice-enabled'
},
{
id: 'setting-tempvoice-hub',
kind: 'setting',
labelKey: 'modulePages.tempvoice.hubChannelId',
href: 'tempvoice',
hash: 'field-tempvoice-hub'
},
{
id: 'setting-tempvoice-category',
kind: 'setting',
labelKey: 'modulePages.tempvoice.categoryId',
href: 'tempvoice',
hash: 'field-tempvoice-category'
},
{
id: 'setting-tempvoice-name',
kind: 'setting',
labelKey: 'modulePages.tempvoice.defaultName',
href: 'tempvoice',
hash: 'field-tempvoice-name'
},
{
id: 'setting-tempvoice-limit',
kind: 'setting',
labelKey: 'modulePages.tempvoice.defaultLimit',
href: 'tempvoice',
hash: 'field-tempvoice-limit'
},
// Stats
{
id: 'setting-stats-enabled',
kind: 'setting',
labelKey: 'modulePages.stats.enabledLabel',
href: 'stats',
hash: 'field-stats-enabled'
},
{
id: 'setting-stats-members',
kind: 'setting',
labelKey: 'modulePages.stats.membersChannelId',
href: 'stats',
hash: 'field-stats-members'
},
{
id: 'setting-stats-online',
kind: 'setting',
labelKey: 'modulePages.stats.onlineChannelId',
href: 'stats',
hash: 'field-stats-online'
},
{
id: 'setting-stats-boosts',
kind: 'setting',
labelKey: 'modulePages.stats.boostsChannelId',
href: 'stats',
hash: 'field-stats-boosts'
},
// Feeds
{
id: 'setting-feeds-add',
kind: 'setting',
labelKey: 'modulePages.feeds.addFeed',
href: 'feeds',
hash: 'field-feeds-add'
},
// Scheduler
{
id: 'setting-scheduler-create',
kind: 'setting',
labelKey: 'modulePages.scheduler.createTitle',
href: 'scheduler',
hash: 'field-scheduler-create'
},
// Backup
{
id: 'setting-backup',
kind: 'setting',
labelKey: 'modules.guildbackup.label',
href: 'backup',
hash: 'field-backup',
keywords: ['backup', 'restore']
}
];
export const OWNER_NAV_ENTRIES: SearchIndexEntry[] = [
{ id: 'owner-overview', kind: 'page', labelKey: 'owner.nav.overview', href: '/owner' },
{ id: 'owner-guilds', kind: 'page', labelKey: 'owner.nav.guilds', href: '/owner/guilds' },
{ id: 'owner-users', kind: 'page', labelKey: 'owner.nav.users', href: '/owner/users' },
{ id: 'owner-flags', kind: 'page', labelKey: 'owner.nav.flags', href: '/owner/flags' },
{ id: 'owner-premium', kind: 'page', labelKey: 'owner.nav.premium', href: '/owner/premium' },
{ id: 'owner-presence', kind: 'page', labelKey: 'owner.nav.presence', href: '/owner/presence' },
{ id: 'owner-team', kind: 'page', labelKey: 'owner.nav.team', href: '/owner/team' },
{ id: 'owner-jobs', kind: 'page', labelKey: 'owner.nav.jobs', href: '/owner/jobs' },
{ id: 'owner-changelog', kind: 'page', labelKey: 'owner.nav.changelog', href: '/owner/changelog' },
{ id: 'owner-audit', kind: 'page', labelKey: 'owner.nav.audit', href: '/owner/audit' }
];
export function buildGuildSearchIndex(): SearchIndexEntry[] {
return [...CORE_PAGES, ...MODULE_PAGES, ...COMMAND_ENTRIES, ...SETTINGS_SEARCH_FIELDS];
}
/** Commands use query string; settings use hash; pages are plain paths. */
export function buildGuildSearchHref(guildId: string, entry: SearchIndexEntry): string {
if (entry.kind === 'command') {
return `/dashboard/${guildId}/${entry.href}`;
}
const base = entry.href ? `/dashboard/${guildId}/${entry.href}` : `/dashboard/${guildId}`;
return entry.hash ? `${base}#${entry.hash}` : base;
}

View File

@@ -770,5 +770,16 @@
"updatesTitle": "Updates & Incidents", "updatesTitle": "Updates & Incidents",
"updatesEmpty": "Noch keine Einträge.", "updatesEmpty": "Noch keine Einträge.",
"incident": "Incident" "incident": "Incident"
},
"search": {
"title": "Suche",
"placeholder": "Seiten, Befehle oder Optionen suchen…",
"empty": "Keine Treffer",
"openNav": "Navigation öffnen",
"groups": {
"pages": "Seiten",
"commands": "Befehle",
"settings": "Optionen"
}
} }
} }

View File

@@ -770,5 +770,16 @@
"updatesTitle": "Updates & incidents", "updatesTitle": "Updates & incidents",
"updatesEmpty": "No entries yet.", "updatesEmpty": "No entries yet.",
"incident": "Incident" "incident": "Incident"
},
"search": {
"title": "Search",
"placeholder": "Search pages, commands, or settings…",
"empty": "No results",
"openNav": "Open navigation",
"groups": {
"pages": "Pages",
"commands": "Commands",
"settings": "Settings"
}
} }
} }

View File

@@ -67,6 +67,27 @@
- Premium-Zahlungsanbindung - Premium-Zahlungsanbindung
- Externe Marketing-Landing auf `nexumi.de` - Externe Marketing-Landing auf `nexumi.de`
## Post-Phase Dashboard-UI + Command-Palette + Landing-Polish (Status: implementiert)
### Abgeschlossen (Code)
- Guild-/Owner-Shell: Branding, Sticky-Header, mobile Sidebar-Sheet, Overview-Polish
- Globale Cmd/Ctrl+K-Suche: Seiten, Slash-Commands, Einstellungsfelder (Deep-Links)
- Settings-Feld-Registry + Form-Anchors; Commands-Seite liest `?q=`
- Plesk-Landing: Inter, Hero-Hierarchie, dezente Motion, Designsprache an WebUI
### Manuell testen
- [ ] Dashboard: Cmd/Ctrl+K → Seite, Command und Option navigieren (Hash-Scroll)
- [ ] Mobile: Sidebar-Sheet + Such-Trigger
- [ ] Overview und Owner-Panel Layout
- [ ] Landing Light/Dark (`prefers-color-scheme`) + CTAs
### Bewusst offen
- Musik- und KI-Modul (nur auf Zuruf)
- Premium-Zahlungsanbindung
## Nächster geplanter Schritt ## Nächster geplanter Schritt
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe). - Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe).

View File

@@ -9,7 +9,9 @@
--secondary: #f1f1f5; --secondary: #f1f1f5;
--secondary-foreground: #111116; --secondary-foreground: #111116;
--border: #e4e4ea; --border: #e4e4ea;
--ring: #6366f1;
--radius: 0.625rem; --radius: 0.625rem;
--hero-glow: rgba(99, 102, 241, 0.12);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -22,6 +24,7 @@
--secondary: #1b1b22; --secondary: #1b1b22;
--secondary-foreground: #e6e6ea; --secondary-foreground: #e6e6ea;
--border: #23232b; --border: #23232b;
--hero-glow: rgba(99, 102, 241, 0.18);
} }
} }
@@ -39,10 +42,11 @@ body {
body { body {
min-height: 100vh; min-height: 100vh;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased;
} }
a { a {
@@ -72,6 +76,14 @@ a {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
header {
position: sticky;
top: 0;
z-index: 40;
background: color-mix(in srgb, var(--background) 80%, transparent);
backdrop-filter: blur(12px);
}
header .bar { header .bar {
display: flex; display: flex;
height: 3.5rem; height: 3.5rem;
@@ -82,8 +94,9 @@ header .bar {
.brand { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.625rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em;
} }
.brand img { .brand img {
@@ -110,12 +123,12 @@ nav {
font-weight: 500; font-weight: 500;
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
} }
.btn-lg { .btn-lg {
height: 2.75rem; height: 2.75rem;
padding: 0 1.25rem; padding: 0 1.35rem;
font-size: 1rem; font-size: 1rem;
} }
@@ -126,6 +139,7 @@ nav {
.btn-primary:hover { .btn-primary:hover {
filter: brightness(0.95); filter: brightness(0.95);
transform: translateY(-1px);
} }
.btn-secondary { .btn-secondary {
@@ -152,17 +166,35 @@ main {
flex: 1; flex: 1;
} }
.hero-section {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
}
.hero-section::before {
content: '';
position: absolute;
inset: -20% 20% auto;
height: 55%;
background: radial-gradient(ellipse at center, var(--hero-glow), transparent 70%);
pointer-events: none;
}
.hero { .hero {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 1.5rem;
padding-top: 4rem; min-height: calc(100vh - 3.5rem);
justify-content: center;
padding-top: 3.5rem;
padding-bottom: 4rem; padding-bottom: 4rem;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.hero { .hero {
padding-top: 5rem; padding-top: 4rem;
padding-bottom: 5rem; padding-bottom: 5rem;
} }
} }
@@ -170,45 +202,65 @@ main {
.hero-brand { .hero-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.875rem;
animation: rise 0.55s ease both;
} }
.hero-brand img { .hero-brand img {
width: 48px; width: 56px;
height: 48px; height: 56px;
} }
.hero h1 { .hero-brand h1 {
margin: 0; margin: 0;
font-size: 2.25rem; font-size: 2.75rem;
font-weight: 600; font-weight: 700;
letter-spacing: -0.025em; letter-spacing: -0.04em;
line-height: 1.05;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.hero h1 { .hero-brand h1 {
font-size: 3rem; font-size: 4rem;
} }
} }
.hero p { .hero-headline {
margin: 0; margin: 0;
max-width: 42rem; max-width: 36rem;
font-size: 1.125rem; font-size: 1.35rem;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.35;
animation: rise 0.55s ease 0.08s both;
}
@media (min-width: 640px) {
.hero-headline {
font-size: 1.5rem;
}
}
.hero-subtitle {
margin: 0;
max-width: 36rem;
font-size: 1.05rem;
color: var(--muted-foreground); color: var(--muted-foreground);
animation: rise 0.55s ease 0.16s both;
} }
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
animation: rise 0.55s ease 0.24s both;
} }
.stats { .stats {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
padding-top: 3rem; padding-top: 3.5rem;
padding-bottom: 3rem; padding-bottom: 3.5rem;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
@@ -217,63 +269,75 @@ main {
} }
} }
.card { .stat-card {
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 1rem 1.25rem; padding: 1.35rem 1.5rem;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.stat-card:hover {
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
transform: translateY(-2px);
} }
.card-label { .card-label {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 0.875rem; font-size: 0.75rem;
font-weight: 500; font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground); color: var(--muted-foreground);
} }
.card-value { .card-value {
margin: 0; margin: 0;
font-size: 1.875rem; font-size: 2rem;
font-weight: 600; font-weight: 650;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
} }
.features { .features {
padding-top: 3rem; padding-top: 3.5rem;
padding-bottom: 3rem; padding-bottom: 4rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2.5rem;
} }
.features > .intro h2 { .features > .intro h2 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 600; font-weight: 650;
letter-spacing: -0.03em;
} }
.features > .intro p { .features > .intro p {
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
max-width: 36rem;
color: var(--muted-foreground); color: var(--muted-foreground);
} }
.group { .group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.85rem;
} }
.group h3 { .group h3 {
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.75rem;
font-weight: 600; font-weight: 650;
letter-spacing: 0.05em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted-foreground); color: var(--muted-foreground);
} }
.grid { .grid {
display: grid; display: grid;
gap: 0.75rem; gap: 0.85rem;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
@@ -288,16 +352,31 @@ main {
} }
} }
.module-card {
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.1rem 1.2rem;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.module-card:hover {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border));
background: color-mix(in srgb, var(--primary) 6%, transparent);
}
.module-title { .module-title {
margin: 0 0 0.5rem; margin: 0 0 0.4rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em;
} }
.module-desc { .module-desc {
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--muted-foreground); color: var(--muted-foreground);
line-height: 1.55;
} }
footer .bar { footer .bar {
@@ -404,3 +483,27 @@ footer a:hover {
.legal-lines { .legal-lines {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@keyframes rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.hero-brand,
.hero-headline,
.hero-subtitle,
.actions,
.stat-card,
.module-card,
.btn {
animation: none !important;
transition: none !important;
}
}

View File

@@ -24,6 +24,9 @@ function render_header(array $config, array $messages, string $locale, string $t
<title><?= h($title) ?></title> <title><?= h($title) ?></title>
<meta name="description" content="<?= h($description) ?>"> <meta name="description" content="<?= h($description) ?>">
<link rel="icon" href="assets/nexumi-logo.svg" type="image/svg+xml"> <link rel="icon" href="assets/nexumi-logo.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css"> <link rel="stylesheet" href="assets/style.css">
</head> </head>
<body> <body>

View File

@@ -59,13 +59,14 @@ render_header(
t($messages, 'meta.description') t($messages, 'meta.description')
); );
?> ?>
<section class="border-b"> <section class="hero-section">
<div class="container hero"> <div class="container hero">
<div class="hero-brand"> <div class="hero-brand">
<img src="assets/nexumi-logo.svg" alt="" width="48" height="48"> <img src="assets/nexumi-logo.svg" alt="" width="56" height="56">
<h1>Nexumi</h1> <h1>Nexumi</h1>
</div> </div>
<p><?= h(t($messages, 'landing.subtitle')) ?></p> <p class="hero-headline"><?= h(t($messages, 'landing.headline')) ?></p>
<p class="hero-subtitle"><?= h(t($messages, 'landing.subtitle')) ?></p>
<div class="actions"> <div class="actions">
<a class="btn btn-lg btn-primary" href="<?= h($inviteUrl) ?>" target="_blank" rel="noreferrer"><?= h(t($messages, 'landing.invite')) ?></a> <a class="btn btn-lg btn-primary" href="<?= h($inviteUrl) ?>" target="_blank" rel="noreferrer"><?= h(t($messages, 'landing.invite')) ?></a>
<a class="btn btn-lg btn-secondary" href="<?= h($loginUrl) ?>"><?= h(t($messages, 'landing.login')) ?></a> <a class="btn btn-lg btn-secondary" href="<?= h($loginUrl) ?>"><?= h(t($messages, 'landing.login')) ?></a>
@@ -78,11 +79,11 @@ render_header(
<section class="border-b"> <section class="border-b">
<div class="container stats"> <div class="container stats">
<div class="card"> <div class="stat-card">
<p class="card-label"><?= h(t($messages, 'landing.stats_guilds')) ?></p> <p class="card-label"><?= h(t($messages, 'landing.stats_guilds')) ?></p>
<p class="card-value"><?= h($formatNumber($guildCount)) ?></p> <p class="card-value"><?= h($formatNumber($guildCount)) ?></p>
</div> </div>
<div class="card"> <div class="stat-card">
<p class="card-label"><?= h(t($messages, 'landing.stats_users')) ?></p> <p class="card-label"><?= h(t($messages, 'landing.stats_users')) ?></p>
<p class="card-value"><?= h($formatNumber($userCount)) ?></p> <p class="card-value"><?= h($formatNumber($userCount)) ?></p>
</div> </div>
@@ -100,7 +101,7 @@ render_header(
<h3><?= h($group['label']) ?></h3> <h3><?= h($group['label']) ?></h3>
<div class="grid"> <div class="grid">
<?php foreach ($group['modules'] as $module): ?> <?php foreach ($group['modules'] as $module): ?>
<div class="card"> <div class="module-card">
<p class="module-title"><?= h($module['label']) ?></p> <p class="module-title"><?= h($module['label']) ?></p>
<p class="module-desc"><?= h($module['description']) ?></p> <p class="module-desc"><?= h($module['description']) ?></p>
</div> </div>

View File

@@ -16,6 +16,7 @@ return [
'dashboard' => 'Dashboard', 'dashboard' => 'Dashboard',
], ],
'landing' => [ 'landing' => [
'headline' => 'Moderation, Community und Integrationen in einem Bot.',
'subtitle' => 'Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.', 'subtitle' => 'Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.',
'invite' => 'Bot einladen', 'invite' => 'Bot einladen',
'login' => 'Zum Dashboard', 'login' => 'Zum Dashboard',

View File

@@ -16,6 +16,7 @@ return [
'dashboard' => 'Dashboard', 'dashboard' => 'Dashboard',
], ],
'landing' => [ 'landing' => [
'headline' => 'Moderation, community, and integrations — in one bot.',
'subtitle' => 'Discord bot with moderation, community, and integration modules plus a web dashboard.', 'subtitle' => 'Discord bot with moderation, community, and integration modules plus a web dashboard.',
'invite' => 'Invite bot', 'invite' => 'Invite bot',
'login' => 'Open dashboard', 'login' => 'Open dashboard',