deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
49 changed files with 2393 additions and 320 deletions
Showing only changes of commit 75ac91eecc - Show all commits

View File

@@ -15,6 +15,7 @@
"@nexumi/shared": "workspace:^",
"@prisma/client": "^5.22.0",
"@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-label": "^2.1.12",
"@radix-ui/react-select": "^2.3.4",
@@ -24,6 +25,7 @@
"bullmq": "^5.34.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^16.4.7",
"ioredis": "^5.4.2",
"lucide-react": "^1.25.0",

View File

@@ -1,3 +1,4 @@
import { Suspense } from 'react';
import { CommandsManager } from '@/components/modules/commands-manager';
import { getLocale, t } from '@/lib/i18n';
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>
<p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
</div>
<CommandsManager guildId={guildId} initialCommands={commands} />
<Suspense fallback={null}>
<CommandsManager guildId={guildId} initialCommands={commands} />
</Suspense>
</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 { getActivitySummary } from '@/lib/activity';
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 { getLocale, t } from '@/lib/i18n';
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) {
const { guildId } = await params;
const locale = await getLocale();
@@ -26,55 +32,108 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
getActivitySummary(guildId)
]);
const enabledCount = modules.filter((module) => module.enabled).length;
const numberLocale = locale === 'de' ? 'de-DE' : 'en-US';
return (
<div className="space-y-8">
<h1 className="text-2xl font-semibold">{t(locale, 'dashboard.overview.title')}</h1>
<div className="space-y-10">
<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">
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityMessages')}</p>
<p className="text-2xl font-semibold">{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
<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>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card className="border-border/80 bg-card/80">
<CardContent className="flex items-start gap-3 p-5">
<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>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityVoiceMinutes')}</p>
<p className="text-2xl font-semibold">{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
<Card className="border-border/80 bg-card/80">
<CardContent className="flex items-start gap-3 p-5">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<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>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityActiveUsers')}</p>
<p className="text-2xl font-semibold">{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
<Card className="border-border/80 bg-card/80">
<CardContent className="flex items-start gap-3 p-5">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<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>
</Card>
</div>
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityHint')}</p>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
<Link href={`/dashboard/${guildId}/modules`} className="text-sm text-primary hover:underline">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
{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')}
</Link>
</div>
{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">
{modules.map((module) => (
<Card key={module.id}>
<CardContent className="flex items-center justify-between p-4">
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
<Badge variant={module.enabled ? 'success' : 'secondary'}>
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
</Badge>
</CardContent>
</Card>
<Link key={module.id} href={`/dashboard/${guildId}/${moduleHref(module.id)}`}>
<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>
<Badge variant={module.enabled ? 'success' : 'secondary'}>
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
@@ -82,22 +141,23 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
<section className="space-y-4">
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
{t(locale, 'dashboard.overview.recentAuditTitle')}
</CardTitle>
<Card className="border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t(locale, 'dashboard.overview.recentAuditTitle')}</CardTitle>
<CardDescription>{t(locale, 'dashboard.overview.auditPath')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CardContent>
{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">
{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">
<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>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{formatDate(entry.createdAt, locale)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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",
"updatesEmpty": "Noch keine Einträge.",
"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",
"updatesEmpty": "No entries yet.",
"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
- 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
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe).

View File

@@ -9,7 +9,9 @@
--secondary: #f1f1f5;
--secondary-foreground: #111116;
--border: #e4e4ea;
--ring: #6366f1;
--radius: 0.625rem;
--hero-glow: rgba(99, 102, 241, 0.12);
}
@media (prefers-color-scheme: dark) {
@@ -22,6 +24,7 @@
--secondary: #1b1b22;
--secondary-foreground: #e6e6ea;
--border: #23232b;
--hero-glow: rgba(99, 102, 241, 0.18);
}
}
@@ -39,10 +42,11 @@ body {
body {
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);
color: var(--foreground);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
@@ -72,6 +76,14 @@ a {
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 {
display: flex;
height: 3.5rem;
@@ -82,8 +94,9 @@ header .bar {
.brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: 0.625rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.brand img {
@@ -110,12 +123,12 @@ nav {
font-weight: 500;
border: 1px solid transparent;
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 {
height: 2.75rem;
padding: 0 1.25rem;
padding: 0 1.35rem;
font-size: 1rem;
}
@@ -126,6 +139,7 @@ nav {
.btn-primary:hover {
filter: brightness(0.95);
transform: translateY(-1px);
}
.btn-secondary {
@@ -152,17 +166,35 @@ main {
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 {
position: relative;
display: flex;
flex-direction: column;
gap: 2rem;
padding-top: 4rem;
gap: 1.5rem;
min-height: calc(100vh - 3.5rem);
justify-content: center;
padding-top: 3.5rem;
padding-bottom: 4rem;
}
@media (min-width: 640px) {
.hero {
padding-top: 5rem;
padding-top: 4rem;
padding-bottom: 5rem;
}
}
@@ -170,45 +202,65 @@ main {
.hero-brand {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.875rem;
animation: rise 0.55s ease both;
}
.hero-brand img {
width: 48px;
height: 48px;
width: 56px;
height: 56px;
}
.hero h1 {
.hero-brand h1 {
margin: 0;
font-size: 2.25rem;
font-weight: 600;
letter-spacing: -0.025em;
font-size: 2.75rem;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.05;
}
@media (min-width: 640px) {
.hero h1 {
font-size: 3rem;
.hero-brand h1 {
font-size: 4rem;
}
}
.hero p {
.hero-headline {
margin: 0;
max-width: 42rem;
font-size: 1.125rem;
max-width: 36rem;
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);
animation: rise 0.55s ease 0.16s both;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
animation: rise 0.55s ease 0.24s both;
}
.stats {
display: grid;
gap: 1rem;
padding-top: 3rem;
padding-bottom: 3rem;
padding-top: 3.5rem;
padding-bottom: 3.5rem;
}
@media (min-width: 640px) {
@@ -217,63 +269,75 @@ main {
}
}
.card {
.stat-card {
background: var(--card);
border: 1px solid var(--border);
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 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.card-value {
margin: 0;
font-size: 1.875rem;
font-weight: 600;
font-size: 2rem;
font-weight: 650;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
}
.features {
padding-top: 3rem;
padding-bottom: 3rem;
padding-top: 3.5rem;
padding-bottom: 4rem;
display: flex;
flex-direction: column;
gap: 2rem;
gap: 2.5rem;
}
.features > .intro h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
font-size: 1.75rem;
font-weight: 650;
letter-spacing: -0.03em;
}
.features > .intro p {
margin: 0.5rem 0 0;
max-width: 36rem;
color: var(--muted-foreground);
}
.group {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.85rem;
}
.group h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.05em;
font-size: 0.75rem;
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.grid {
display: grid;
gap: 0.75rem;
gap: 0.85rem;
}
@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 {
margin: 0 0 0.5rem;
margin: 0 0 0.4rem;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.module-desc {
margin: 0;
font-size: 0.875rem;
color: var(--muted-foreground);
line-height: 1.55;
}
footer .bar {
@@ -404,3 +483,27 @@ footer a:hover {
.legal-lines {
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>
<meta name="description" content="<?= h($description) ?>">
<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">
</head>
<body>

View File

@@ -59,13 +59,14 @@ render_header(
t($messages, 'meta.description')
);
?>
<section class="border-b">
<section class="hero-section">
<div class="container hero">
<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>
</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">
<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>
@@ -78,11 +79,11 @@ render_header(
<section class="border-b">
<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-value"><?= h($formatNumber($guildCount)) ?></p>
</div>
<div class="card">
<div class="stat-card">
<p class="card-label"><?= h(t($messages, 'landing.stats_users')) ?></p>
<p class="card-value"><?= h($formatNumber($userCount)) ?></p>
</div>
@@ -100,7 +101,7 @@ render_header(
<h3><?= h($group['label']) ?></h3>
<div class="grid">
<?php foreach ($group['modules'] as $module): ?>
<div class="card">
<div class="module-card">
<p class="module-title"><?= h($module['label']) ?></p>
<p class="module-desc"><?= h($module['description']) ?></p>
</div>

View File

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

View File

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