Enhance environment configuration and expand Prisma schema for bot management

- Added OWNER_USER_IDS to .env.example for specifying global bot owners.
- Introduced new models in the Prisma schema: CommandOverride, OwnerTeamMember, GlobalUserBlacklist, GuildBlacklist, FeatureFlag, BotPresenceConfig, ChangelogEntry, and OwnerAuditLog to support advanced bot management features.
- Updated env.ts to preprocess OWNER_USER_IDS for better handling of global bot owner IDs.
- Modified ModulePlaceholderPage to ensure proper routing for remaining dashboard modules.
This commit is contained in:
smueller
2026-07-22 14:51:56 +02:00
parent 946283dfba
commit 1de28fa494
54 changed files with 3564 additions and 3 deletions

View File

@@ -21,3 +21,5 @@ TWITCH_CLIENT_SECRET=
WEBUI_URL=http://10.111.0.65:3000
# Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`.
SESSION_SECRET=
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
OWNER_USER_IDS=

View File

@@ -0,0 +1,131 @@
-- CreateTable
CREATE TABLE "CommandOverride" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"commandName" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deniedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"cooldownSeconds" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommandOverride_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OwnerTeamMember" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OwnerTeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GlobalUserBlacklist" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT,
"note" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GlobalUserBlacklist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GuildBlacklist" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"reason" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GuildBlacklist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeatureFlag" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"rolloutPercent" INTEGER NOT NULL DEFAULT 0,
"whitelistGuildIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FeatureFlag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BotPresenceConfig" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"status" TEXT NOT NULL DEFAULT 'online',
"activityType" TEXT NOT NULL DEFAULT 'Playing',
"activityText" TEXT NOT NULL DEFAULT 'nexumi.de',
"rotatingMessages" JSONB,
"maintenanceMode" BOOLEAN NOT NULL DEFAULT false,
"maintenanceMessage" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BotPresenceConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChangelogEntry" (
"id" TEXT NOT NULL,
"version" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChangelogEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OwnerAuditLog" (
"id" TEXT NOT NULL,
"actorUserId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"targetType" TEXT,
"targetId" TEXT,
"before" JSONB,
"after" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OwnerAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommandOverride_guildId_idx" ON "CommandOverride"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "CommandOverride_guildId_commandName_key" ON "CommandOverride"("guildId", "commandName");
-- CreateIndex
CREATE UNIQUE INDEX "OwnerTeamMember_userId_key" ON "OwnerTeamMember"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "GlobalUserBlacklist_userId_key" ON "GlobalUserBlacklist"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "GuildBlacklist_guildId_key" ON "GuildBlacklist"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "FeatureFlag_key_key" ON "FeatureFlag"("key");
-- CreateIndex
CREATE INDEX "OwnerAuditLog_createdAt_idx" ON "OwnerAuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "CommandOverride" ADD CONSTRAINT "CommandOverride_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -53,6 +53,7 @@ model Guild {
guildBackups GuildBackup[]
dashboardAccessRules DashboardAccessRule[]
dashboardAuditLogs DashboardAuditLog[]
commandOverrides CommandOverride[]
}
model GuildSettings {
@@ -790,3 +791,93 @@ model GuildBackup {
@@index([guildId, createdAt])
}
model CommandOverride {
id String @id @default(cuid())
guildId String
commandName String
enabled Boolean @default(true)
allowedRoleIds String[] @default([])
deniedRoleIds String[] @default([])
allowedChannelIds String[] @default([])
deniedChannelIds String[] @default([])
cooldownSeconds Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, commandName])
@@index([guildId])
}
model OwnerTeamMember {
id String @id @default(cuid())
userId String @unique
role String
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GlobalUserBlacklist {
id String @id @default(cuid())
userId String @unique
reason String?
note String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GuildBlacklist {
id String @id @default(cuid())
guildId String @unique
reason String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FeatureFlag {
id String @id @default(cuid())
key String @unique
enabled Boolean @default(false)
rolloutPercent Int @default(0)
whitelistGuildIds String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BotPresenceConfig {
id String @id @default("singleton")
status String @default("online")
activityType String @default("Playing")
activityText String @default("nexumi.de")
rotatingMessages Json?
maintenanceMode Boolean @default(false)
maintenanceMessage String?
updatedAt DateTime @updatedAt
}
model ChangelogEntry {
id String @id @default(cuid())
version String
title String
body String
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OwnerAuditLog {
id String @id @default(cuid())
actorUserId String
action String
targetType String?
targetId String?
before Json?
after Json?
createdAt DateTime @default(now())
@@index([createdAt])
}

View File

@@ -0,0 +1,46 @@
import { AutomodConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getAutomodDashboard, updateAutomodDashboard } from '@/lib/module-configs/automod';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getAutomodDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = AutomodConfigDashboardPatchSchema.parse(body);
const before = await getAutomodDashboard(guildId);
const after = await updateAutomodDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'automod.update',
path: `/dashboard/${guildId}/automod`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,25 @@
import { CaseListQuerySchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listCases } from '@/lib/module-configs/cases';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const searchParams = request.nextUrl.searchParams;
const query = CaseListQuerySchema.parse({
search: searchParams.get('search') ?? undefined,
action: searchParams.get('action') ?? undefined,
limit: searchParams.get('limit') ?? undefined
});
const cases = await listCases(guildId, query);
return NextResponse.json({ cases });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { EconomyConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getEconomyDashboard, updateEconomyDashboard } from '@/lib/module-configs/economy';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getEconomyDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = EconomyConfigDashboardPatchSchema.parse(body);
const before = await getEconomyDashboard(guildId);
const after = await updateEconomyDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'economy.update',
path: `/dashboard/${guildId}/economy`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { FunConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getFunDashboard, updateFunDashboard } from '@/lib/module-configs/fun';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getFunDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = FunConfigDashboardPatchSchema.parse(body);
const before = await getFunDashboard(guildId);
const after = await updateFunDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'fun.update',
path: `/dashboard/${guildId}/fun`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { LevelingConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getLevelingDashboard, updateLevelingDashboard } from '@/lib/module-configs/leveling';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getLevelingDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = LevelingConfigDashboardPatchSchema.parse(body);
const before = await getLevelingDashboard(guildId);
const after = await updateLevelingDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'leveling.update',
path: `/dashboard/${guildId}/leveling`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { LoggingConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getLoggingDashboard, updateLoggingDashboard } from '@/lib/module-configs/logging';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getLoggingDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = LoggingConfigDashboardPatchSchema.parse(body);
const before = await getLoggingDashboard(guildId);
const after = await updateLoggingDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'logging.update',
path: `/dashboard/${guildId}/logging`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { ModerationDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getModerationDashboard, updateModerationDashboard } from '@/lib/module-configs/moderation';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getModerationDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = ModerationDashboardPatchSchema.parse(body);
const before = await getModerationDashboard(guildId);
const after = await updateModerationDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'moderation.update',
path: `/dashboard/${guildId}/moderation`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { VerificationConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getVerificationDashboard, updateVerificationDashboard } from '@/lib/module-configs/verification';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getVerificationDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = VerificationConfigDashboardPatchSchema.parse(body);
const before = await getVerificationDashboard(guildId);
const after = await updateVerificationDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'verification.update',
path: `/dashboard/${guildId}/verification`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,23 @@
import { WarningListQuerySchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listWarnings } from '@/lib/module-configs/warnings';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const searchParams = request.nextUrl.searchParams;
const query = WarningListQuerySchema.parse({
userId: searchParams.get('userId') ?? undefined
});
const warnings = await listWarnings(guildId, query);
return NextResponse.json({ warnings });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { WelcomeConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getWelcomeDashboard, updateWelcomeDashboard } from '@/lib/module-configs/welcome';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getWelcomeDashboard(guildId);
return NextResponse.json(config);
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const patch = WelcomeConfigDashboardPatchSchema.parse(body);
const before = await getWelcomeDashboard(guildId);
const after = await updateWelcomeDashboard(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'welcome.update',
path: `/dashboard/${guildId}/welcome`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -1,10 +1,31 @@
import { DASHBOARD_MODULES } from '@nexumi/shared';
import { DASHBOARD_MODULES, type DashboardModuleId } from '@nexumi/shared';
import { Construction } from 'lucide-react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { getLocale, t } from '@/lib/i18n';
/**
* Batch A modules (Phase 7) got dedicated route folders (e.g.
* `dashboard/[guildId]/moderation/page.tsx`), which Next.js prefers over this
* catch-all - so they never reach this component. This placeholder now only
* serves the remaining Batch B/C modules until they get dedicated pages too.
*/
const REMAINING_MODULE_IDS: ReadonlySet<DashboardModuleId> = new Set([
'giveaways',
'tickets',
'selfroles',
'tags',
'starboard',
'suggestions',
'birthdays',
'tempvoice',
'stats',
'feeds',
'scheduler',
'guildbackup'
]);
interface ModulePlaceholderPageProps {
params: Promise<{ guildId: string; module: string }>;
}
@@ -13,7 +34,7 @@ export default async function ModulePlaceholderPage({ params }: ModulePlaceholde
const { guildId, module: moduleHref } = await params;
const moduleEntry = DASHBOARD_MODULES.find((entry) => entry.href === moduleHref);
if (!moduleEntry) {
if (!moduleEntry || !REMAINING_MODULE_IDS.has(moduleEntry.id)) {
notFound();
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function AutomodLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-40 rounded-lg" />
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { AutomodForm } from '@/components/modules/automod-form';
import { getLocale, t } from '@/lib/i18n';
import { getAutomodDashboard } from '@/lib/module-configs/automod';
interface AutomodPageProps {
params: Promise<{ guildId: string }>;
}
export default async function AutomodPage({ params }: AutomodPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getAutomodDashboard(guildId)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.automod.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.automod.description')}</p>
</div>
<AutomodForm guildId={guildId} initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function EconomyLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="h-48 rounded-lg" />
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { EconomyForm } from '@/components/modules/economy-form';
import { getLocale, t } from '@/lib/i18n';
import { getEconomyDashboard } from '@/lib/module-configs/economy';
interface EconomyPageProps {
params: Promise<{ guildId: string }>;
}
export default async function EconomyPage({ params }: EconomyPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getEconomyDashboard(guildId)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.economy.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.economy.description')}</p>
</div>
<EconomyForm guildId={guildId} initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function FunLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-40 rounded-lg" />
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { FunForm } from '@/components/modules/fun-form';
import { getLocale, t } from '@/lib/i18n';
import { getFunDashboard } from '@/lib/module-configs/fun';
interface FunPageProps {
params: Promise<{ guildId: string }>;
}
export default async function FunPage({ params }: FunPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getFunDashboard(guildId)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.fun.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.fun.description')}</p>
</div>
<FunForm guildId={guildId} initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function LevelingLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-48 rounded-lg" />
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { LevelingForm } from '@/components/modules/leveling-form';
import { getLocale, t } from '@/lib/i18n';
import { getLevelingDashboard } from '@/lib/module-configs/leveling';
interface LevelingPageProps {
params: Promise<{ guildId: string }>;
}
export default async function LevelingPage({ params }: LevelingPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getLevelingDashboard(guildId)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.leveling.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.leveling.description')}</p>
</div>
<LevelingForm guildId={guildId} initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function LoggingLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="h-48 rounded-lg" />
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { LoggingForm } from '@/components/modules/logging-form';
import { getLocale, t } from '@/lib/i18n';
import { getLoggingDashboard } from '@/lib/module-configs/logging';
interface LoggingPageProps {
params: Promise<{ guildId: string }>;
}
export default async function LoggingPage({ params }: LoggingPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getLoggingDashboard(guildId)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.logging.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.logging.description')}</p>
</div>
<LoggingForm guildId={guildId} initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function ModerationLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-40 rounded-lg" />
))}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { CaseListQuerySchema } from '@nexumi/shared';
import { CasesTable } from '@/components/modules/cases-table';
import { ModerationForm } from '@/components/modules/moderation-form';
import { getLocale, t } from '@/lib/i18n';
import { listCases } from '@/lib/module-configs/cases';
import { getModerationDashboard } from '@/lib/module-configs/moderation';
interface ModerationPageProps {
params: Promise<{ guildId: string }>;
}
export default async function ModerationPage({ params }: ModerationPageProps) {
const { guildId } = await params;
const [locale, moderation, cases] = await Promise.all([
getLocale(),
getModerationDashboard(guildId),
listCases(guildId, CaseListQuerySchema.parse({}))
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.moderation.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.moderation.description')}</p>
</div>
<ModerationForm guildId={guildId} initialValue={moderation} />
<CasesTable guildId={guildId} initialCases={cases} />
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function VerificationLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-64 rounded-lg" />
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { VerificationForm } from '@/components/modules/verification-form';
import { getLocale, t } from '@/lib/i18n';
import { getVerificationDashboard } from '@/lib/module-configs/verification';
interface VerificationPageProps {
params: Promise<{ guildId: string }>;
}
export default async function VerificationPage({ params }: VerificationPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.verification.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.verification.description')}</p>
</div>
<VerificationForm guildId={guildId} initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function WelcomeLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="h-64 rounded-lg" />
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { WelcomeForm } from '@/components/modules/welcome-form';
import { getLocale, t } from '@/lib/i18n';
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
interface WelcomePageProps {
params: Promise<{ guildId: string }>;
}
export default async function WelcomePage({ params }: WelcomePageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getWelcomeDashboard(guildId)]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.welcome.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.welcome.description')}</p>
</div>
<WelcomeForm guildId={guildId} initialValue={config} />
</div>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import type { AutomodConfigDashboard } 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 { Switch } from '@/components/ui/switch';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/automod`, {
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' };
}
}
interface AutomodFormProps {
guildId: string;
initialValue: AutomodConfigDashboard;
}
export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
const t = useTranslations();
return (
<SettingsForm<AutomodConfigDashboard> initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.title')}</CardTitle>
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
<Switch
checked={value.antiRaidEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiRaidJoinThreshold}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label>
<Input
type="number"
min={1}
value={value.antiRaidWindowSeconds}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 }))
}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
<Switch
checked={value.antiNukeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeBanThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeBanThreshold}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeChannelThreshold')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeChannelThreshold}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.automod.antiNukeWindowSeconds')}</Label>
<Input
type="number"
min={1}
value={value.antiNukeWindowSeconds}
onChange={(event) =>
setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 }))
}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.automod.lockdownActive')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
</div>
<Switch
checked={value.lockdownActive}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
/>
</div>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import type { CaseDashboard } from '@nexumi/shared';
import { Search } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
interface CasesTableProps {
guildId: string;
initialCases: CaseDashboard[];
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
export function CasesTable({ guildId, initialCases }: CasesTableProps) {
const t = useTranslations();
const [cases, setCases] = useState<CaseDashboard[]>(initialCases);
const [search, setSearch] = useState('');
const [action, setAction] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchCases = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search.trim()) params.set('search', search.trim());
if (action.trim()) params.set('action', action.trim());
const response = await fetch(`/api/guilds/${guildId}/cases?${params.toString()}`);
if (!response.ok) {
setError(t('common.saveError'));
return;
}
const body = (await response.json()) as { cases: CaseDashboard[] };
setCases(body.cases);
} catch {
setError(t('common.saveError'));
} finally {
setLoading(false);
}
}, [action, guildId, search, t]);
return (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
void fetchCases();
}}
>
<div className="flex-1 space-y-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.moderation.searchPlaceholder')}
/>
</div>
<div className="space-y-2">
<Input
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder={t('modulePages.moderation.actionPlaceholder')}
className="w-40"
/>
</div>
<Button type="submit" variant="outline" disabled={loading}>
<Search className="size-4" />
{t('common.confirm')}
</Button>
</form>
{error && <p className="text-sm text-destructive">{error}</p>}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-12 rounded-md" />
))}
</div>
) : cases.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
<th className="py-2 pr-4">#</th>
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{cases.map((entry) => (
<tr key={entry.id}>
<td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</td>
<td className="py-2 pr-4">{entry.action}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.targetUserId}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
<td className="max-w-64 truncate py-2 pr-4">
{entry.reason ?? t('modulePages.moderation.noReason')}
</td>
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
{formatDate(entry.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import type { EconomyConfigDashboard } 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 { Switch } from '@/components/ui/switch';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveEconomy(guildId: string, value: EconomyConfigDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/economy`, {
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' };
}
}
interface EconomyFormProps {
guildId: string;
initialValue: EconomyConfigDashboard;
}
export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
const t = useTranslations();
return (
<SettingsForm<EconomyConfigDashboard> initialValue={initialValue} onSave={(value) => saveEconomy(guildId, value)}>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.economy.title')}</CardTitle>
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.economy.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.economy.currencyName')}</Label>
<Input
value={value.currencyName}
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.economy.currencySymbol')}</Label>
<Input
value={value.currencySymbol}
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.economy.dailyAmount')}</Label>
<Input
type="number"
min={0}
value={value.dailyAmount}
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
<Input
type="number"
min={0}
value={value.weeklyAmount}
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.economy.workMin')}</Label>
<Input
type="number"
min={0}
value={value.workMin}
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.economy.workMax')}</Label>
<Input
type="number"
min={0}
value={value.workMax}
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
<Input
type="number"
min={1}
value={value.workCooldownSeconds}
onChange={(event) =>
setValue((prev) => ({ ...prev, workCooldownSeconds: Number(event.target.value) || 1 }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.economy.startingBalance')}</Label>
<Input
type="number"
min={0}
value={value.startingBalance}
onChange={(event) =>
setValue((prev) => ({ ...prev, startingBalance: Number(event.target.value) || 0 }))
}
/>
</div>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import type { FunConfigDashboard } from '@nexumi/shared';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveFun(guildId: string, value: FunConfigDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/fun`, {
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' };
}
}
interface FunFormProps {
guildId: string;
initialValue: FunConfigDashboard;
}
export function FunForm({ guildId, initialValue }: FunFormProps) {
const t = useTranslations();
return (
<SettingsForm<FunConfigDashboard> initialValue={initialValue} onSave={(value) => saveFun(guildId, value)}>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.fun.title')}</CardTitle>
<CardDescription>{t('modulePages.fun.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.fun.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.fun.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.fun.mediaEnabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.fun.mediaEnabledHint')}</p>
</div>
<Switch
checked={value.mediaEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, mediaEnabled: checked }))}
/>
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,320 @@
'use client';
import type { LevelingConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
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 { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
interface LocalLevelingValue
extends Omit<LevelingConfigDashboard, 'noXpChannelIds' | 'noXpRoleIds' | 'roleMultipliers' | 'channelMultipliers'> {
noXpChannelIdsText: string;
noXpRoleIdsText: string;
roleMultipliersText: string;
channelMultipliersText: string;
}
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toMultiplierText(map: Record<string, number>): string {
return Object.entries(map)
.map(([id, multiplier]) => `${id}:${multiplier}`)
.join('\n');
}
function fromMultiplierText(text: string): { value: Record<string, number>; error?: string } {
const value: Record<string, number> = {};
const lines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (const line of lines) {
const [id, multiplierRaw] = line.split(':').map((part) => part.trim());
const multiplier = Number(multiplierRaw);
if (!id || Number.isNaN(multiplier) || multiplier <= 0) {
return { value: {}, error: `Invalid multiplier line: "${line}" (expected id:number)` };
}
value[id] = multiplier;
}
return { value };
}
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
const { noXpChannelIds, noXpRoleIds, roleMultipliers, channelMultipliers, ...rest } = config;
return {
...rest,
noXpChannelIdsText: toCsv(noXpChannelIds),
noXpRoleIdsText: toCsv(noXpRoleIds),
roleMultipliersText: toMultiplierText(roleMultipliers),
channelMultipliersText: toMultiplierText(channelMultipliers)
};
}
async function saveLeveling(guildId: string, value: LocalLevelingValue): Promise<SettingsSaveResult> {
const roleMultipliers = fromMultiplierText(value.roleMultipliersText);
if (roleMultipliers.error) {
return { ok: false, error: roleMultipliers.error };
}
const channelMultipliers = fromMultiplierText(value.channelMultipliersText);
if (channelMultipliers.error) {
return { ok: false, error: channelMultipliers.error };
}
const { noXpChannelIdsText, noXpRoleIdsText, roleMultipliersText, channelMultipliersText, ...rest } = value;
const payload: LevelingConfigDashboard = {
...rest,
noXpChannelIds: fromCsv(noXpChannelIdsText),
noXpRoleIds: fromCsv(noXpRoleIdsText),
roleMultipliers: roleMultipliers.value,
channelMultipliers: channelMultipliers.value
};
try {
const response = await fetch(`/api/guilds/${guildId}/leveling`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
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' };
}
}
interface LevelingFormProps {
guildId: string;
initialValue: LevelingConfigDashboard;
}
export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
const t = useTranslations();
const noXpChannelsId = useId();
const noXpRolesId = useId();
return (
<SettingsForm<LocalLevelingValue>
initialValue={toLocal(initialValue)}
onSave={(value) => saveLeveling(guildId, value)}
>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.title')}</CardTitle>
<CardDescription>{t('modulePages.leveling.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMin')}</Label>
<Input
type="number"
min={1}
value={value.textXpMin}
onChange={(event) => setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMax')}</Label>
<Input
type="number"
min={1}
value={value.textXpMax}
onChange={(event) => setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
<Input
type="number"
min={1}
value={value.textCooldownSeconds}
onChange={(event) =>
setValue((prev) => ({ ...prev, textCooldownSeconds: Number(event.target.value) || 1 }))
}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
<Input
type="number"
min={0}
className="w-40"
value={value.voiceXpPerMinute}
onChange={(event) =>
setValue((prev) => ({ ...prev, voiceXpPerMinute: Number(event.target.value) || 0 }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor={noXpChannelsId}>{t('modulePages.leveling.noXpChannels')}</Label>
<Textarea
id={noXpChannelsId}
value={value.noXpChannelIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, noXpChannelIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor={noXpRolesId}>{t('modulePages.leveling.noXpRoles')}</Label>
<Textarea
id={noXpRolesId}
value={value.noXpRoleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, noXpRoleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
<Textarea
className="font-mono"
rows={4}
value={value.roleMultipliersText}
onChange={(event) => setValue((prev) => ({ ...prev, roleMultipliersText: event.target.value }))}
placeholder="123456789012345678:1.5"
/>
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
<Textarea
className="font-mono"
rows={4}
value={value.channelMultipliersText}
onChange={(event) => setValue((prev) => ({ ...prev, channelMultipliersText: event.target.value }))}
placeholder="123456789012345678:2"
/>
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpMode')}</Label>
<Select
value={value.levelUpMode}
onValueChange={(mode) =>
setValue((prev) => ({ ...prev, levelUpMode: mode as LevelingConfigDashboard['levelUpMode'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHANNEL">{t('modulePages.leveling.mode.CHANNEL')}</SelectItem>
<SelectItem value="DM">{t('modulePages.leveling.mode.DM')}</SelectItem>
<SelectItem value="OFF">{t('modulePages.leveling.mode.OFF')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpChannelId')}</Label>
<Input
value={value.levelUpChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, levelUpChannelId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpMessage')}</Label>
<Textarea
value={value.levelUpMessage ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, levelUpMessage: event.target.value || null }))}
placeholder="Congrats {user}, you reached level {level}!"
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.leveling.stackRewards')}</Label>
<Switch
checked={value.stackRewards}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, stackRewards: checked }))}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.leveling.cardAccentColor')}</Label>
<div className="flex items-center gap-2">
<input
type="color"
className="size-9 rounded-md border border-input"
value={value.cardAccentColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))}
/>
<Input
value={value.cardAccentColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.cardBackgroundColor')}</Label>
<div className="flex items-center gap-2">
<input
type="color"
className="size-9 rounded-md border border-input"
value={value.cardBackgroundColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
/>
<Input
value={value.cardBackgroundColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
/>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,257 @@
'use client';
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
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 { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [
'MESSAGE_EDIT',
'MESSAGE_DELETE',
'MESSAGE_BULK_DELETE',
'MEMBER_JOIN',
'MEMBER_LEAVE',
'MEMBER_BAN',
'MEMBER_UNBAN',
'MEMBER_UPDATE',
'CHANNEL_CREATE',
'CHANNEL_DELETE',
'CHANNEL_UPDATE',
'VOICE_JOIN',
'VOICE_LEAVE',
'VOICE_MOVE',
'INVITE_CREATE',
'EMOJI_CREATE',
'EMOJI_UPDATE',
'EMOJI_DELETE',
'STICKER_CREATE',
'STICKER_UPDATE',
'STICKER_DELETE',
'THREAD_CREATE',
'THREAD_UPDATE',
'THREAD_DELETE',
'GUILD_UPDATE',
'MOD_ACTION'
];
interface LocalLogChannel extends LogChannelMapping {
clientKey: string;
}
interface LocalLoggingValue {
enabled: boolean;
ignoreBots: boolean;
ignoredChannelIdsText: string;
ignoredRoleIdsText: string;
logChannels: LocalLogChannel[];
}
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toLocal(config: LoggingConfigDashboard): LocalLoggingValue {
return {
enabled: config.enabled,
ignoreBots: config.ignoreBots,
ignoredChannelIdsText: toCsv(config.ignoredChannelIds),
ignoredRoleIdsText: toCsv(config.ignoredRoleIds),
logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` }))
};
}
function toRemote(value: LocalLoggingValue): LoggingConfigDashboard {
return {
enabled: value.enabled,
ignoreBots: value.ignoreBots,
ignoredChannelIds: fromCsv(value.ignoredChannelIdsText),
ignoredRoleIds: fromCsv(value.ignoredRoleIdsText),
logChannels: value.logChannels.map(({ clientKey, ...rest }) => rest)
};
}
async function saveLogging(guildId: string, value: LocalLoggingValue): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/logging`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toRemote(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' };
}
}
interface LoggingFormProps {
guildId: string;
initialValue: LoggingConfigDashboard;
}
export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
const t = useTranslations();
const ignoredChannelsId = useId();
const ignoredRolesId = useId();
return (
<SettingsForm<LocalLoggingValue>
initialValue={toLocal(initialValue)}
onSave={(value) => saveLogging(guildId, value)}
>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.logging.title')}</CardTitle>
<CardDescription>{t('modulePages.logging.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.logging.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.logging.ignoreBotsLabel')}</Label>
<Switch
checked={value.ignoreBots}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, ignoreBots: checked }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
<Textarea
id={ignoredChannelsId}
value={value.ignoredChannelIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label>
<Textarea
id={ignoredRolesId}
value={value.ignoredRoleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredRoleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.logging.channelsTitle')}</CardTitle>
<CardDescription>{t('modulePages.logging.channelsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{value.logChannels.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.logging.channelsEmpty')}</p>
)}
{value.logChannels.map((mapping) => (
<div key={mapping.clientKey} className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3">
<div className="space-y-2">
<Label>{t('modulePages.logging.eventType')}</Label>
<Select
value={mapping.eventType}
onValueChange={(eventType) =>
setValue((prev) => ({
...prev,
logChannels: prev.logChannels.map((entry) =>
entry.clientKey === mapping.clientKey
? { ...entry, eventType: eventType as LogEventTypeDashboard }
: entry
)
}))
}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOG_EVENT_TYPES.map((eventType) => (
<SelectItem key={eventType} value={eventType}>
{eventType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<Label>{t('modulePages.logging.channelId')}</Label>
<Input
value={mapping.channelId}
onChange={(event) =>
setValue((prev) => ({
...prev,
logChannels: prev.logChannels.map((entry) =>
entry.clientKey === mapping.clientKey ? { ...entry, channelId: event.target.value.trim() } : entry
)
}))
}
placeholder="123456789012345678"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('common.remove')}
onClick={() =>
setValue((prev) => ({
...prev,
logChannels: prev.logChannels.filter((entry) => entry.clientKey !== mapping.clientKey)
}))
}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setValue((prev) => ({
...prev,
logChannels: [
...prev.logChannels,
{ clientKey: `new-${Date.now()}`, eventType: 'MOD_ACTION', channelId: '' }
]
}))
}
>
<Plus className="size-4" />
{t('modulePages.logging.addMapping')}
</Button>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,232 @@
'use client';
import type { EscalationAction, EscalationRuleDashboard, ModerationDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
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 '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
const ESCALATION_ACTIONS: EscalationAction[] = ['TIMEOUT', 'KICK', 'BAN'];
interface LocalEscalation extends EscalationRuleDashboard {
clientKey: string;
}
function toLocal(rules: EscalationRuleDashboard[]): LocalEscalation[] {
return rules.map((rule, index) => ({ ...rule, clientKey: rule.id ?? `existing-${index}` }));
}
function toRemote(rules: LocalEscalation[]): EscalationRuleDashboard[] {
return rules.map(({ clientKey, ...rest }) => rest);
}
async function saveModeration(guildId: string, value: ModerationDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/moderation`, {
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' };
}
}
function EscalationRow({
rule,
onChange,
onRemove
}: {
rule: LocalEscalation;
onChange: (rule: LocalEscalation) => void;
onRemove: () => void;
}) {
const t = useTranslations();
const warnCountId = useId();
const durationId = useId();
const reasonId = useId();
return (
<div className="space-y-4 rounded-md border border-border p-4">
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-2">
<Label htmlFor={warnCountId}>{t('modulePages.moderation.warnCount')}</Label>
<Input
id={warnCountId}
type="number"
min={1}
className="w-28"
value={rule.warnCount}
onChange={(event) => onChange({ ...rule, warnCount: Number(event.target.value) || 1 })}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.moderation.action')}</Label>
<Select
value={rule.action}
onValueChange={(action) => onChange({ ...rule, action: action as EscalationAction })}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ESCALATION_ACTIONS.map((action) => (
<SelectItem key={action} value={action}>
{t(`modulePages.moderation.actions.${action}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{rule.action === 'TIMEOUT' && (
<div className="space-y-2">
<Label htmlFor={durationId}>{t('modulePages.moderation.durationMs')}</Label>
<Input
id={durationId}
type="number"
min={1000}
step={1000}
className="w-36"
value={rule.durationMs ?? 0}
onChange={(event) => onChange({ ...rule, durationMs: Number(event.target.value) || null })}
/>
</div>
)}
<div className="flex items-center gap-2 pb-1">
<Switch checked={rule.enabled} onCheckedChange={(enabled) => onChange({ ...rule, enabled })} />
<span className="text-sm text-muted-foreground">{t('common.enabled')}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="ml-auto"
onClick={onRemove}
aria-label={t('common.remove')}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="space-y-2">
<Label htmlFor={reasonId}>{t('modulePages.moderation.reason')}</Label>
<Input
id={reasonId}
value={rule.reason ?? ''}
onChange={(event) => onChange({ ...rule, reason: event.target.value || null })}
placeholder={t('modulePages.moderation.reasonPlaceholder')}
/>
</div>
</div>
);
}
interface ModerationFormProps {
guildId: string;
initialValue: ModerationDashboard;
}
export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
const t = useTranslations();
return (
<SettingsForm<{ moderationEnabled: boolean; escalations: LocalEscalation[] }>
initialValue={{ moderationEnabled: initialValue.moderationEnabled, escalations: toLocal(initialValue.escalations) }}
onSave={(value) =>
saveModeration(guildId, { moderationEnabled: value.moderationEnabled, escalations: toRemote(value.escalations) })
}
>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.title')}</CardTitle>
<CardDescription>{t('modulePages.moderation.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.moderation.enabledLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.enabledHint')}</p>
</div>
<Switch
checked={value.moderationEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle>
<CardDescription>{t('modulePages.moderation.escalationsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{value.escalations.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.noEscalations')}</p>
)}
{value.escalations.map((rule) => (
<EscalationRow
key={rule.clientKey}
rule={rule}
onChange={(updated) =>
setValue((prev) => ({
...prev,
escalations: prev.escalations.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry))
}))
}
onRemove={() =>
setValue((prev) => ({
...prev,
escalations: prev.escalations.filter((entry) => entry.clientKey !== rule.clientKey)
}))
}
/>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setValue((prev) => ({
...prev,
escalations: [
...prev.escalations,
{
clientKey: `new-${Date.now()}-${prev.escalations.length}`,
warnCount: prev.escalations.length + 1,
action: 'TIMEOUT',
durationMs: 3_600_000,
reason: null,
enabled: true
}
]
}))
}
>
<Plus className="size-4" />
{t('modulePages.moderation.addEscalation')}
</Button>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import type { VerificationConfigDashboard } 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 '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveVerification(guildId: string, value: VerificationConfigDashboard): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/verification`, {
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' };
}
}
interface VerificationFormProps {
guildId: string;
initialValue: VerificationConfigDashboard;
}
export function VerificationForm({ guildId, initialValue }: VerificationFormProps) {
const t = useTranslations();
return (
<SettingsForm<VerificationConfigDashboard>
initialValue={initialValue}
onSave={(value) => saveVerification(guildId, value)}
>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.verification.title')}</CardTitle>
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.verification.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.verification.mode')}</Label>
<Select
value={value.mode}
onValueChange={(mode) =>
setValue((prev) => ({ ...prev, mode: mode as VerificationConfigDashboard['mode'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BUTTON">{t('modulePages.verification.mode.BUTTON')}</SelectItem>
<SelectItem value="CAPTCHA">{t('modulePages.verification.mode.CAPTCHA')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.verification.failAction')}</Label>
<Select
value={value.failAction}
onValueChange={(failAction) =>
setValue((prev) => ({ ...prev, failAction: failAction as VerificationConfigDashboard['failAction'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="KICK">{t('modulePages.verification.failAction.KICK')}</SelectItem>
<SelectItem value="BAN">{t('modulePages.verification.failAction.BAN')}</SelectItem>
<SelectItem value="NONE">{t('modulePages.verification.failAction.NONE')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('modulePages.verification.channelId')}</Label>
<Input
value={value.channelId}
onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
<Input
value={value.verifiedRoleId}
onChange={(event) => setValue((prev) => ({ ...prev, verifiedRoleId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
<Input
value={value.unverifiedRoleId}
onChange={(event) => setValue((prev) => ({ ...prev, unverifiedRoleId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
<Input
type="number"
min={0}
className="w-40"
value={value.minAccountAgeDays}
onChange={(event) =>
setValue((prev) => ({ ...prev, minAccountAgeDays: Number(event.target.value) || 0 }))
}
/>
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,269 @@
'use client';
import type { WelcomeConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
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 { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'userAutoroleIds' | 'botAutoroleIds' | 'welcomeEmbed' | 'leaveEmbed'> {
userAutoroleIdsText: string;
botAutoroleIdsText: string;
welcomeEmbedText: string;
leaveEmbedText: string;
}
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toJsonText(value: Record<string, unknown> | null | undefined): string {
return value ? JSON.stringify(value, null, 2) : '';
}
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
const { userAutoroleIds, botAutoroleIds, welcomeEmbed, leaveEmbed, ...rest } = config;
return {
...rest,
userAutoroleIdsText: toCsv(userAutoroleIds),
botAutoroleIdsText: toCsv(botAutoroleIds),
welcomeEmbedText: toJsonText(welcomeEmbed),
leaveEmbedText: toJsonText(leaveEmbed)
};
}
function parseEmbedJson(text: string): { value: Record<string, unknown> | null; error?: string } {
if (!text.trim()) {
return { value: null };
}
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return { value: parsed as Record<string, unknown> };
}
return { value: null, error: 'Invalid embed JSON (must be an object)' };
} catch {
return { value: null, error: 'Invalid embed JSON' };
}
}
async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<SettingsSaveResult> {
const welcomeEmbed = parseEmbedJson(value.welcomeEmbedText);
if (welcomeEmbed.error) {
return { ok: false, error: welcomeEmbed.error };
}
const leaveEmbed = parseEmbedJson(value.leaveEmbedText);
if (leaveEmbed.error) {
return { ok: false, error: leaveEmbed.error };
}
const { userAutoroleIdsText, botAutoroleIdsText, welcomeEmbedText, leaveEmbedText, ...rest } = value;
const payload: WelcomeConfigDashboard = {
...rest,
userAutoroleIds: fromCsv(userAutoroleIdsText),
botAutoroleIds: fromCsv(botAutoroleIdsText),
welcomeEmbed: welcomeEmbed.value,
leaveEmbed: leaveEmbed.value
};
try {
const response = await fetch(`/api/guilds/${guildId}/welcome`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
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' };
}
}
interface WelcomeFormProps {
guildId: string;
initialValue: WelcomeConfigDashboard;
}
export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
const t = useTranslations();
const userAutorolesId = useId();
const botAutorolesId = useId();
return (
<SettingsForm<LocalWelcomeValue> initialValue={toLocal(initialValue)} onSave={(value) => saveWelcome(guildId, value)}>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.welcome.welcomeTitle')}</CardTitle>
<CardDescription>{t('modulePages.welcome.welcomeDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.welcome.welcomeEnabledLabel')}</Label>
<Switch
checked={value.welcomeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, welcomeEnabled: checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeChannelId')}</Label>
<Input
value={value.welcomeChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeChannelId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeType')}</Label>
<Select
value={value.welcomeType}
onValueChange={(welcomeType) =>
setValue((prev) => ({ ...prev, welcomeType: welcomeType as WelcomeConfigDashboard['welcomeType'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TEXT">{t('modulePages.welcome.type.TEXT')}</SelectItem>
<SelectItem value="EMBED">{t('modulePages.welcome.type.EMBED')}</SelectItem>
<SelectItem value="IMAGE">{t('modulePages.welcome.type.IMAGE')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
<Textarea
value={value.welcomeContent ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
placeholder={t('modulePages.welcome.contentPlaceholder')}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.welcome.placeholdersHint')}</p>
</div>
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
<Textarea
className="font-mono"
rows={6}
value={value.welcomeEmbedText}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeEmbedText: event.target.value }))}
placeholder='{ "title": "Welcome!", "color": 6591981 }'
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.welcome.welcomeDmEnabled')}</Label>
<Switch
checked={value.welcomeDmEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, welcomeDmEnabled: checked }))}
/>
</div>
<Textarea
value={value.welcomeDmContent ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeDmContent: event.target.value || null }))}
placeholder={t('modulePages.welcome.dmContentPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor={userAutorolesId}>{t('modulePages.welcome.userAutoroles')}</Label>
<Textarea
id={userAutorolesId}
value={value.userAutoroleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, userAutoroleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor={botAutorolesId}>{t('modulePages.welcome.botAutoroles')}</Label>
<Textarea
id={botAutorolesId}
value={value.botAutoroleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, botAutoroleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageTitle')}</Label>
<Input
value={value.imageTitle ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
<Input
value={value.imageSubtitle ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.welcome.leaveTitle')}</CardTitle>
<CardDescription>{t('modulePages.welcome.leaveDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.welcome.leaveEnabledLabel')}</Label>
<Switch
checked={value.leaveEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, leaveEnabled: checked }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveChannelId')}</Label>
<Input
value={value.leaveChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, leaveChannelId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveContent')}</Label>
<Textarea
value={value.leaveContent ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
placeholder={t('modulePages.welcome.contentPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
<Textarea
className="font-mono"
rows={6}
value={value.leaveEmbedText}
onChange={(event) => setValue((prev) => ({ ...prev, leaveEmbedText: event.target.value }))}
placeholder='{ "title": "Goodbye", "color": 15158332 }'
/>
</div>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
data-slot="textarea"
className={cn(
'flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 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}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -12,7 +12,25 @@ const EnvSchema = z.object({
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')
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'),
/**
* Comma-separated Discord user IDs that are treated as global bot owners
* for the (future) owner panel. Optional for now.
*/
OWNER_USER_IDS: z.preprocess(
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z
.string()
.optional()
.transform((value) =>
value
? value
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0)
: []
)
)
});
export const env = EnvSchema.parse(process.env);

View File

@@ -0,0 +1,43 @@
import type { AutomodConfigDashboard, AutomodConfigDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureAutoModConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.autoModConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureAutoModConfig>>): AutomodConfigDashboard {
return {
enabled: config.enabled,
antiRaidEnabled: config.antiRaidEnabled,
antiRaidJoinThreshold: config.antiRaidJoinThreshold,
antiRaidWindowSeconds: config.antiRaidWindowSeconds,
antiRaidAction: 'LOCKDOWN',
antiNukeEnabled: config.antiNukeEnabled,
antiNukeBanThreshold: config.antiNukeBanThreshold,
antiNukeChannelThreshold: config.antiNukeChannelThreshold,
antiNukeWindowSeconds: config.antiNukeWindowSeconds,
lockdownActive: config.lockdownActive
};
}
export async function getAutomodDashboard(guildId: string): Promise<AutomodConfigDashboard> {
const config = await ensureAutoModConfig(guildId);
return toDashboard(config);
}
export async function updateAutomodDashboard(
guildId: string,
patch: AutomodConfigDashboardPatch
): Promise<AutomodConfigDashboard> {
await ensureAutoModConfig(guildId);
const updated = await prisma.autoModConfig.update({
where: { guildId },
data: patch
});
return toDashboard(updated);
}

View File

@@ -0,0 +1,39 @@
import type { CaseDashboard, CaseListQuery } from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
export async function listCases(guildId: string, query: CaseListQuery): Promise<CaseDashboard[]> {
const where: Prisma.CaseWhereInput = {
guildId,
deletedAt: null
};
if (query.action) {
where.action = query.action;
}
if (query.search) {
where.OR = [
{ targetUserId: { contains: query.search } },
{ moderatorId: { contains: query.search } },
{ reason: { contains: query.search, mode: 'insensitive' } }
];
}
const cases = await prisma.case.findMany({
where,
orderBy: { createdAt: 'desc' },
take: query.limit
});
return cases.map((entry) => ({
id: entry.id,
caseNumber: entry.caseNumber,
targetUserId: entry.targetUserId,
moderatorId: entry.moderatorId,
action: entry.action,
reason: entry.reason,
createdAt: entry.createdAt.toISOString(),
deletedAt: entry.deletedAt ? entry.deletedAt.toISOString() : null
}));
}

View File

@@ -0,0 +1,39 @@
import type { EconomyConfigDashboard, EconomyConfigDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureEconomyConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.economyConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureEconomyConfig>>): EconomyConfigDashboard {
return {
enabled: config.enabled,
currencyName: config.currencyName,
currencySymbol: config.currencySymbol,
dailyAmount: config.dailyAmount,
weeklyAmount: config.weeklyAmount,
workMin: config.workMin,
workMax: config.workMax,
workCooldownSeconds: config.workCooldownSeconds,
startingBalance: config.startingBalance
};
}
export async function getEconomyDashboard(guildId: string): Promise<EconomyConfigDashboard> {
const config = await ensureEconomyConfig(guildId);
return toDashboard(config);
}
export async function updateEconomyDashboard(
guildId: string,
patch: EconomyConfigDashboardPatch
): Promise<EconomyConfigDashboard> {
await ensureEconomyConfig(guildId);
const updated = await prisma.economyConfig.update({ where: { guildId }, data: patch });
return toDashboard(updated);
}

View File

@@ -0,0 +1,29 @@
import type { FunConfigDashboard, FunConfigDashboardPatch } from '@nexumi/shared';
import { redis } from '../redis';
function funConfigKey(guildId: string): string {
return `fun:config:${guildId}`;
}
export async function getFunDashboard(guildId: string): Promise<FunConfigDashboard> {
const raw = await redis.get(funConfigKey(guildId));
if (!raw) {
return { enabled: true, mediaEnabled: true };
}
try {
const parsed = JSON.parse(raw) as Partial<FunConfigDashboard>;
return { enabled: parsed.enabled ?? true, mediaEnabled: parsed.mediaEnabled ?? true };
} catch {
return { enabled: true, mediaEnabled: true };
}
}
export async function updateFunDashboard(
guildId: string,
patch: FunConfigDashboardPatch
): Promise<FunConfigDashboard> {
const current = await getFunDashboard(guildId);
const next: FunConfigDashboard = { ...current, ...patch };
await redis.set(funConfigKey(guildId), JSON.stringify(next));
return next;
}

View File

@@ -0,0 +1,69 @@
import type { LevelingConfigDashboard, LevelingConfigDashboardPatch } from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
async function ensureLevelingConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.levelingConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toMultiplierMap(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== 'object') {
return {};
}
const entries = Object.entries(raw as Record<string, unknown>).filter(
(entry): entry is [string, number] => typeof entry[1] === 'number'
);
return Object.fromEntries(entries);
}
function toDashboard(config: Awaited<ReturnType<typeof ensureLevelingConfig>>): LevelingConfigDashboard {
return {
enabled: config.enabled,
textXpMin: config.textXpMin,
textXpMax: config.textXpMax,
textCooldownSeconds: config.textCooldownSeconds,
voiceXpPerMinute: config.voiceXpPerMinute,
noXpChannelIds: config.noXpChannelIds,
noXpRoleIds: config.noXpRoleIds,
roleMultipliers: toMultiplierMap(config.roleMultipliers),
channelMultipliers: toMultiplierMap(config.channelMultipliers),
levelUpMode: config.levelUpMode as LevelingConfigDashboard['levelUpMode'],
levelUpChannelId: config.levelUpChannelId ?? '',
levelUpMessage: config.levelUpMessage,
stackRewards: config.stackRewards,
cardAccentColor: config.cardAccentColor,
cardBackgroundColor: config.cardBackgroundColor
};
}
export async function getLevelingDashboard(guildId: string): Promise<LevelingConfigDashboard> {
const config = await ensureLevelingConfig(guildId);
return toDashboard(config);
}
export async function updateLevelingDashboard(
guildId: string,
patch: LevelingConfigDashboardPatch
): Promise<LevelingConfigDashboard> {
await ensureLevelingConfig(guildId);
const { levelUpChannelId, roleMultipliers, channelMultipliers, ...rest } = patch;
const data: Prisma.LevelingConfigUpdateInput = { ...rest };
if (levelUpChannelId !== undefined) {
data.levelUpChannelId = levelUpChannelId === '' ? null : levelUpChannelId;
}
if (roleMultipliers !== undefined) {
data.roleMultipliers = roleMultipliers as Prisma.InputJsonValue;
}
if (channelMultipliers !== undefined) {
data.channelMultipliers = channelMultipliers as Prisma.InputJsonValue;
}
const updated = await prisma.levelingConfig.update({ where: { guildId }, data });
return toDashboard(updated);
}

View File

@@ -0,0 +1,55 @@
import type { LogEventTypeDashboard, LoggingConfigDashboard, LoggingConfigDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureLoggingConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.loggingConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function getLoggingDashboard(guildId: string): Promise<LoggingConfigDashboard> {
const [config, channels] = await Promise.all([
ensureLoggingConfig(guildId),
prisma.logChannel.findMany({ where: { guildId }, orderBy: { eventType: 'asc' } })
]);
return {
enabled: config.enabled,
ignoreBots: config.ignoreBots,
ignoredChannelIds: config.ignoredChannelIds,
ignoredRoleIds: config.ignoredRoleIds,
logChannels: channels.map((channel) => ({
eventType: channel.eventType as LogEventTypeDashboard,
channelId: channel.channelId
}))
};
}
export async function updateLoggingDashboard(
guildId: string,
patch: LoggingConfigDashboardPatch
): Promise<LoggingConfigDashboard> {
await ensureLoggingConfig(guildId);
const { logChannels, ...configPatch } = patch;
if (Object.keys(configPatch).length > 0) {
await prisma.loggingConfig.update({ where: { guildId }, data: configPatch });
}
if (logChannels !== undefined) {
await prisma.$transaction([
prisma.logChannel.deleteMany({ where: { guildId } }),
...logChannels.map((mapping) =>
prisma.logChannel.create({
data: { guildId, eventType: mapping.eventType, channelId: mapping.channelId }
})
)
]);
}
return getLoggingDashboard(guildId);
}

View File

@@ -0,0 +1,69 @@
import type { EscalationAction, ModerationDashboard, ModerationDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureGuild(guildId: string): Promise<void> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
}
async function ensureGuildSettings(guildId: string) {
await ensureGuild(guildId);
return prisma.guildSettings.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function getModerationDashboard(guildId: string): Promise<ModerationDashboard> {
const [settings, rules] = await Promise.all([
ensureGuildSettings(guildId),
prisma.escalationRule.findMany({ where: { guildId }, orderBy: { warnCount: 'asc' } })
]);
return {
moderationEnabled: settings.moderationEnabled,
escalations: rules.map((rule) => ({
id: rule.id,
warnCount: rule.warnCount,
action: rule.action as EscalationAction,
durationMs: rule.durationMs,
reason: rule.reason,
enabled: rule.enabled
}))
};
}
export async function updateModerationDashboard(
guildId: string,
patch: ModerationDashboardPatch
): Promise<ModerationDashboard> {
await ensureGuild(guildId);
if (patch.moderationEnabled !== undefined) {
await prisma.guildSettings.upsert({
where: { guildId },
update: { moderationEnabled: patch.moderationEnabled },
create: { guildId, moderationEnabled: patch.moderationEnabled }
});
}
if (patch.escalations !== undefined) {
await prisma.$transaction([
prisma.escalationRule.deleteMany({ where: { guildId } }),
...patch.escalations.map((rule) =>
prisma.escalationRule.create({
data: {
guildId,
warnCount: rule.warnCount,
action: rule.action,
durationMs: rule.durationMs ?? null,
reason: rule.reason ?? null,
enabled: rule.enabled
}
})
)
]);
}
return getModerationDashboard(guildId);
}

View File

@@ -0,0 +1,51 @@
import type { VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
async function ensureVerificationConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.verificationConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureVerificationConfig>>): VerificationConfigDashboard {
return {
enabled: config.enabled,
channelId: config.channelId ?? '',
verifiedRoleId: config.verifiedRoleId ?? '',
unverifiedRoleId: config.unverifiedRoleId ?? '',
mode: config.mode as VerificationConfigDashboard['mode'],
minAccountAgeDays: config.minAccountAgeDays,
failAction: config.failAction as VerificationConfigDashboard['failAction']
};
}
export async function getVerificationDashboard(guildId: string): Promise<VerificationConfigDashboard> {
const config = await ensureVerificationConfig(guildId);
return toDashboard(config);
}
export async function updateVerificationDashboard(
guildId: string,
patch: VerificationConfigDashboardPatch
): Promise<VerificationConfigDashboard> {
await ensureVerificationConfig(guildId);
const { channelId, verifiedRoleId, unverifiedRoleId, ...rest } = patch;
const data: Prisma.VerificationConfigUpdateInput = { ...rest };
if (channelId !== undefined) {
data.channelId = channelId === '' ? null : channelId;
}
if (verifiedRoleId !== undefined) {
data.verifiedRoleId = verifiedRoleId === '' ? null : verifiedRoleId;
}
if (unverifiedRoleId !== undefined) {
data.unverifiedRoleId = unverifiedRoleId === '' ? null : unverifiedRoleId;
}
const updated = await prisma.verificationConfig.update({ where: { guildId }, data });
return toDashboard(updated);
}

View File

@@ -0,0 +1,22 @@
import type { WarningDashboard, WarningListQuery } from '@nexumi/shared';
import { prisma } from '../prisma';
export async function listWarnings(guildId: string, query: WarningListQuery): Promise<WarningDashboard[]> {
const warnings = await prisma.warning.findMany({
where: {
guildId,
...(query.userId ? { userId: query.userId } : {})
},
orderBy: { createdAt: 'desc' },
take: 100
});
return warnings.map((warning) => ({
id: warning.id,
userId: warning.userId,
moderatorId: warning.moderatorId,
reason: warning.reason,
caseId: warning.caseId,
createdAt: warning.createdAt.toISOString()
}));
}

View File

@@ -0,0 +1,63 @@
import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch } from '@nexumi/shared';
import { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
async function ensureWelcomeConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.welcomeConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
return {
welcomeEnabled: config.welcomeEnabled,
leaveEnabled: config.leaveEnabled,
welcomeChannelId: config.welcomeChannelId ?? '',
leaveChannelId: config.leaveChannelId ?? '',
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
welcomeContent: config.welcomeContent,
welcomeEmbed: (config.welcomeEmbed as Record<string, unknown> | null) ?? null,
leaveContent: config.leaveContent,
leaveEmbed: (config.leaveEmbed as Record<string, unknown> | null) ?? null,
welcomeDmEnabled: config.welcomeDmEnabled,
welcomeDmContent: config.welcomeDmContent,
userAutoroleIds: config.userAutoroleIds,
botAutoroleIds: config.botAutoroleIds,
imageTitle: config.imageTitle,
imageSubtitle: config.imageSubtitle
};
}
export async function getWelcomeDashboard(guildId: string): Promise<WelcomeConfigDashboard> {
const config = await ensureWelcomeConfig(guildId);
return toDashboard(config);
}
export async function updateWelcomeDashboard(
guildId: string,
patch: WelcomeConfigDashboardPatch
): Promise<WelcomeConfigDashboard> {
await ensureWelcomeConfig(guildId);
const { welcomeChannelId, leaveChannelId, welcomeEmbed, leaveEmbed, ...rest } = patch;
const data: Prisma.WelcomeConfigUpdateInput = { ...rest };
if (welcomeChannelId !== undefined) {
data.welcomeChannelId = welcomeChannelId === '' ? null : welcomeChannelId;
}
if (leaveChannelId !== undefined) {
data.leaveChannelId = leaveChannelId === '' ? null : leaveChannelId;
}
if (welcomeEmbed !== undefined) {
data.welcomeEmbed = welcomeEmbed === null ? Prisma.JsonNull : (welcomeEmbed as Prisma.InputJsonValue);
}
if (leaveEmbed !== undefined) {
data.leaveEmbed = leaveEmbed === null ? Prisma.JsonNull : (leaveEmbed as Prisma.InputJsonValue);
}
const updated = await prisma.welcomeConfig.update({ where: { guildId }, data });
return toDashboard(updated);
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import {
AutomodConfigDashboardPatchSchema,
CaseListQuerySchema,
EconomyConfigDashboardPatchSchema,
FunConfigDashboardSchema,
LoggingConfigDashboardPatchSchema,
ModerationDashboardPatchSchema,
OptionalSnowflakeSchema,
WelcomeConfigDashboardPatchSchema
} from './dashboard.js';
describe('dashboard schemas', () => {
it('accepts moderation patches with escalations', () => {
const parsed = ModerationDashboardPatchSchema.parse({
moderationEnabled: true,
escalations: [{ warnCount: 3, action: 'TIMEOUT', durationMs: 60_000, enabled: true }]
});
expect(parsed.escalations?.[0]?.action).toBe('TIMEOUT');
});
it('rejects empty moderation patches', () => {
expect(() => ModerationDashboardPatchSchema.parse({})).toThrow();
});
it('validates automod patches', () => {
const parsed = AutomodConfigDashboardPatchSchema.parse({
antiRaidEnabled: true,
antiRaidJoinThreshold: 15
});
expect(parsed.antiRaidJoinThreshold).toBe(15);
});
it('validates logging patches with channel mappings', () => {
const parsed = LoggingConfigDashboardPatchSchema.parse({
logChannels: [{ eventType: 'MEMBER_JOIN', channelId: '123456789012345678' }]
});
expect(parsed.logChannels?.[0]?.eventType).toBe('MEMBER_JOIN');
});
it('rejects invalid log channel ids', () => {
expect(() =>
LoggingConfigDashboardPatchSchema.parse({
logChannels: [{ eventType: 'MEMBER_JOIN', channelId: 'not-a-snowflake' }]
})
).toThrow();
});
it('accepts empty string or a valid snowflake as optional id', () => {
expect(OptionalSnowflakeSchema.parse('')).toBe('');
expect(OptionalSnowflakeSchema.parse('123456789012345678')).toBe('123456789012345678');
expect(() => OptionalSnowflakeSchema.parse('abc')).toThrow();
});
it('validates welcome patches', () => {
const parsed = WelcomeConfigDashboardPatchSchema.parse({
welcomeEnabled: true,
welcomeChannelId: '123456789012345678'
});
expect(parsed.welcomeEnabled).toBe(true);
});
it('validates economy patches', () => {
const parsed = EconomyConfigDashboardPatchSchema.parse({ currencyName: 'Gems', dailyAmount: 100 });
expect(parsed.currencyName).toBe('Gems');
});
it('validates fun config', () => {
const parsed = FunConfigDashboardSchema.parse({ enabled: true, mediaEnabled: false });
expect(parsed.mediaEnabled).toBe(false);
});
it('applies default limit for case list queries', () => {
const parsed = CaseListQuerySchema.parse({});
expect(parsed.limit).toBe(25);
});
});

View File

@@ -0,0 +1,279 @@
import { z } from 'zod';
/**
* Discord snowflake ID: 17-20 digit numeric string.
*/
export const SnowflakeSchema = z.string().regex(/^\d{17,20}$/, 'Invalid Discord ID');
/**
* Optional snowflake field for forms: accepts an empty string (treated as
* "unset"/cleared) or a valid snowflake. Kept as a plain string (rather than
* transformed to `undefined`) so that `JSON.stringify` does not silently
* drop the field when a user clears it in the dashboard - the API layer
* maps `''` to `null` when persisting.
*/
export const OptionalSnowflakeSchema = z.union([z.literal(''), SnowflakeSchema]);
function nonEmptyPatch<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
return schema.partial().refine((value) => Object.keys(value).length > 0, {
message: 'At least one field is required'
});
}
// ---------------------------------------------------------------------------
// Moderation
// ---------------------------------------------------------------------------
export const EscalationActionSchema = z.enum(['TIMEOUT', 'KICK', 'BAN']);
export type EscalationAction = z.infer<typeof EscalationActionSchema>;
export const EscalationRuleDashboardSchema = z.object({
id: z.string().optional(),
warnCount: z.number().int().positive(),
action: EscalationActionSchema,
durationMs: z.number().int().positive().nullable().optional(),
reason: z.string().max(500).nullable().optional(),
enabled: z.boolean().default(true)
});
export type EscalationRuleDashboard = z.infer<typeof EscalationRuleDashboardSchema>;
export const ModerationDashboardSchema = z.object({
moderationEnabled: z.boolean(),
escalations: z.array(EscalationRuleDashboardSchema)
});
export type ModerationDashboard = z.infer<typeof ModerationDashboardSchema>;
export const ModerationDashboardPatchSchema = z
.object({
moderationEnabled: z.boolean().optional(),
escalations: z.array(EscalationRuleDashboardSchema).optional()
})
.refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' });
export type ModerationDashboardPatch = z.infer<typeof ModerationDashboardPatchSchema>;
export const CaseDashboardSchema = z.object({
id: z.string(),
caseNumber: z.number().int(),
targetUserId: z.string(),
moderatorId: z.string(),
action: z.string(),
reason: z.string().nullable(),
createdAt: z.string(),
deletedAt: z.string().nullable()
});
export type CaseDashboard = z.infer<typeof CaseDashboardSchema>;
export const CaseListQuerySchema = z.object({
search: z.string().trim().max(100).optional(),
action: z.string().trim().max(50).optional(),
limit: z.coerce.number().int().positive().max(100).default(25)
});
export type CaseListQuery = z.infer<typeof CaseListQuerySchema>;
export const WarningDashboardSchema = z.object({
id: z.string(),
userId: z.string(),
moderatorId: z.string(),
reason: z.string().nullable(),
caseId: z.string().nullable(),
createdAt: z.string()
});
export type WarningDashboard = z.infer<typeof WarningDashboardSchema>;
export const WarningListQuerySchema = z.object({
userId: SnowflakeSchema.optional()
});
export type WarningListQuery = z.infer<typeof WarningListQuerySchema>;
// ---------------------------------------------------------------------------
// AutoMod
// ---------------------------------------------------------------------------
export const AntiRaidActionSchema = z.enum(['LOCKDOWN']);
export const AutomodConfigDashboardSchema = z.object({
enabled: z.boolean(),
antiRaidEnabled: z.boolean(),
antiRaidJoinThreshold: z.number().int().positive(),
antiRaidWindowSeconds: z.number().int().positive(),
antiRaidAction: AntiRaidActionSchema,
antiNukeEnabled: z.boolean(),
antiNukeBanThreshold: z.number().int().positive(),
antiNukeChannelThreshold: z.number().int().positive(),
antiNukeWindowSeconds: z.number().int().positive(),
lockdownActive: z.boolean()
});
export type AutomodConfigDashboard = z.infer<typeof AutomodConfigDashboardSchema>;
export const AutomodConfigDashboardPatchSchema = nonEmptyPatch(AutomodConfigDashboardSchema);
export type AutomodConfigDashboardPatch = z.infer<typeof AutomodConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
export const LogEventTypeDashboardSchema = z.enum([
'MESSAGE_EDIT',
'MESSAGE_DELETE',
'MESSAGE_BULK_DELETE',
'MEMBER_JOIN',
'MEMBER_LEAVE',
'MEMBER_BAN',
'MEMBER_UNBAN',
'MEMBER_UPDATE',
'CHANNEL_CREATE',
'CHANNEL_DELETE',
'CHANNEL_UPDATE',
'VOICE_JOIN',
'VOICE_LEAVE',
'VOICE_MOVE',
'INVITE_CREATE',
'EMOJI_CREATE',
'EMOJI_UPDATE',
'EMOJI_DELETE',
'STICKER_CREATE',
'STICKER_UPDATE',
'STICKER_DELETE',
'THREAD_CREATE',
'THREAD_UPDATE',
'THREAD_DELETE',
'GUILD_UPDATE',
'MOD_ACTION'
]);
export type LogEventTypeDashboard = z.infer<typeof LogEventTypeDashboardSchema>;
export const LogChannelMappingSchema = z.object({
eventType: LogEventTypeDashboardSchema,
channelId: SnowflakeSchema
});
export type LogChannelMapping = z.infer<typeof LogChannelMappingSchema>;
export const LoggingConfigDashboardSchema = z.object({
enabled: z.boolean(),
ignoreBots: z.boolean(),
ignoredChannelIds: z.array(z.string()),
ignoredRoleIds: z.array(z.string()),
logChannels: z.array(LogChannelMappingSchema)
});
export type LoggingConfigDashboard = z.infer<typeof LoggingConfigDashboardSchema>;
export const LoggingConfigDashboardPatchSchema = z
.object({
enabled: z.boolean().optional(),
ignoreBots: z.boolean().optional(),
ignoredChannelIds: z.array(z.string()).optional(),
ignoredRoleIds: z.array(z.string()).optional(),
logChannels: z.array(LogChannelMappingSchema).optional()
})
.refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' });
export type LoggingConfigDashboardPatch = z.infer<typeof LoggingConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Welcome & Leave
// ---------------------------------------------------------------------------
export const WelcomeMessageTypeDashboardSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
export const WelcomeConfigDashboardSchema = z.object({
welcomeEnabled: z.boolean(),
leaveEnabled: z.boolean(),
welcomeChannelId: OptionalSnowflakeSchema,
leaveChannelId: OptionalSnowflakeSchema,
welcomeType: WelcomeMessageTypeDashboardSchema,
welcomeContent: z.string().max(2000).nullable().optional(),
welcomeEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
leaveContent: z.string().max(2000).nullable().optional(),
leaveEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
welcomeDmEnabled: z.boolean(),
welcomeDmContent: z.string().max(2000).nullable().optional(),
userAutoroleIds: z.array(z.string()),
botAutoroleIds: z.array(z.string()),
imageTitle: z.string().max(200).nullable().optional(),
imageSubtitle: z.string().max(200).nullable().optional()
});
export type WelcomeConfigDashboard = z.infer<typeof WelcomeConfigDashboardSchema>;
export const WelcomeConfigDashboardPatchSchema = nonEmptyPatch(WelcomeConfigDashboardSchema);
export type WelcomeConfigDashboardPatch = z.infer<typeof WelcomeConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------------
export const VerificationModeDashboardSchema = z.enum(['BUTTON', 'CAPTCHA']);
export const VerificationFailActionDashboardSchema = z.enum(['KICK', 'BAN', 'NONE']);
export const VerificationConfigDashboardSchema = z.object({
enabled: z.boolean(),
channelId: OptionalSnowflakeSchema,
verifiedRoleId: OptionalSnowflakeSchema,
unverifiedRoleId: OptionalSnowflakeSchema,
mode: VerificationModeDashboardSchema,
minAccountAgeDays: z.number().int().nonnegative(),
failAction: VerificationFailActionDashboardSchema
});
export type VerificationConfigDashboard = z.infer<typeof VerificationConfigDashboardSchema>;
export const VerificationConfigDashboardPatchSchema = nonEmptyPatch(VerificationConfigDashboardSchema);
export type VerificationConfigDashboardPatch = z.infer<typeof VerificationConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Leveling
// ---------------------------------------------------------------------------
export const LevelUpModeDashboardSchema = z.enum(['CHANNEL', 'DM', 'OFF']);
export const LevelingConfigDashboardSchema = z.object({
enabled: z.boolean(),
textXpMin: z.number().int().positive(),
textXpMax: z.number().int().positive(),
textCooldownSeconds: z.number().int().positive(),
voiceXpPerMinute: z.number().int().nonnegative(),
noXpChannelIds: z.array(z.string()),
noXpRoleIds: z.array(z.string()),
roleMultipliers: z.record(z.string(), z.number().positive()),
channelMultipliers: z.record(z.string(), z.number().positive()),
levelUpMode: LevelUpModeDashboardSchema,
levelUpChannelId: OptionalSnowflakeSchema,
levelUpMessage: z.string().max(500).nullable().optional(),
stackRewards: z.boolean(),
cardAccentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
cardBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/)
});
export type LevelingConfigDashboard = z.infer<typeof LevelingConfigDashboardSchema>;
export const LevelingConfigDashboardPatchSchema = nonEmptyPatch(LevelingConfigDashboardSchema);
export type LevelingConfigDashboardPatch = z.infer<typeof LevelingConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Economy
// ---------------------------------------------------------------------------
export const EconomyConfigDashboardSchema = z.object({
enabled: z.boolean(),
currencyName: z.string().min(1).max(32),
currencySymbol: z.string().min(1).max(8),
dailyAmount: z.number().int().nonnegative(),
weeklyAmount: z.number().int().nonnegative(),
workMin: z.number().int().nonnegative(),
workMax: z.number().int().nonnegative(),
workCooldownSeconds: z.number().int().positive(),
startingBalance: z.number().int().nonnegative()
});
export type EconomyConfigDashboard = z.infer<typeof EconomyConfigDashboardSchema>;
export const EconomyConfigDashboardPatchSchema = nonEmptyPatch(EconomyConfigDashboardSchema);
export type EconomyConfigDashboardPatch = z.infer<typeof EconomyConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Fun
// ---------------------------------------------------------------------------
export const FunConfigDashboardSchema = z.object({
enabled: z.boolean(),
mediaEnabled: z.boolean()
});
export type FunConfigDashboard = z.infer<typeof FunConfigDashboardSchema>;
export const FunConfigDashboardPatchSchema = nonEmptyPatch(FunConfigDashboardSchema);
export type FunConfigDashboardPatch = z.infer<typeof FunConfigDashboardPatchSchema>;

View File

@@ -8,6 +8,7 @@ export * from './phase3.js';
export * from './phase4.js';
export * from './phase5.js';
export * from './phase6.js';
export * from './dashboard.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;