Update fun module configuration and enhance CSS styles
- Added 'enabled' property to FunConfigSchema with a default value of true for better configuration management. - Refactored getFunConfig to return a complete default configuration when no data is found. - Improved globals.css by expanding CSS variables for light and dark themes, enhancing styling consistency across the application.
This commit is contained in:
30
apps/webui/src/components/layout/dashboard-shell.tsx
Normal file
30
apps/webui/src/components/layout/dashboard-shell.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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';
|
||||
|
||||
interface DashboardShellProps {
|
||||
guildId: string;
|
||||
currentGuild: DashboardGuild;
|
||||
otherGuilds: DashboardGuild[];
|
||||
user: SessionUser;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardShell({ guildId, currentGuild, otherGuilds, user, children }: DashboardShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<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">
|
||||
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
||||
<UserMenu user={user} />
|
||||
</header>
|
||||
<main className="flex-1 px-6 py-8">
|
||||
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/webui/src/components/layout/server-switcher.tsx
Normal file
77
apps/webui/src/components/layout/server-switcher.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import type { DashboardGuild } from '@nexumi/shared';
|
||||
import { ChevronsUpDown, LayoutGrid } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
function guildIconUrl(guild: Pick<DashboardGuild, 'id' | 'icon'>): string | undefined {
|
||||
return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
interface ServerSwitcherProps {
|
||||
currentGuild: DashboardGuild;
|
||||
otherGuilds: DashboardGuild[];
|
||||
}
|
||||
|
||||
export function ServerSwitcher({ currentGuild, otherGuilds }: ServerSwitcherProps) {
|
||||
const t = useTranslations();
|
||||
const botGuilds = otherGuilds.filter((guild) => guild.botPresent);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={guildIconUrl(currentGuild)} alt={currentGuild.name} />
|
||||
<AvatarFallback>{initials(currentGuild.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-40 truncate">{currentGuild.name}</span>
|
||||
<ChevronsUpDown className="size-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel>{t('serverSwitcher.switchServer')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{botGuilds.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-sm text-muted-foreground">{t('serverSwitcher.noOtherServers')}</p>
|
||||
) : (
|
||||
botGuilds.map((guild) => (
|
||||
<DropdownMenuItem key={guild.id} asChild>
|
||||
<Link href={`/dashboard/${guild.id}`} className="flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
|
||||
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{guild.name}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<LayoutGrid className="size-4" />
|
||||
{t('serverSwitcher.allServers')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
86
apps/webui/src/components/layout/sidebar.tsx
Normal file
86
apps/webui/src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
|
||||
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
|
||||
'moderation',
|
||||
'engagement',
|
||||
'community',
|
||||
'utility',
|
||||
'integrations'
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ guildId }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
const base = `/dashboard/${guildId}`;
|
||||
|
||||
const groups = MODULE_GROUP_ORDER.map((group) => ({
|
||||
group,
|
||||
modules: DASHBOARD_MODULES.filter((module) => module.group === group)
|
||||
}));
|
||||
|
||||
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" />
|
||||
{t('nav.overview')}
|
||||
</NavLink>
|
||||
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}>
|
||||
<Settings className="size-4" />
|
||||
{t('nav.settings')}
|
||||
</NavLink>
|
||||
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
{t('nav.modules')}
|
||||
</NavLink>
|
||||
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}>
|
||||
<ShieldCheck className="size-4" />
|
||||
{t('nav.access')}
|
||||
</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">
|
||||
{t(`moduleGroups.${group}`)}
|
||||
</p>
|
||||
{modules.map((module) => {
|
||||
const href = `${base}/${module.href}`;
|
||||
return (
|
||||
<NavLink key={module.id} href={href} active={pathname === href}>
|
||||
{t(`modules.${module.id}.label`)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
12
apps/webui/src/components/layout/theme-provider.tsx
Normal file
12
apps/webui/src/components/layout/theme-provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
73
apps/webui/src/components/layout/user-menu.tsx
Normal file
73
apps/webui/src/components/layout/user-menu.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import type { SessionUser } from '@nexumi/shared';
|
||||
import { LogOut, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
function userAvatarUrl(user: SessionUser): string | undefined {
|
||||
return user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=64` : undefined;
|
||||
}
|
||||
|
||||
function initials(user: SessionUser): string {
|
||||
const name = user.globalName ?? user.username;
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function UserMenu({ user }: { user: SessionUser }) {
|
||||
const t = useTranslations();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Logout failed');
|
||||
}
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t('errors.generic'));
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={userAvatarUrl(user)} alt={user.username} />
|
||||
<AvatarFallback>{initials(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="truncate">{user.globalName ?? user.username}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
{theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut} variant="destructive">
|
||||
<LogOut className="size-4" />
|
||||
{isLoggingOut ? t('userMenu.loggingOut') : t('userMenu.logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user