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:
smueller
2026-07-22 13:56:33 +02:00
parent 7bc1684566
commit 04e04eb11d
39 changed files with 2394 additions and 14 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}