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:
@@ -4,18 +4,21 @@ import { z } from 'zod';
|
|||||||
const FUN_CONFIG_PREFIX = 'fun:config:';
|
const FUN_CONFIG_PREFIX = 'fun:config:';
|
||||||
|
|
||||||
export const FunConfigSchema = z.object({
|
export const FunConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
mediaEnabled: z.boolean().default(true)
|
mediaEnabled: z.boolean().default(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FunConfig = z.infer<typeof FunConfigSchema>;
|
export type FunConfig = z.infer<typeof FunConfigSchema>;
|
||||||
|
|
||||||
|
const DEFAULT_FUN_CONFIG: FunConfig = { enabled: true, mediaEnabled: true };
|
||||||
|
|
||||||
export async function getFunConfig(redis: Redis, guildId: string): Promise<FunConfig> {
|
export async function getFunConfig(redis: Redis, guildId: string): Promise<FunConfig> {
|
||||||
const raw = await redis.get(`${FUN_CONFIG_PREFIX}${guildId}`);
|
const raw = await redis.get(`${FUN_CONFIG_PREFIX}${guildId}`);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return { mediaEnabled: true };
|
return { ...DEFAULT_FUN_CONFIG };
|
||||||
}
|
}
|
||||||
const parsed = FunConfigSchema.safeParse(JSON.parse(raw));
|
const parsed = FunConfigSchema.safeParse(JSON.parse(raw));
|
||||||
return parsed.success ? parsed.data : { mediaEnabled: true };
|
return parsed.success ? parsed.data : { ...DEFAULT_FUN_CONFIG };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setFunConfig(redis: Redis, guildId: string, config: FunConfig): Promise<void> {
|
export async function setFunConfig(redis: Redis, guildId: string, config: FunConfig): Promise<void> {
|
||||||
|
|||||||
@@ -1,26 +1,84 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #f8f8fb;
|
||||||
--foreground: #171717;
|
--foreground: #111116;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #111116;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #111116;
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #f1f1f5;
|
||||||
|
--secondary-foreground: #111116;
|
||||||
|
--muted: #f1f1f5;
|
||||||
|
--muted-foreground: #6b7280;
|
||||||
|
--accent: #eef0ff;
|
||||||
|
--accent-foreground: #312e81;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--border: #e4e4ea;
|
||||||
|
--input: #e4e4ea;
|
||||||
|
--ring: #6366f1;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #0a0a0d;
|
||||||
|
--foreground: #e6e6ea;
|
||||||
|
--card: #131318;
|
||||||
|
--card-foreground: #e6e6ea;
|
||||||
|
--popover: #15151b;
|
||||||
|
--popover-foreground: #e6e6ea;
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #1b1b22;
|
||||||
|
--secondary-foreground: #e6e6ea;
|
||||||
|
--muted: #1b1b22;
|
||||||
|
--muted-foreground: #9a9aa5;
|
||||||
|
--accent: #1e1b4b;
|
||||||
|
--accent-foreground: #c7d2fe;
|
||||||
|
--destructive: #f87171;
|
||||||
|
--destructive-foreground: #1a0505;
|
||||||
|
--border: #23232b;
|
||||||
|
--input: #23232b;
|
||||||
|
--ring: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-card: var(--card);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--font-sans: var(--font-inter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
border-color: var(--border);
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/webui/src/components/locale-provider.tsx
Normal file
63
apps/webui/src/components/locale-provider.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||||
|
import type { Locale, Messages } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface LocaleContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
messages: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||||
|
|
||||||
|
export function LocaleProvider({
|
||||||
|
locale,
|
||||||
|
messages,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
locale: Locale;
|
||||||
|
messages: Messages;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const value = useMemo<LocaleContextValue>(() => ({ locale, messages }), [locale, messages]);
|
||||||
|
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLocaleContext(): LocaleContextValue {
|
||||||
|
const ctx = useContext(LocaleContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useTranslations/useLocale must be used within a LocaleProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(messages: Messages, key: string): string | undefined {
|
||||||
|
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||||
|
if (acc && typeof acc === 'object' && part in (acc as Record<string, unknown>)) {
|
||||||
|
return (acc as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, messages as unknown);
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
return Object.entries(vars).reduce(
|
||||||
|
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||||
|
template
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranslateFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
export function useTranslations(): TranslateFn {
|
||||||
|
const { messages } = useLocaleContext();
|
||||||
|
return (key, vars) => interpolate(resolve(messages, key) ?? key, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale(): Locale {
|
||||||
|
return useLocaleContext().locale;
|
||||||
|
}
|
||||||
95
apps/webui/src/components/settings/general-settings-form.tsx
Normal file
95
apps/webui/src/components/settings/general-settings-form.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { GuildGeneralSettings } from '@nexumi/shared';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import type { SettingsSaveResult } from './settings-form';
|
||||||
|
import { SettingsForm } from './settings-form';
|
||||||
|
|
||||||
|
interface GeneralSettingsFormProps {
|
||||||
|
guildId: string;
|
||||||
|
initialSettings: GuildGeneralSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGeneralSettings(guildId: string, value: GuildGeneralSettings): Promise<SettingsSaveResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/guilds/${guildId}/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(value)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSettingsFormProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsForm<GuildGeneralSettings>
|
||||||
|
initialValue={initialSettings}
|
||||||
|
onSave={(value) => saveGeneralSettings(guildId, value)}
|
||||||
|
>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('settings.general.title')}</CardTitle>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="general-moderation"
|
||||||
|
checked={value.moderationEnabled}
|
||||||
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/webui/src/components/settings/save-bar.tsx
Normal file
43
apps/webui/src/components/settings/save-bar.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SaveBarProps {
|
||||||
|
visible: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveBar({ visible, saving, onSave, onDiscard }: SaveBarProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden={!visible}
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4 pb-4 transition-transform duration-200',
|
||||||
|
visible ? 'translate-y-0' : 'translate-y-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full max-w-3xl items-center justify-between gap-4 rounded-lg border border-border bg-card px-4 py-3 shadow-lg',
|
||||||
|
visible && 'pointer-events-auto'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('common.unsavedChanges')}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={onDiscard} disabled={saving}>
|
||||||
|
{t('common.discard')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={onSave} disabled={saving}>
|
||||||
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/webui/src/components/settings/settings-form.tsx
Normal file
73
apps/webui/src/components/settings/settings-form.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState, useTransition, type ReactNode } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { SaveBar } from './save-bar';
|
||||||
|
|
||||||
|
export type SettingsSaveResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export interface SettingsFormRenderProps<T> {
|
||||||
|
value: T;
|
||||||
|
setValue: (updater: T | ((previous: T) => T)) => void;
|
||||||
|
isDirty: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsFormProps<T> {
|
||||||
|
initialValue: T;
|
||||||
|
onSave: (value: T) => Promise<SettingsSaveResult>;
|
||||||
|
children: (props: SettingsFormRenderProps<T>) => ReactNode;
|
||||||
|
isEqual?: (a: T, b: T) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultIsEqual<T>(a: T, b: T): boolean {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic settings framework used by every module settings page: tracks a
|
||||||
|
* local draft against the last-saved baseline, shows a sticky save bar while
|
||||||
|
* dirty, and surfaces inline + toast errors on save failure.
|
||||||
|
*/
|
||||||
|
export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defaultIsEqual }: SettingsFormProps<T>) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [baseline, setBaseline] = useState(initialValue);
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const isDirty = !isEqual(baseline, value);
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
setValue(baseline);
|
||||||
|
setError(null);
|
||||||
|
}, [baseline]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await onSave(value);
|
||||||
|
if (result.ok) {
|
||||||
|
setBaseline(value);
|
||||||
|
toast.success(t('common.saveSuccess'));
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
toast.error(result.error || t('common.saveError'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t('common.saveError'));
|
||||||
|
toast.error(t('common.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [onSave, t, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-20">
|
||||||
|
{children({ value, setValue, isDirty, isSaving: isPending, error })}
|
||||||
|
<SaveBar visible={isDirty} saving={isPending} onSave={handleSave} onDiscard={handleDiscard} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/webui/src/components/ui/avatar.tsx
Normal file
43
apps/webui/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square size-full', className)} {...props} />
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex size-full items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
29
apps/webui/src/components/ui/badge.tsx
Normal file
29
apps/webui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
outline: 'border-border text-foreground',
|
||||||
|
success: 'border-transparent bg-emerald-500/15 text-emerald-500',
|
||||||
|
destructive: 'border-transparent bg-destructive/15 text-destructive'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <span data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
53
apps/webui/src/components/ui/button.tsx
Normal file
53
apps/webui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-6',
|
||||||
|
icon: 'size-9'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
46
apps/webui/src/components/ui/card.tsx
Normal file
46
apps/webui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn('rounded-lg border border-border bg-card text-card-foreground shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn('flex flex-col gap-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
|
<h3 data-slot="card-title" className={cn('text-base font-semibold leading-none', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
|
<p data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div data-slot="card-content" className={cn('p-6 pt-0', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="card-footer" className={cn('flex items-center gap-2 p-6 pt-0', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||||
175
apps/webui/src/components/ui/dropdown-menu.tsx
Normal file
175
apps/webui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-32 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-48 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
}
|
||||||
|
>(({ className, inset, variant = 'default', ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
variant === 'destructive' && 'text-destructive focus:bg-destructive/10 focus:text-destructive',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
||||||
|
return (
|
||||||
|
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup
|
||||||
|
};
|
||||||
22
apps/webui/src/components/ui/input.tsx
Normal file
22
apps/webui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
23
apps/webui/src/components/ui/label.tsx
Normal file
23
apps/webui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
148
apps/webui/src/components/ui/select.tsx
Normal file
148
apps/webui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
data-slot="select-trigger"
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton
|
||||||
|
};
|
||||||
26
apps/webui/src/components/ui/separator.tsx
Normal file
26
apps/webui/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
data-slot="separator"
|
||||||
|
className={cn(
|
||||||
|
'bg-border shrink-0',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
14
apps/webui/src/components/ui/skeleton.tsx
Normal file
14
apps/webui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
29
apps/webui/src/components/ui/switch.tsx
Normal file
29
apps/webui/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block size-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
38
apps/webui/src/lib/access-rules.ts
Normal file
38
apps/webui/src/lib/access-rules.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import type { DashboardAccessRule } from '@nexumi/shared';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
export async function listAccessRules(guildId: string): Promise<DashboardAccessRule[]> {
|
||||||
|
const rules = await prisma.dashboardAccessRule.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return rules.map((rule) => ({
|
||||||
|
id: rule.id,
|
||||||
|
roleId: rule.roleId,
|
||||||
|
modules: rule.modules as DashboardAccessRule['modules']
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceAccessRules(
|
||||||
|
guildId: string,
|
||||||
|
rules: DashboardAccessRule[]
|
||||||
|
): Promise<DashboardAccessRule[]> {
|
||||||
|
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.dashboardAccessRule.deleteMany({ where: { guildId } }),
|
||||||
|
...rules.map((rule) =>
|
||||||
|
prisma.dashboardAccessRule.create({
|
||||||
|
data: {
|
||||||
|
guildId,
|
||||||
|
roleId: rule.roleId,
|
||||||
|
modules: rule.modules as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return listAccessRules(guildId);
|
||||||
|
}
|
||||||
55
apps/webui/src/lib/audit.ts
Normal file
55
apps/webui/src/lib/audit.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||||
|
import type { DashboardAuditEntry } from '@nexumi/shared';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
export interface WriteDashboardAuditInput {
|
||||||
|
guildId?: string | null;
|
||||||
|
actorUserId: string;
|
||||||
|
action: string;
|
||||||
|
path?: string | null;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJsonInput(value: unknown): Prisma.InputJsonValue | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeDashboardAudit(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: WriteDashboardAuditInput
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.dashboardAuditLog.create({
|
||||||
|
data: {
|
||||||
|
guildId: input.guildId ?? null,
|
||||||
|
actorUserId: input.actorUserId,
|
||||||
|
action: input.action,
|
||||||
|
path: input.path ?? null,
|
||||||
|
before: toJsonInput(input.before) ?? Prisma.JsonNull,
|
||||||
|
after: toJsonInput(input.after) ?? Prisma.JsonNull
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecentDashboardAudit(
|
||||||
|
guildId: string,
|
||||||
|
limit = 10
|
||||||
|
): Promise<DashboardAuditEntry[]> {
|
||||||
|
const entries = await prisma.dashboardAuditLog.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
guildId: entry.guildId,
|
||||||
|
actorUserId: entry.actorUserId,
|
||||||
|
action: entry.action,
|
||||||
|
path: entry.path,
|
||||||
|
createdAt: entry.createdAt.toISOString()
|
||||||
|
}));
|
||||||
|
}
|
||||||
99
apps/webui/src/lib/auth.ts
Normal file
99
apps/webui/src/lib/auth.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { hasManageGuildPermission } from '@nexumi/shared';
|
||||||
|
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
import { getSession, type SessionPayload } from './session';
|
||||||
|
|
||||||
|
export class UnauthorizedError extends Error {
|
||||||
|
constructor(message = 'Authentication required') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'UnauthorizedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends Error {
|
||||||
|
constructor(message = 'You do not have access to this resource') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ForbiddenError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use in API routes. Throws {@link UnauthorizedError} when no session exists.
|
||||||
|
*/
|
||||||
|
export async function requireAuth(): Promise<SessionPayload> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use in Server Components / layouts. Redirects to /login instead of throwing,
|
||||||
|
* since there is no JSON response to return.
|
||||||
|
*/
|
||||||
|
export async function requireAuthOrRedirect(): Promise<SessionPayload> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the current session user has Manage Guild permission on the given
|
||||||
|
* guild (via Discord OAuth guild list) and that the bot is actually installed
|
||||||
|
* there (tracked via the `Guild` table). Use in API routes.
|
||||||
|
*/
|
||||||
|
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||||
|
const guild = guilds.find((entry) => entry.id === guildId);
|
||||||
|
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||||
|
throw new ForbiddenError('Missing Manage Guild permission for this server');
|
||||||
|
}
|
||||||
|
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
|
||||||
|
if (!dbGuild) {
|
||||||
|
throw new ForbiddenError('Nexumi is not installed on this server');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link requireGuildAccess} but for Server Components: redirects
|
||||||
|
* instead of throwing.
|
||||||
|
*/
|
||||||
|
export async function requireGuildAccessOrRedirect(guildId: string): Promise<SessionPayload> {
|
||||||
|
const session = await requireAuthOrRedirect();
|
||||||
|
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||||
|
const guild = guilds.find((entry) => entry.id === guildId);
|
||||||
|
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
|
||||||
|
if (!dbGuild) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toApiErrorResponse(error: unknown): NextResponse {
|
||||||
|
if (error instanceof UnauthorizedError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (error instanceof ForbiddenError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', issues: error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[api]', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
115
apps/webui/src/lib/discord-oauth.ts
Normal file
115
apps/webui/src/lib/discord-oauth.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { env } from './env';
|
||||||
|
import { redis } from './redis';
|
||||||
|
|
||||||
|
const DISCORD_API_BASE = 'https://discord.com/api/v10';
|
||||||
|
const OAUTH_SCOPES = ['identify', 'guilds'] as const;
|
||||||
|
const USER_GUILDS_CACHE_TTL_SECONDS = 30;
|
||||||
|
|
||||||
|
export function getRedirectUri(): string {
|
||||||
|
return `${env.WEBUI_URL}/api/auth/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthorizeUrl(state: string): string {
|
||||||
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||||
|
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
|
||||||
|
url.searchParams.set('redirect_uri', getRedirectUri());
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', OAUTH_SCOPES.join(' '));
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('prompt', 'consent');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCodeForToken(code: string): Promise<DiscordTokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: env.BOT_CLIENT_ID,
|
||||||
|
client_secret: env.BOT_CLIENT_SECRET,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: getRedirectUri()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${DISCORD_API_BASE}/oauth2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Discord token exchange failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as DiscordTokenResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
global_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
|
||||||
|
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch Discord user with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as DiscordUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordUserGuild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
owner: boolean;
|
||||||
|
permissions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDiscordUserGuilds(accessToken: string): Promise<DiscordUserGuild[]> {
|
||||||
|
const response = await fetch(`${DISCORD_API_BASE}/users/@me/guilds`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch Discord guilds with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as DiscordUserGuild[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function guildsCacheKey(accessToken: string): string {
|
||||||
|
return `webui:discordguilds:${createHash('sha256').update(accessToken).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached variant of {@link fetchDiscordUserGuilds} to avoid hitting Discord's
|
||||||
|
* rate limits when a single dashboard page load needs the guild list more
|
||||||
|
* than once (e.g. layout + page + server switcher).
|
||||||
|
*/
|
||||||
|
export async function fetchDiscordUserGuildsCached(accessToken: string): Promise<DiscordUserGuild[]> {
|
||||||
|
const cacheKey = guildsCacheKey(accessToken);
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached) as DiscordUserGuild[];
|
||||||
|
}
|
||||||
|
const guilds = await fetchDiscordUserGuilds(accessToken);
|
||||||
|
await redis.set(cacheKey, JSON.stringify(guilds), 'EX', USER_GUILDS_CACHE_TTL_SECONDS);
|
||||||
|
return guilds;
|
||||||
|
}
|
||||||
19
apps/webui/src/lib/env.ts
Normal file
19
apps/webui/src/lib/env.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
const EnvSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
REDIS_URL: z.string().url(),
|
||||||
|
BOT_CLIENT_ID: z.string().min(1),
|
||||||
|
BOT_CLIENT_SECRET: z.string().min(1),
|
||||||
|
WEBUI_URL: z.string().url().default('http://localhost:3000'),
|
||||||
|
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
|
||||||
|
SENTRY_DSN: z.string().optional(),
|
||||||
|
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de')
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = EnvSchema.parse(process.env);
|
||||||
|
export type Env = typeof env;
|
||||||
41
apps/webui/src/lib/guild-settings.ts
Normal file
41
apps/webui/src/lib/guild-settings.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { GuildGeneralSettings, GuildGeneralSettingsPatch } from '@nexumi/shared';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
async function ensureGuildSettings(guildId: string) {
|
||||||
|
await prisma.guild.upsert({
|
||||||
|
where: { id: guildId },
|
||||||
|
update: {},
|
||||||
|
create: { id: guildId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return prisma.guildSettings.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: {},
|
||||||
|
create: { guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGeneralSettings(settings: { locale: string; timezone: string; moderationEnabled: boolean }): GuildGeneralSettings {
|
||||||
|
return {
|
||||||
|
locale: settings.locale === 'en' ? 'en' : 'de',
|
||||||
|
timezone: settings.timezone,
|
||||||
|
moderationEnabled: settings.moderationEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuildGeneralSettings(guildId: string): Promise<GuildGeneralSettings> {
|
||||||
|
const settings = await ensureGuildSettings(guildId);
|
||||||
|
return toGeneralSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGuildGeneralSettings(
|
||||||
|
guildId: string,
|
||||||
|
patch: GuildGeneralSettingsPatch
|
||||||
|
): Promise<GuildGeneralSettings> {
|
||||||
|
await ensureGuildSettings(guildId);
|
||||||
|
const updated = await prisma.guildSettings.update({
|
||||||
|
where: { guildId },
|
||||||
|
data: patch
|
||||||
|
});
|
||||||
|
return toGeneralSettings(updated);
|
||||||
|
}
|
||||||
51
apps/webui/src/lib/guilds.ts
Normal file
51
apps/webui/src/lib/guilds.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { hasManageGuildPermission, type DashboardGuild } from '@nexumi/shared';
|
||||||
|
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
import type { SessionPayload } from './session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the guilds the current session user can manage (Manage Guild
|
||||||
|
* permission), enriched with whether Nexumi is already installed there.
|
||||||
|
* Guilds where the bot is present are sorted first.
|
||||||
|
*/
|
||||||
|
export async function getManageableGuilds(session: SessionPayload): Promise<DashboardGuild[]> {
|
||||||
|
const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||||
|
const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions));
|
||||||
|
|
||||||
|
if (manageable.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentGuilds = await prisma.guild.findMany({
|
||||||
|
where: { id: { in: manageable.map((guild) => guild.id) } },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
const presentIds = new Set(presentGuilds.map((guild) => guild.id));
|
||||||
|
|
||||||
|
return manageable
|
||||||
|
.map<DashboardGuild>((guild) => ({
|
||||||
|
id: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
icon: guild.icon,
|
||||||
|
botPresent: presentIds.has(guild.id),
|
||||||
|
permissions: guild.permissions
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.botPresent !== b.botPresent) {
|
||||||
|
return a.botPresent ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a single manageable guild by id, or null when the session user
|
||||||
|
* does not manage it.
|
||||||
|
*/
|
||||||
|
export async function getManageableGuild(
|
||||||
|
session: SessionPayload,
|
||||||
|
guildId: string
|
||||||
|
): Promise<DashboardGuild | null> {
|
||||||
|
const guilds = await getManageableGuilds(session);
|
||||||
|
return guilds.find((guild) => guild.id === guildId) ?? null;
|
||||||
|
}
|
||||||
54
apps/webui/src/lib/i18n.ts
Normal file
54
apps/webui/src/lib/i18n.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import deMessages from '@/messages/de.json';
|
||||||
|
import enMessages from '@/messages/en.json';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export type Locale = 'de' | 'en';
|
||||||
|
|
||||||
|
export const LOCALE_COOKIE_NAME = 'nexumi_locale';
|
||||||
|
|
||||||
|
export type Messages = typeof enMessages;
|
||||||
|
|
||||||
|
export const MESSAGES: Record<Locale, Messages> = {
|
||||||
|
de: deMessages,
|
||||||
|
en: enMessages
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolve(messages: Messages, key: string): string | undefined {
|
||||||
|
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||||
|
if (acc && typeof acc === 'object' && part in (acc as Record<string, unknown>)) {
|
||||||
|
return (acc as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, messages);
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
return Object.entries(vars).reduce(
|
||||||
|
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||||
|
template
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(locale: Locale, key: string, vars?: Record<string, string | number>): string {
|
||||||
|
const dict = MESSAGES[locale] ?? MESSAGES[env.DEFAULT_LOCALE];
|
||||||
|
return interpolate(resolve(dict, key) ?? key, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocale(value: string | undefined): value is Locale {
|
||||||
|
return value === 'de' || value === 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the active locale for the current request from the
|
||||||
|
* `nexumi_locale` cookie, falling back to the server's configured default.
|
||||||
|
*/
|
||||||
|
export async function getLocale(): Promise<Locale> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookieLocale = cookieStore.get(LOCALE_COOKIE_NAME)?.value;
|
||||||
|
return isLocale(cookieLocale) ? cookieLocale : env.DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
253
apps/webui/src/lib/modules.ts
Normal file
253
apps/webui/src/lib/modules.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { DASHBOARD_MODULES, type DashboardModuleId, type ModuleStatus } from '@nexumi/shared';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
import { redis } from './redis';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modules that have no dedicated "enabled" column on any Prisma model.
|
||||||
|
* Their dashboard toggle state is tracked in a Redis hash instead.
|
||||||
|
*/
|
||||||
|
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
|
||||||
|
'giveaways',
|
||||||
|
'tags',
|
||||||
|
'selfroles',
|
||||||
|
'feeds',
|
||||||
|
'scheduler',
|
||||||
|
'guildbackup'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function redisModulesKey(guildId: string): string {
|
||||||
|
return `dashboard:modules:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function funConfigKey(guildId: string): string {
|
||||||
|
return `fun:config:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunConfigLike {
|
||||||
|
enabled: boolean;
|
||||||
|
mediaEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRedisModuleFlags(guildId: string): Promise<Partial<Record<DashboardModuleId, boolean>>> {
|
||||||
|
const raw = await redis.hgetall(redisModulesKey(guildId));
|
||||||
|
const result: Partial<Record<DashboardModuleId, boolean>> = {};
|
||||||
|
for (const [moduleId, value] of Object.entries(raw)) {
|
||||||
|
result[moduleId as DashboardModuleId] = value === 'true';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRedisModuleFlag(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise<void> {
|
||||||
|
await redis.hset(redisModulesKey(guildId), moduleId, enabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFunConfig(guildId: string): Promise<FunConfigLike> {
|
||||||
|
const raw = await redis.get(funConfigKey(guildId));
|
||||||
|
if (!raw) {
|
||||||
|
return { enabled: true, mediaEnabled: true };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { enabled?: boolean; mediaEnabled?: boolean };
|
||||||
|
return { enabled: parsed.enabled ?? true, mediaEnabled: parsed.mediaEnabled ?? true };
|
||||||
|
} catch {
|
||||||
|
return { enabled: true, mediaEnabled: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setFunConfigEnabled(guildId: string, enabled: boolean): Promise<void> {
|
||||||
|
const current = await getFunConfig(guildId);
|
||||||
|
await redis.set(funConfigKey(guildId), JSON.stringify({ ...current, enabled }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates the enabled/disabled status of every dashboard module for a
|
||||||
|
* guild, reading from each module's Prisma config table (when it has an
|
||||||
|
* `enabled` column), the Fun module's Redis config, or the generic
|
||||||
|
* `dashboard:modules:{guildId}` Redis hash for modules without any
|
||||||
|
* dedicated storage yet.
|
||||||
|
*/
|
||||||
|
export async function getModuleStatuses(guildId: string): Promise<ModuleStatus[]> {
|
||||||
|
const [
|
||||||
|
guildSettings,
|
||||||
|
autoModConfig,
|
||||||
|
loggingConfig,
|
||||||
|
welcomeConfig,
|
||||||
|
verificationConfig,
|
||||||
|
levelingConfig,
|
||||||
|
economyConfig,
|
||||||
|
funConfig,
|
||||||
|
ticketConfig,
|
||||||
|
starboardConfig,
|
||||||
|
suggestionConfig,
|
||||||
|
birthdayConfig,
|
||||||
|
tempVoiceConfig,
|
||||||
|
statsConfig,
|
||||||
|
redisFlags
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.guildSettings.findUnique({ where: { guildId } }),
|
||||||
|
prisma.autoModConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.loggingConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.welcomeConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.verificationConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.levelingConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.economyConfig.findUnique({ where: { guildId } }),
|
||||||
|
getFunConfig(guildId),
|
||||||
|
prisma.ticketConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.starboardConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.suggestionConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.birthdayConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.tempVoiceConfig.findUnique({ where: { guildId } }),
|
||||||
|
prisma.statsConfig.findUnique({ where: { guildId } }),
|
||||||
|
getRedisModuleFlags(guildId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enabledById: Record<DashboardModuleId, boolean> = {
|
||||||
|
moderation: guildSettings?.moderationEnabled ?? true,
|
||||||
|
automod: autoModConfig?.enabled ?? true,
|
||||||
|
logging: loggingConfig?.enabled ?? true,
|
||||||
|
welcome: Boolean(welcomeConfig?.welcomeEnabled || welcomeConfig?.leaveEnabled),
|
||||||
|
verification: verificationConfig?.enabled ?? false,
|
||||||
|
leveling: levelingConfig?.enabled ?? true,
|
||||||
|
economy: economyConfig?.enabled ?? true,
|
||||||
|
fun: funConfig.enabled,
|
||||||
|
giveaways: redisFlags.giveaways ?? true,
|
||||||
|
tickets: ticketConfig?.enabled ?? true,
|
||||||
|
selfroles: redisFlags.selfroles ?? true,
|
||||||
|
tags: redisFlags.tags ?? true,
|
||||||
|
starboard: starboardConfig?.enabled ?? false,
|
||||||
|
suggestions: suggestionConfig?.enabled ?? true,
|
||||||
|
birthdays: birthdayConfig?.enabled ?? true,
|
||||||
|
tempvoice: tempVoiceConfig?.enabled ?? false,
|
||||||
|
stats: statsConfig?.enabled ?? false,
|
||||||
|
feeds: redisFlags.feeds ?? true,
|
||||||
|
scheduler: redisFlags.scheduler ?? true,
|
||||||
|
guildbackup: redisFlags.guildbackup ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
return DASHBOARD_MODULES.map((module) => ({
|
||||||
|
id: module.id,
|
||||||
|
group: module.group,
|
||||||
|
href: module.href,
|
||||||
|
enabled: enabledById[module.id]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setModuleEnabled(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise<void> {
|
||||||
|
if (REDIS_ONLY_MODULES.has(moduleId)) {
|
||||||
|
await setRedisModuleFlag(guildId, moduleId, enabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (moduleId) {
|
||||||
|
case 'moderation':
|
||||||
|
await prisma.guildSettings.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { moderationEnabled: enabled },
|
||||||
|
create: { guildId, moderationEnabled: enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'automod':
|
||||||
|
await prisma.autoModConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'logging':
|
||||||
|
await prisma.loggingConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'welcome':
|
||||||
|
await prisma.welcomeConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { welcomeEnabled: enabled, leaveEnabled: enabled },
|
||||||
|
create: { guildId, welcomeEnabled: enabled, leaveEnabled: enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'verification':
|
||||||
|
await prisma.verificationConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'leveling':
|
||||||
|
await prisma.levelingConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'economy':
|
||||||
|
await prisma.economyConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'fun':
|
||||||
|
await setFunConfigEnabled(guildId, enabled);
|
||||||
|
return;
|
||||||
|
case 'tickets':
|
||||||
|
await prisma.ticketConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'starboard':
|
||||||
|
await prisma.starboardConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'suggestions':
|
||||||
|
await prisma.suggestionConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'birthdays':
|
||||||
|
await prisma.birthdayConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'tempvoice':
|
||||||
|
await prisma.tempVoiceConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'stats':
|
||||||
|
await prisma.statsConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: { enabled },
|
||||||
|
create: { guildId, enabled }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown or unsupported module: ${String(moduleId)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModuleStatuses(
|
||||||
|
guildId: string,
|
||||||
|
updates: Partial<Record<DashboardModuleId, boolean>>
|
||||||
|
): Promise<ModuleStatus[]> {
|
||||||
|
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||||
|
|
||||||
|
for (const [moduleId, enabled] of Object.entries(updates)) {
|
||||||
|
if (enabled === undefined) continue;
|
||||||
|
await setModuleEnabled(guildId, moduleId as DashboardModuleId, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getModuleStatuses(guildId);
|
||||||
|
}
|
||||||
10
apps/webui/src/lib/prisma.ts
Normal file
10
apps/webui/src/lib/prisma.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { __nexumiPrisma?: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma: PrismaClient = globalForPrisma.__nexumiPrisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.__nexumiPrisma = prisma;
|
||||||
|
}
|
||||||
12
apps/webui/src/lib/redis.ts
Normal file
12
apps/webui/src/lib/redis.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
const globalForRedis = globalThis as unknown as { __nexumiRedis?: Redis };
|
||||||
|
|
||||||
|
export const redis: Redis = globalForRedis.__nexumiRedis ?? new Redis(env.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
if (env.NODE_ENV !== 'production') {
|
||||||
|
globalForRedis.__nexumiRedis = redis;
|
||||||
|
}
|
||||||
105
apps/webui/src/lib/session.ts
Normal file
105
apps/webui/src/lib/session.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { SessionUserSchema } from '@nexumi/shared';
|
||||||
|
import { env } from './env';
|
||||||
|
import { redis } from './redis';
|
||||||
|
|
||||||
|
export const SESSION_COOKIE_NAME = 'nexumi_session';
|
||||||
|
const SESSION_KEY_PREFIX = 'webui:session:';
|
||||||
|
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
const SessionPayloadSchema = z.object({
|
||||||
|
user: SessionUserSchema,
|
||||||
|
accessToken: z.string().min(1),
|
||||||
|
tokenExpiresAt: z.number().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SessionPayload = z.infer<typeof SessionPayloadSchema>;
|
||||||
|
|
||||||
|
export class SessionRequiredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Session required');
|
||||||
|
this.name = 'SessionRequiredError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(value: string): string {
|
||||||
|
return createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCookieValue(sessionId: string): string {
|
||||||
|
return `${sessionId}.${sign(sessionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieValue(cookieValue: string): string | null {
|
||||||
|
const separatorIndex = cookieValue.lastIndexOf('.');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sessionId = cookieValue.slice(0, separatorIndex);
|
||||||
|
const signature = cookieValue.slice(separatorIndex + 1);
|
||||||
|
const expected = sign(sessionId);
|
||||||
|
const provided = Buffer.from(signature);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
if (provided.length !== expectedBuffer.length || !timingSafeEqual(provided, expectedBuffer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionRedisKey(sessionId: string): string {
|
||||||
|
return `${SESSION_KEY_PREFIX}${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(payload: SessionPayload): Promise<void> {
|
||||||
|
const sessionId = randomBytes(32).toString('hex');
|
||||||
|
await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS);
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(SESSION_COOKIE_NAME, buildCookieValue(sessionId), {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
maxAge: SESSION_TTL_SECONDS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<SessionPayload | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sessionId = parseCookieValue(raw);
|
||||||
|
if (!sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const stored = await redis.get(sessionRedisKey(sessionId));
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored));
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
if (raw) {
|
||||||
|
const sessionId = parseCookieValue(raw);
|
||||||
|
if (sessionId) {
|
||||||
|
await redis.del(sessionRedisKey(sessionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireSession(): Promise<SessionPayload> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
throw new SessionRequiredError();
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
16
apps/webui/src/lib/utils.test.ts
Normal file
16
apps/webui/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { cn } from './utils';
|
||||||
|
|
||||||
|
describe('cn', () => {
|
||||||
|
it('merges class names', () => {
|
||||||
|
expect(cn('flex', 'items-center')).toBe('flex items-center');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves conflicting tailwind classes in favor of the last one', () => {
|
||||||
|
expect(cn('px-2', 'px-4')).toBe('px-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops falsy values', () => {
|
||||||
|
expect(cn('base', false && 'hidden', undefined, null, 'visible')).toBe('base visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
6
apps/webui/src/lib/utils.ts
Normal file
6
apps/webui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
143
apps/webui/src/messages/de.json
Normal file
143
apps/webui/src/messages/de.json
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Speichert…",
|
||||||
|
"discard": "Verwerfen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"loading": "Lädt…",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"disabled": "Deaktiviert",
|
||||||
|
"unsavedChanges": "Du hast ungespeicherte Änderungen",
|
||||||
|
"saveSuccess": "Änderungen gespeichert",
|
||||||
|
"saveError": "Änderungen konnten nicht gespeichert werden",
|
||||||
|
"back": "Zurück",
|
||||||
|
"comingSoon": "Demnächst verfügbar",
|
||||||
|
"close": "Schließen"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"modules": "Module",
|
||||||
|
"access": "Dashboard-Zugriff"
|
||||||
|
},
|
||||||
|
"moduleGroups": {
|
||||||
|
"moderation": "Moderation",
|
||||||
|
"engagement": "Engagement",
|
||||||
|
"utility": "Utility",
|
||||||
|
"community": "Community",
|
||||||
|
"integrations": "Integrationen"
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"moderation": { "label": "Moderation", "description": "Bans, Verwarnungen, Timeouts und das Fall-System" },
|
||||||
|
"automod": { "label": "Auto-Moderation", "description": "Spam-, Raid- und Nuke-Schutz" },
|
||||||
|
"logging": { "label": "Logging", "description": "Log-Kanäle für Server-Events" },
|
||||||
|
"welcome": { "label": "Willkommen & Abschied", "description": "Join- und Leave-Nachrichten, Autoroles" },
|
||||||
|
"verification": { "label": "Verifizierung", "description": "Button- und Captcha-Verifizierung" },
|
||||||
|
"leveling": { "label": "Leveling & XP", "description": "Text- und Voice-XP, Rollen-Belohnungen" },
|
||||||
|
"economy": { "label": "Economy", "description": "Währung, Shop und Spiele" },
|
||||||
|
"fun": { "label": "Fun & Spiele", "description": "Minispiele und Medien-Commands" },
|
||||||
|
"giveaways": { "label": "Giveaways", "description": "Button-basierte Giveaways" },
|
||||||
|
"tickets": { "label": "Tickets", "description": "Support-Ticket-Panels und Transkripte" },
|
||||||
|
"selfroles": { "label": "Self Roles", "description": "Reaktions-, Button- und Dropdown-Rollenpanels" },
|
||||||
|
"tags": { "label": "Tags", "description": "Custom Commands und Auto-Responder" },
|
||||||
|
"starboard": { "label": "Starboard", "description": "Beliebte Nachrichten hervorheben" },
|
||||||
|
"suggestions": { "label": "Vorschläge", "description": "Community-Vorschläge mit Abstimmung" },
|
||||||
|
"birthdays": { "label": "Geburtstage", "description": "Geburtstags-Ankündigungen und -Rollen" },
|
||||||
|
"tempvoice": { "label": "Temp-Voice", "description": "Join-to-Create Voice-Kanäle" },
|
||||||
|
"stats": { "label": "Server-Statistiken", "description": "Automatische Statistik-Kanäle und Invites" },
|
||||||
|
"feeds": { "label": "Social Feeds", "description": "Twitch-, YouTube-, RSS- und Reddit-Benachrichtigungen" },
|
||||||
|
"scheduler": { "label": "Scheduler", "description": "Geplante und wiederkehrende Ankündigungen" },
|
||||||
|
"guildbackup": { "label": "Backups", "description": "Server-Backup und -Wiederherstellung" }
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Nexumi",
|
||||||
|
"subtitle": "Melde dich mit Discord an, um deine Server zu verwalten.",
|
||||||
|
"cta": "Mit Discord anmelden",
|
||||||
|
"footer": "Mit der Anmeldung akzeptierst du die Nexumi-Nutzungsbedingungen und Datenschutzerklärung.",
|
||||||
|
"errors": {
|
||||||
|
"access_denied": "Du hast die Discord-Anmeldung abgebrochen.",
|
||||||
|
"invalid_request": "Ungültige Anmeldeanfrage. Bitte erneut versuchen.",
|
||||||
|
"invalid_state": "Deine Anmelde-Sitzung ist abgelaufen. Bitte erneut versuchen.",
|
||||||
|
"oauth_failed": "Die Discord-Anmeldung ist fehlgeschlagen. Bitte erneut versuchen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"guildList": {
|
||||||
|
"title": "Deine Server",
|
||||||
|
"subtitle": "Wähle einen Server zur Verwaltung oder lade Nexumi auf einen neuen Server ein.",
|
||||||
|
"manage": "Verwalten",
|
||||||
|
"invite": "Einladen",
|
||||||
|
"botMissing": "Nicht installiert",
|
||||||
|
"empty": "Keine Server gefunden. Du brauchst die Berechtigung „Server verwalten“ auf einem Discord-Server, um hier zu erscheinen."
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Übersicht",
|
||||||
|
"moduleStatusTitle": "Modul-Status",
|
||||||
|
"moduleStatusEmpty": "Noch keine Modul-Daten vorhanden.",
|
||||||
|
"recentAuditTitle": "Letzte Dashboard-Aktivität",
|
||||||
|
"auditEmpty": "Noch keine Dashboard-Änderungen.",
|
||||||
|
"auditPath": "Pfad",
|
||||||
|
"viewAllModules": "Module verwalten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"general": {
|
||||||
|
"title": "Allgemeine Einstellungen",
|
||||||
|
"description": "Sprache, Zeitzone und der Moderations-Modul-Schalter für diesen Server.",
|
||||||
|
"localeLabel": "Dashboard- & Bot-Sprache",
|
||||||
|
"localeHint": "Wird für Bot-Antworten und Dashboard-Standardwerte auf diesem Server verwendet.",
|
||||||
|
"timezoneLabel": "Zeitzone",
|
||||||
|
"timezoneHint": "IANA-Zeitzonen-Bezeichner, z. B. Europe/Berlin oder America/New_York.",
|
||||||
|
"moderationLabel": "Moderations-Modul",
|
||||||
|
"moderationHint": "Aktiviert Moderations-Commands (Ban, Kick, Warn, Purge, …) auf diesem Server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modulesPage": {
|
||||||
|
"title": "Module",
|
||||||
|
"description": "Aktiviere oder deaktiviere einzelne Nexumi-Module für diesen Server.",
|
||||||
|
"configure": "Konfigurieren"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"title": "Dashboard-Zugriff",
|
||||||
|
"description": "Gewähre zusätzlichen Discord-Rollen Zugriff auf bestimmte Dashboard-Bereiche, zusätzlich zu Server-Administratoren.",
|
||||||
|
"roleIdLabel": "Rollen-ID",
|
||||||
|
"roleIdPlaceholder": "z. B. 123456789012345678",
|
||||||
|
"roleIdHint": "Rechtsklick auf eine Rolle in Discord bei aktiviertem Entwicklermodus und „Rollen-ID kopieren“ wählen.",
|
||||||
|
"modulesLabel": "Erlaubte Module",
|
||||||
|
"allModulesOption": "Alle Module",
|
||||||
|
"specificModulesOption": "Bestimmte Module",
|
||||||
|
"addRule": "Regel hinzufügen",
|
||||||
|
"removeRule": "Entfernen",
|
||||||
|
"noRules": "Keine zusätzlichen Zugriffsregeln konfiguriert. Nur Server-Administratoren können das Dashboard nutzen.",
|
||||||
|
"invalidRoleId": "Gib eine gültige Discord-Rollen-ID an (17–20 Ziffern)."
|
||||||
|
},
|
||||||
|
"modulePlaceholder": {
|
||||||
|
"title": "{module}-Einstellungen",
|
||||||
|
"message": "Modul-Einstellungen kommen in Phase 7.",
|
||||||
|
"backToModules": "Zurück zu den Modulen"
|
||||||
|
},
|
||||||
|
"userMenu": {
|
||||||
|
"theme": "Design",
|
||||||
|
"themeDark": "Dunkel",
|
||||||
|
"themeLight": "Hell",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"loggingOut": "Meldet ab…"
|
||||||
|
},
|
||||||
|
"serverSwitcher": {
|
||||||
|
"switchServer": "Server wechseln",
|
||||||
|
"allServers": "Alle Server",
|
||||||
|
"noOtherServers": "Keine weiteren verwaltbaren Server"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"unauthorized": "Bitte melde dich an, um fortzufahren.",
|
||||||
|
"forbidden": "Du hast keinen Zugriff auf diesen Server.",
|
||||||
|
"notFound": "Nicht gefunden.",
|
||||||
|
"generic": "Etwas ist schiefgelaufen. Bitte erneut versuchen."
|
||||||
|
}
|
||||||
|
}
|
||||||
143
apps/webui/src/messages/en.json
Normal file
143
apps/webui/src/messages/en.json
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"discard": "Discard",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"unsavedChanges": "You have unsaved changes",
|
||||||
|
"saveSuccess": "Changes saved",
|
||||||
|
"saveError": "Could not save changes",
|
||||||
|
"back": "Back",
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"overview": "Overview",
|
||||||
|
"settings": "Settings",
|
||||||
|
"modules": "Modules",
|
||||||
|
"access": "Dashboard access"
|
||||||
|
},
|
||||||
|
"moduleGroups": {
|
||||||
|
"moderation": "Moderation",
|
||||||
|
"engagement": "Engagement",
|
||||||
|
"utility": "Utility",
|
||||||
|
"community": "Community",
|
||||||
|
"integrations": "Integrations"
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"moderation": { "label": "Moderation", "description": "Bans, warns, timeouts and the case system" },
|
||||||
|
"automod": { "label": "Auto-Moderation", "description": "Spam, raid and nuke protection" },
|
||||||
|
"logging": { "label": "Logging", "description": "Server event log channels" },
|
||||||
|
"welcome": { "label": "Welcome & Leave", "description": "Join and leave messages, autoroles" },
|
||||||
|
"verification": { "label": "Verification", "description": "Button and captcha verification" },
|
||||||
|
"leveling": { "label": "Leveling & XP", "description": "Text and voice XP, level rewards" },
|
||||||
|
"economy": { "label": "Economy", "description": "Currency, shop and games" },
|
||||||
|
"fun": { "label": "Fun & Games", "description": "Mini games and media commands" },
|
||||||
|
"giveaways": { "label": "Giveaways", "description": "Button-based giveaways" },
|
||||||
|
"tickets": { "label": "Tickets", "description": "Support ticket panels and transcripts" },
|
||||||
|
"selfroles": { "label": "Self Roles", "description": "Reaction, button and dropdown role panels" },
|
||||||
|
"tags": { "label": "Tags", "description": "Custom commands and auto-responders" },
|
||||||
|
"starboard": { "label": "Starboard", "description": "Highlight popular messages" },
|
||||||
|
"suggestions": { "label": "Suggestions", "description": "Community suggestion voting" },
|
||||||
|
"birthdays": { "label": "Birthdays", "description": "Birthday announcements and roles" },
|
||||||
|
"tempvoice": { "label": "Temp Voice", "description": "Join-to-create voice channels" },
|
||||||
|
"stats": { "label": "Server Stats", "description": "Auto-updating stat channels and invites" },
|
||||||
|
"feeds": { "label": "Social Feeds", "description": "Twitch, YouTube, RSS and Reddit notifications" },
|
||||||
|
"scheduler": { "label": "Scheduler", "description": "Scheduled and recurring announcements" },
|
||||||
|
"guildbackup": { "label": "Backups", "description": "Server backup and restore" }
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Nexumi",
|
||||||
|
"subtitle": "Sign in with Discord to manage your servers.",
|
||||||
|
"cta": "Login with Discord",
|
||||||
|
"footer": "By logging in you agree to the Nexumi Terms of Service and Privacy Policy.",
|
||||||
|
"errors": {
|
||||||
|
"access_denied": "You cancelled the Discord login.",
|
||||||
|
"invalid_request": "Invalid login request. Please try again.",
|
||||||
|
"invalid_state": "Your login session expired. Please try again.",
|
||||||
|
"oauth_failed": "Discord login failed. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"guildList": {
|
||||||
|
"title": "Your servers",
|
||||||
|
"subtitle": "Select a server to manage, or invite Nexumi to a new one.",
|
||||||
|
"manage": "Manage",
|
||||||
|
"invite": "Invite",
|
||||||
|
"botMissing": "Not installed",
|
||||||
|
"empty": "No servers found. You need the Manage Server permission on a Discord server to appear here."
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"moduleStatusTitle": "Module status",
|
||||||
|
"moduleStatusEmpty": "No module data yet.",
|
||||||
|
"recentAuditTitle": "Recent dashboard activity",
|
||||||
|
"auditEmpty": "No dashboard changes yet.",
|
||||||
|
"auditPath": "Path",
|
||||||
|
"viewAllModules": "Manage modules"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"general": {
|
||||||
|
"title": "General settings",
|
||||||
|
"description": "Language, timezone and the moderation module toggle for this server.",
|
||||||
|
"localeLabel": "Dashboard & bot language",
|
||||||
|
"localeHint": "Used for bot replies and dashboard defaults on this server.",
|
||||||
|
"timezoneLabel": "Timezone",
|
||||||
|
"timezoneHint": "IANA timezone identifier, e.g. Europe/Berlin or America/New_York.",
|
||||||
|
"moderationLabel": "Moderation module",
|
||||||
|
"moderationHint": "Enable moderation commands (ban, kick, warn, purge, …) on this server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modulesPage": {
|
||||||
|
"title": "Modules",
|
||||||
|
"description": "Enable or disable individual Nexumi modules for this server.",
|
||||||
|
"configure": "Configure"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"title": "Dashboard access",
|
||||||
|
"description": "Grant additional Discord roles access to specific dashboard sections, in addition to server administrators.",
|
||||||
|
"roleIdLabel": "Role ID",
|
||||||
|
"roleIdPlaceholder": "e.g. 123456789012345678",
|
||||||
|
"roleIdHint": "Right-click a role in Discord with Developer Mode enabled and choose \"Copy Role ID\".",
|
||||||
|
"modulesLabel": "Allowed modules",
|
||||||
|
"allModulesOption": "All modules",
|
||||||
|
"specificModulesOption": "Specific modules",
|
||||||
|
"addRule": "Add rule",
|
||||||
|
"removeRule": "Remove",
|
||||||
|
"noRules": "No additional access rules configured. Only server administrators can access the dashboard.",
|
||||||
|
"invalidRoleId": "Enter a valid Discord role ID (17–20 digits)."
|
||||||
|
},
|
||||||
|
"modulePlaceholder": {
|
||||||
|
"title": "{module} settings",
|
||||||
|
"message": "Module settings arrive in Phase 7.",
|
||||||
|
"backToModules": "Back to modules"
|
||||||
|
},
|
||||||
|
"userMenu": {
|
||||||
|
"theme": "Theme",
|
||||||
|
"themeDark": "Dark",
|
||||||
|
"themeLight": "Light",
|
||||||
|
"logout": "Log out",
|
||||||
|
"loggingOut": "Logging out…"
|
||||||
|
},
|
||||||
|
"serverSwitcher": {
|
||||||
|
"switchServer": "Switch server",
|
||||||
|
"allServers": "All servers",
|
||||||
|
"noOtherServers": "No other manageable servers"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"unauthorized": "Please log in to continue.",
|
||||||
|
"forbidden": "You do not have access to this server.",
|
||||||
|
"notFound": "Not found.",
|
||||||
|
"generic": "Something went wrong. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user