diff --git a/apps/webui/package.json b/apps/webui/package.json index d2486ad..278a552 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -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", diff --git a/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx index 4362d01..e6bcfaa 100644 --- a/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx @@ -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) {

{t(locale, 'modulePages.commands.title')}

{t(locale, 'modulePages.commands.description')}

- + + + ); } diff --git a/apps/webui/src/app/dashboard/[guildId]/page.tsx b/apps/webui/src/app/dashboard/[guildId]/page.tsx index ed8c6e3..c6ac62a 100644 --- a/apps/webui/src/app/dashboard/[guildId]/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/page.tsx @@ -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 ( -
-

{t(locale, 'dashboard.overview.title')}

+
+
+

{t(locale, 'dashboard.overview.title')}

+

+ {t(locale, 'dashboard.overview.activityHint')} +

+
-

{t(locale, 'dashboard.overview.activityTitle')}

-
- - -

{t(locale, 'dashboard.overview.activityMessages')}

-

{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+
+ +

{t(locale, 'dashboard.overview.activityTitle')}

+
+
+ + +
+ +
+
+

+ {t(locale, 'dashboard.overview.activityMessages')} +

+

+ {activitySummary.messageCount.toLocaleString(numberLocale)} +

+
- - -

{t(locale, 'dashboard.overview.activityVoiceMinutes')}

-

{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+ + +
+ +
+
+

+ {t(locale, 'dashboard.overview.activityVoiceMinutes')} +

+

+ {activitySummary.voiceMinutes.toLocaleString(numberLocale)} +

+
- - -

{t(locale, 'dashboard.overview.activityActiveUsers')}

-

{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+ + +
+ +
+
+

+ {t(locale, 'dashboard.overview.activityActiveUsers')} +

+

+ {activitySummary.activeUserCount.toLocaleString(numberLocale)} +

+
-

{t(locale, 'dashboard.overview.activityHint')}

-
-

{t(locale, 'dashboard.overview.moduleStatusTitle')}

- +
+
+

{t(locale, 'dashboard.overview.moduleStatusTitle')}

+ {modules.length > 0 ? ( +

+ {enabledCount}/{modules.length} {t(locale, 'common.enabled').toLowerCase()} +

+ ) : null} +
+ {t(locale, 'dashboard.overview.viewAllModules')}
{modules.length === 0 ? ( -

{t(locale, 'dashboard.overview.moduleStatusEmpty')}

+ + + {t(locale, 'dashboard.overview.moduleStatusEmpty')} + + ) : (
{modules.map((module) => ( - - - {t(locale, `modules.${module.id}.label`)} - - {t(locale, module.enabled ? 'common.enabled' : 'common.disabled')} - - - + + + + {t(locale, `modules.${module.id}.label`)} + + {t(locale, module.enabled ? 'common.enabled' : 'common.disabled')} + + + + ))}
)} @@ -82,22 +141,23 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {

{t(locale, 'dashboard.overview.recentAuditTitle')}

- - - - {t(locale, 'dashboard.overview.recentAuditTitle')} - + + + {t(locale, 'dashboard.overview.recentAuditTitle')} + {t(locale, 'dashboard.overview.auditPath')} - + {auditEntries.length === 0 ? ( -

{t(locale, 'dashboard.overview.auditEmpty')}

+

+ {t(locale, 'dashboard.overview.auditEmpty')} +

) : (
    {auditEntries.map((entry) => ( -
  • +
  • {entry.action}

    - {entry.path &&

    {entry.path}

    } + {entry.path ?

    {entry.path}

    : null}
    {formatDate(entry.createdAt, locale)} diff --git a/apps/webui/src/app/globals.css b/apps/webui/src/app/globals.css index 86addc2..fefdfdc 100644 --- a/apps/webui/src/app/globals.css +++ b/apps/webui/src/app/globals.css @@ -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; } diff --git a/apps/webui/src/components/layout/dashboard-command-palette.tsx b/apps/webui/src/components/layout/dashboard-command-palette.tsx new file mode 100644 index 0000000..0398685 --- /dev/null +++ b/apps/webui/src/components/layout/dashboard-command-palette.tsx @@ -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 ( + <> + + + + + + {t('search.empty')} + {pages.length > 0 ? ( + + {pages.map((entry) => { + const Icon = kindIcon(entry.kind); + const label = resolveLabel(entry); + return ( + navigateTo(entry)} + > + + {label} + + ); + })} + + ) : null} + {commands.length > 0 ? ( + + {commands.map((entry) => { + const Icon = kindIcon(entry.kind); + const label = resolveLabel(entry); + return ( + navigateTo(entry)} + > + + {label} + + ); + })} + + ) : null} + {settings.length > 0 ? ( + + {settings.map((entry) => { + const Icon = kindIcon(entry.kind); + const label = resolveLabel(entry); + return ( + navigateTo(entry)} + > + + {label} + + ); + })} + + ) : null} + + + + ); +} diff --git a/apps/webui/src/components/layout/dashboard-shell.tsx b/apps/webui/src/components/layout/dashboard-shell.tsx index c0e1746..904d83c 100644 --- a/apps/webui/src/components/layout/dashboard-shell.tsx +++ b/apps/webui/src/components/layout/dashboard-shell.tsx @@ -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 ( -
    +
    + -
    -
    +
    +
    + + + + + + {t('nav.dashboard')} + setMobileOpen(false)} /> + + + - +
    +
    + +
    + +
    -
    +
    {children}
    diff --git a/apps/webui/src/components/layout/field-anchor.tsx b/apps/webui/src/components/layout/field-anchor.tsx new file mode 100644 index 0000000..fca2a23 --- /dev/null +++ b/apps/webui/src/components/layout/field-anchor.tsx @@ -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 ( +
    + {children} +
    + ); +} + +/** 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; +} diff --git a/apps/webui/src/components/layout/sidebar.tsx b/apps/webui/src/components/layout/sidebar.tsx index 2b2a641..6a3150a 100644 --- a/apps/webui/src/components/layout/sidebar.tsx +++ b/apps/webui/src/components/layout/sidebar.tsx @@ -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 = { + 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 ( {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 ( -