Update fun module configuration and enhance CSS styles

- Added 'enabled' property to FunConfigSchema with a default value of true for better configuration management.
- Refactored getFunConfig to return a complete default configuration when no data is found.
- Improved globals.css by expanding CSS variables for light and dark themes, enhancing styling consistency across the application.
This commit is contained in:
smueller
2026-07-22 13:56:33 +02:00
parent 7bc1684566
commit 04e04eb11d
39 changed files with 2394 additions and 14 deletions

View File

@@ -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> {

View File

@@ -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;
} }

View File

@@ -0,0 +1,30 @@
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
import type { ReactNode } from 'react';
import { ServerSwitcher } from './server-switcher';
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
interface DashboardShellProps {
guildId: string;
currentGuild: DashboardGuild;
otherGuilds: DashboardGuild[];
user: SessionUser;
children: ReactNode;
}
export function DashboardShell({ guildId, currentGuild, otherGuilds, user, children }: DashboardShellProps) {
return (
<div className="flex min-h-screen">
<Sidebar guildId={guildId} />
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b border-border px-6">
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
<UserMenu user={user} />
</header>
<main className="flex-1 px-6 py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import type { DashboardGuild } from '@nexumi/shared';
import { ChevronsUpDown, LayoutGrid } from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from '@/components/locale-provider';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
function guildIconUrl(guild: Pick<DashboardGuild, 'id' | 'icon'>): string | undefined {
return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined;
}
function initials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
}
interface ServerSwitcherProps {
currentGuild: DashboardGuild;
otherGuilds: DashboardGuild[];
}
export function ServerSwitcher({ currentGuild, otherGuilds }: ServerSwitcherProps) {
const t = useTranslations();
const botGuilds = otherGuilds.filter((guild) => guild.botPresent);
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="size-7">
<AvatarImage src={guildIconUrl(currentGuild)} alt={currentGuild.name} />
<AvatarFallback>{initials(currentGuild.name)}</AvatarFallback>
</Avatar>
<span className="max-w-40 truncate">{currentGuild.name}</span>
<ChevronsUpDown className="size-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel>{t('serverSwitcher.switchServer')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{botGuilds.length === 0 ? (
<p className="px-2 py-1.5 text-sm text-muted-foreground">{t('serverSwitcher.noOtherServers')}</p>
) : (
botGuilds.map((guild) => (
<DropdownMenuItem key={guild.id} asChild>
<Link href={`/dashboard/${guild.id}`} className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
</Avatar>
<span className="truncate">{guild.name}</span>
</Link>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/dashboard" className="flex items-center gap-2">
<LayoutGrid className="size-4" />
{t('serverSwitcher.allServers')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from '@/components/locale-provider';
import { cn } from '@/lib/utils';
const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
'moderation',
'engagement',
'community',
'utility',
'integrations'
];
interface SidebarProps {
guildId: string;
}
function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
return (
<Link
href={href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)}
>
{children}
</Link>
);
}
export function Sidebar({ guildId }: SidebarProps) {
const pathname = usePathname();
const t = useTranslations();
const base = `/dashboard/${guildId}`;
const groups = MODULE_GROUP_ORDER.map((group) => ({
group,
modules: DASHBOARD_MODULES.filter((module) => module.group === group)
}));
return (
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
<div className="flex flex-col gap-1">
<NavLink href={base} active={pathname === base}>
<LayoutGrid className="size-4" />
{t('nav.overview')}
</NavLink>
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}>
<Settings className="size-4" />
{t('nav.settings')}
</NavLink>
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}>
<SlidersHorizontal className="size-4" />
{t('nav.modules')}
</NavLink>
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}>
<ShieldCheck className="size-4" />
{t('nav.access')}
</NavLink>
</div>
{groups.map(({ group, modules }) => (
<div key={group} className="flex flex-col gap-1">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(`moduleGroups.${group}`)}
</p>
{modules.map((module) => {
const href = `${base}/${module.href}`;
return (
<NavLink key={module.id} href={href} active={pathname === href}>
{t(`modules.${module.id}.label`)}
</NavLink>
);
})}
</div>
))}
</nav>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ReactNode } from 'react';
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import type { SessionUser } from '@nexumi/shared';
import { LogOut, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { useTranslations } from '@/components/locale-provider';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
function userAvatarUrl(user: SessionUser): string | undefined {
return user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=64` : undefined;
}
function initials(user: SessionUser): string {
const name = user.globalName ?? user.username;
return name.slice(0, 2).toUpperCase();
}
export function UserMenu({ user }: { user: SessionUser }) {
const t = useTranslations();
const { theme, setTheme } = useTheme();
const router = useRouter();
const [isLoggingOut, setIsLoggingOut] = useState(false);
async function handleLogout() {
setIsLoggingOut(true);
try {
const response = await fetch('/api/auth/logout', { method: 'POST' });
if (!response.ok) {
throw new Error('Logout failed');
}
router.push('/login');
router.refresh();
} catch {
toast.error(t('errors.generic'));
setIsLoggingOut(false);
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="size-8">
<AvatarImage src={userAvatarUrl(user)} alt={user.username} />
<AvatarFallback>{initials(user)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="truncate">{user.globalName ?? user.username}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
{theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut} variant="destructive">
<LogOut className="size-4" />
{isLoggingOut ? t('userMenu.loggingOut') : t('userMenu.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 (1720 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."
}
}

View 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 (1720 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."
}
}

View File

@@ -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,