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:
@@ -21,3 +21,5 @@ TWITCH_CLIENT_SECRET=
|
|||||||
WEBUI_URL=http://10.111.0.65:3000
|
WEBUI_URL=http://10.111.0.65:3000
|
||||||
# Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`.
|
# Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`.
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
|
||||||
|
OWNER_USER_IDS=
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -53,6 +53,7 @@ model Guild {
|
|||||||
guildBackups GuildBackup[]
|
guildBackups GuildBackup[]
|
||||||
dashboardAccessRules DashboardAccessRule[]
|
dashboardAccessRules DashboardAccessRule[]
|
||||||
dashboardAuditLogs DashboardAuditLog[]
|
dashboardAuditLogs DashboardAuditLog[]
|
||||||
|
commandOverrides CommandOverride[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model GuildSettings {
|
model GuildSettings {
|
||||||
@@ -790,3 +791,93 @@ model GuildBackup {
|
|||||||
@@index([guildId, createdAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
46
apps/webui/src/app/api/guilds/[guildId]/automod/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/automod/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/webui/src/app/api/guilds/[guildId]/cases/route.ts
Normal file
25
apps/webui/src/app/api/guilds/[guildId]/cases/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/economy/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/economy/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/fun/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/fun/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/leveling/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/leveling/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/logging/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/logging/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/moderation/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/moderation/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts
Normal file
23
apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/welcome/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/welcome/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
import { DASHBOARD_MODULES } from '@nexumi/shared';
|
import { DASHBOARD_MODULES, type DashboardModuleId } from '@nexumi/shared';
|
||||||
import { Construction } from 'lucide-react';
|
import { Construction } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
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 {
|
interface ModulePlaceholderPageProps {
|
||||||
params: Promise<{ guildId: string; module: string }>;
|
params: Promise<{ guildId: string; module: string }>;
|
||||||
}
|
}
|
||||||
@@ -13,7 +34,7 @@ export default async function ModulePlaceholderPage({ params }: ModulePlaceholde
|
|||||||
const { guildId, module: moduleHref } = await params;
|
const { guildId, module: moduleHref } = await params;
|
||||||
const moduleEntry = DASHBOARD_MODULES.find((entry) => entry.href === moduleHref);
|
const moduleEntry = DASHBOARD_MODULES.find((entry) => entry.href === moduleHref);
|
||||||
|
|
||||||
if (!moduleEntry) {
|
if (!moduleEntry || !REMAINING_MODULE_IDS.has(moduleEntry.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
apps/webui/src/app/dashboard/[guildId]/automod/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/automod/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/automod/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/automod/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/economy/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/economy/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/economy/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/economy/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/fun/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/fun/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/fun/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/fun/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/leveling/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/leveling/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/leveling/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/leveling/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/logging/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/logging/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/logging/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/logging/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx
Normal file
30
apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/verification/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/verification/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/welcome/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/welcome/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
apps/webui/src/components/modules/automod-form.tsx
Normal file
165
apps/webui/src/components/modules/automod-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/webui/src/components/modules/cases-table.tsx
Normal file
130
apps/webui/src/components/modules/cases-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/webui/src/components/modules/economy-form.tsx
Normal file
142
apps/webui/src/components/modules/economy-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/webui/src/components/modules/fun-form.tsx
Normal file
70
apps/webui/src/components/modules/fun-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
apps/webui/src/components/modules/leveling-form.tsx
Normal file
320
apps/webui/src/components/modules/leveling-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
apps/webui/src/components/modules/logging-form.tsx
Normal file
257
apps/webui/src/components/modules/logging-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
apps/webui/src/components/modules/moderation-form.tsx
Normal file
232
apps/webui/src/components/modules/moderation-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
apps/webui/src/components/modules/verification-form.tsx
Normal file
140
apps/webui/src/components/modules/verification-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
apps/webui/src/components/modules/welcome-form.tsx
Normal file
269
apps/webui/src/components/modules/welcome-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/webui/src/components/ui/textarea.tsx
Normal file
21
apps/webui/src/components/ui/textarea.tsx
Normal 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 };
|
||||||
@@ -12,7 +12,25 @@ const EnvSchema = z.object({
|
|||||||
WEBUI_URL: z.string().url().default('http://localhost:3000'),
|
WEBUI_URL: z.string().url().default('http://localhost:3000'),
|
||||||
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
|
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
|
||||||
SENTRY_DSN: z.string().optional(),
|
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);
|
export const env = EnvSchema.parse(process.env);
|
||||||
|
|||||||
43
apps/webui/src/lib/module-configs/automod.ts
Normal file
43
apps/webui/src/lib/module-configs/automod.ts
Normal 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);
|
||||||
|
}
|
||||||
39
apps/webui/src/lib/module-configs/cases.ts
Normal file
39
apps/webui/src/lib/module-configs/cases.ts
Normal 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
39
apps/webui/src/lib/module-configs/economy.ts
Normal file
39
apps/webui/src/lib/module-configs/economy.ts
Normal 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);
|
||||||
|
}
|
||||||
29
apps/webui/src/lib/module-configs/fun.ts
Normal file
29
apps/webui/src/lib/module-configs/fun.ts
Normal 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;
|
||||||
|
}
|
||||||
69
apps/webui/src/lib/module-configs/leveling.ts
Normal file
69
apps/webui/src/lib/module-configs/leveling.ts
Normal 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);
|
||||||
|
}
|
||||||
55
apps/webui/src/lib/module-configs/logging.ts
Normal file
55
apps/webui/src/lib/module-configs/logging.ts
Normal 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);
|
||||||
|
}
|
||||||
69
apps/webui/src/lib/module-configs/moderation.ts
Normal file
69
apps/webui/src/lib/module-configs/moderation.ts
Normal 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);
|
||||||
|
}
|
||||||
51
apps/webui/src/lib/module-configs/verification.ts
Normal file
51
apps/webui/src/lib/module-configs/verification.ts
Normal 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);
|
||||||
|
}
|
||||||
22
apps/webui/src/lib/module-configs/warnings.ts
Normal file
22
apps/webui/src/lib/module-configs/warnings.ts
Normal 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()
|
||||||
|
}));
|
||||||
|
}
|
||||||
63
apps/webui/src/lib/module-configs/welcome.ts
Normal file
63
apps/webui/src/lib/module-configs/welcome.ts
Normal 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);
|
||||||
|
}
|
||||||
77
packages/shared/src/dashboard.test.ts
Normal file
77
packages/shared/src/dashboard.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
279
packages/shared/src/dashboard.ts
Normal file
279
packages/shared/src/dashboard.ts
Normal 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>;
|
||||||
@@ -8,6 +8,7 @@ export * from './phase3.js';
|
|||||||
export * from './phase4.js';
|
export * from './phase4.js';
|
||||||
export * from './phase5.js';
|
export * from './phase5.js';
|
||||||
export * from './phase6.js';
|
export * from './phase6.js';
|
||||||
|
export * from './dashboard.js';
|
||||||
|
|
||||||
export const LocaleSchema = z.enum(['de', 'en']);
|
export const LocaleSchema = z.enum(['de', 'en']);
|
||||||
export type Locale = z.infer<typeof LocaleSchema>;
|
export type Locale = z.infer<typeof LocaleSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user