diff --git a/.env.example b/.env.example index b19a53d..b2aa0c7 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,5 @@ TWITCH_CLIENT_SECRET= WEBUI_URL=http://10.111.0.65:3000 # Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`. SESSION_SECRET= +# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C). +OWNER_USER_IDS= diff --git a/apps/bot/prisma/migrations/20260722190000_phase7_owner/migration.sql b/apps/bot/prisma/migrations/20260722190000_phase7_owner/migration.sql new file mode 100644 index 0000000..1491109 --- /dev/null +++ b/apps/bot/prisma/migrations/20260722190000_phase7_owner/migration.sql @@ -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; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 2411a9c..73464fc 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -53,6 +53,7 @@ model Guild { guildBackups GuildBackup[] dashboardAccessRules DashboardAccessRule[] dashboardAuditLogs DashboardAuditLog[] + commandOverrides CommandOverride[] } model GuildSettings { @@ -790,3 +791,93 @@ model GuildBackup { @@index([guildId, createdAt]) } +model CommandOverride { + id String @id @default(cuid()) + guildId String + commandName String + enabled Boolean @default(true) + allowedRoleIds String[] @default([]) + deniedRoleIds String[] @default([]) + allowedChannelIds String[] @default([]) + deniedChannelIds String[] @default([]) + cooldownSeconds Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, commandName]) + @@index([guildId]) +} + +model OwnerTeamMember { + id String @id @default(cuid()) + userId String @unique + role String + note String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model GlobalUserBlacklist { + id String @id @default(cuid()) + userId String @unique + reason String? + note String? + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model GuildBlacklist { + id String @id @default(cuid()) + guildId String @unique + reason String? + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model FeatureFlag { + id String @id @default(cuid()) + key String @unique + enabled Boolean @default(false) + rolloutPercent Int @default(0) + whitelistGuildIds String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model BotPresenceConfig { + id String @id @default("singleton") + status String @default("online") + activityType String @default("Playing") + activityText String @default("nexumi.de") + rotatingMessages Json? + maintenanceMode Boolean @default(false) + maintenanceMessage String? + updatedAt DateTime @updatedAt +} + +model ChangelogEntry { + id String @id @default(cuid()) + version String + title String + body String + publishedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OwnerAuditLog { + id String @id @default(cuid()) + actorUserId String + action String + targetType String? + targetId String? + before Json? + after Json? + createdAt DateTime @default(now()) + + @@index([createdAt]) +} + diff --git a/apps/webui/src/app/api/guilds/[guildId]/automod/route.ts b/apps/webui/src/app/api/guilds/[guildId]/automod/route.ts new file mode 100644 index 0000000..44fe22f --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/automod/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts b/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts new file mode 100644 index 0000000..d25bf54 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/economy/route.ts b/apps/webui/src/app/api/guilds/[guildId]/economy/route.ts new file mode 100644 index 0000000..0f42544 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/economy/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/fun/route.ts b/apps/webui/src/app/api/guilds/[guildId]/fun/route.ts new file mode 100644 index 0000000..3dabd7d --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/fun/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/leveling/route.ts b/apps/webui/src/app/api/guilds/[guildId]/leveling/route.ts new file mode 100644 index 0000000..7b15953 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/leveling/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/logging/route.ts b/apps/webui/src/app/api/guilds/[guildId]/logging/route.ts new file mode 100644 index 0000000..1dcf0f9 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/logging/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/moderation/route.ts b/apps/webui/src/app/api/guilds/[guildId]/moderation/route.ts new file mode 100644 index 0000000..91dace9 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/moderation/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts new file mode 100644 index 0000000..33d9c64 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts b/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts new file mode 100644 index 0000000..6d7a77f --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/welcome/route.ts b/apps/webui/src/app/api/guilds/[guildId]/welcome/route.ts new file mode 100644 index 0000000..6eed7db --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/welcome/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx b/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx index 5c5a8cd..d26c3ed 100644 --- a/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx @@ -1,10 +1,31 @@ -import { DASHBOARD_MODULES } from '@nexumi/shared'; +import { DASHBOARD_MODULES, type DashboardModuleId } from '@nexumi/shared'; import { Construction } from 'lucide-react'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { Card, CardContent } from '@/components/ui/card'; import { getLocale, t } from '@/lib/i18n'; +/** + * Batch A modules (Phase 7) got dedicated route folders (e.g. + * `dashboard/[guildId]/moderation/page.tsx`), which Next.js prefers over this + * catch-all - so they never reach this component. This placeholder now only + * serves the remaining Batch B/C modules until they get dedicated pages too. + */ +const REMAINING_MODULE_IDS: ReadonlySet = new Set([ + 'giveaways', + 'tickets', + 'selfroles', + 'tags', + 'starboard', + 'suggestions', + 'birthdays', + 'tempvoice', + 'stats', + 'feeds', + 'scheduler', + 'guildbackup' +]); + interface ModulePlaceholderPageProps { params: Promise<{ guildId: string; module: string }>; } @@ -13,7 +34,7 @@ export default async function ModulePlaceholderPage({ params }: ModulePlaceholde const { guildId, module: moduleHref } = await params; const moduleEntry = DASHBOARD_MODULES.find((entry) => entry.href === moduleHref); - if (!moduleEntry) { + if (!moduleEntry || !REMAINING_MODULE_IDS.has(moduleEntry.id)) { notFound(); } diff --git a/apps/webui/src/app/dashboard/[guildId]/automod/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/automod/loading.tsx new file mode 100644 index 0000000..5f883cd --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/automod/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function AutomodLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/automod/page.tsx b/apps/webui/src/app/dashboard/[guildId]/automod/page.tsx new file mode 100644 index 0000000..21870ef --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/automod/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.automod.label')}

+

{t(locale, 'modules.automod.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/economy/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/economy/loading.tsx new file mode 100644 index 0000000..cc1e533 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/economy/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EconomyLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/economy/page.tsx b/apps/webui/src/app/dashboard/[guildId]/economy/page.tsx new file mode 100644 index 0000000..2667e97 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/economy/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.economy.label')}

+

{t(locale, 'modules.economy.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/fun/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/fun/loading.tsx new file mode 100644 index 0000000..08d6549 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/fun/loading.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function FunLoading() { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/fun/page.tsx b/apps/webui/src/app/dashboard/[guildId]/fun/page.tsx new file mode 100644 index 0000000..8de6dd8 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/fun/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.fun.label')}

+

{t(locale, 'modules.fun.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/leveling/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/leveling/loading.tsx new file mode 100644 index 0000000..eb953d0 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/leveling/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function LevelingLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/leveling/page.tsx b/apps/webui/src/app/dashboard/[guildId]/leveling/page.tsx new file mode 100644 index 0000000..81a7394 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/leveling/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.leveling.label')}

+

{t(locale, 'modules.leveling.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/logging/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/logging/loading.tsx new file mode 100644 index 0000000..a995a30 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/logging/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function LoggingLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/logging/page.tsx b/apps/webui/src/app/dashboard/[guildId]/logging/page.tsx new file mode 100644 index 0000000..eedd99c --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/logging/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.logging.label')}

+

{t(locale, 'modules.logging.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/moderation/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/moderation/loading.tsx new file mode 100644 index 0000000..a29da3c --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/moderation/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function ModerationLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx b/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx new file mode 100644 index 0000000..e70627b --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.moderation.label')}

+

{t(locale, 'modules.moderation.description')}

+
+ + +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/verification/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/verification/loading.tsx new file mode 100644 index 0000000..a3d3e11 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/verification/loading.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function VerificationLoading() { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx b/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx new file mode 100644 index 0000000..b5c859c --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.verification.label')}

+

{t(locale, 'modules.verification.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/welcome/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/welcome/loading.tsx new file mode 100644 index 0000000..245b2f2 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/welcome/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function WelcomeLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx new file mode 100644 index 0000000..fe0533f --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx @@ -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 ( +
+
+

{t(locale, 'modules.welcome.label')}

+

{t(locale, 'modules.welcome.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/components/modules/automod-form.tsx b/apps/webui/src/components/modules/automod-form.tsx new file mode 100644 index 0000000..909ba6a --- /dev/null +++ b/apps/webui/src/components/modules/automod-form.tsx @@ -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 { + 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 ( + initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}> + {({ value, setValue }) => ( +
+ + + {t('modulePages.automod.title')} + {t('modulePages.automod.description')} + + +
+
+ +

{t('modulePages.automod.enabledHint')}

+
+ setValue((prev) => ({ ...prev, enabled: checked }))} + /> +
+
+
+ + + + {t('modulePages.automod.antiRaidTitle')} + {t('modulePages.automod.antiRaidDescription')} + + +
+ + setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))} + /> +
+
+
+ + + setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 })) + } + /> +
+
+ + + setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 })) + } + /> +
+
+

{t('modulePages.automod.antiRaidActionHint')}

+
+
+ + + + {t('modulePages.automod.antiNukeTitle')} + {t('modulePages.automod.antiNukeDescription')} + + +
+ + setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))} + /> +
+
+
+ + + setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 })) + } + /> +
+
+ + + setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 })) + } + /> +
+
+ + + setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 })) + } + /> +
+
+
+
+ +

{t('modulePages.automod.lockdownActiveHint')}

+
+ setValue((prev) => ({ ...prev, lockdownActive: checked }))} + /> +
+
+
+
+ )} + + ); +} diff --git a/apps/webui/src/components/modules/cases-table.tsx b/apps/webui/src/components/modules/cases-table.tsx new file mode 100644 index 0000000..214d9c8 --- /dev/null +++ b/apps/webui/src/components/modules/cases-table.tsx @@ -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(initialCases); + const [search, setSearch] = useState(''); + const [action, setAction] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + {t('modulePages.moderation.casesTitle')} + {t('modulePages.moderation.casesDescription')} + + +
{ + event.preventDefault(); + void fetchCases(); + }} + > +
+ setSearch(event.target.value)} + placeholder={t('modulePages.moderation.searchPlaceholder')} + /> +
+
+ setAction(event.target.value)} + placeholder={t('modulePages.moderation.actionPlaceholder')} + className="w-40" + /> +
+ +
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ) : cases.length === 0 ? ( +

{t('modulePages.moderation.casesEmpty')}

+ ) : ( +
+ + + + + + + + + + + + + {cases.map((entry) => ( + + + + + + + + + ))} + +
#{t('modulePages.moderation.action')}{t('modulePages.moderation.target')}{t('modulePages.moderation.moderator')}{t('modulePages.moderation.reason')}{t('modulePages.moderation.createdAt')}
#{entry.caseNumber}{entry.action}{entry.targetUserId}{entry.moderatorId} + {entry.reason ?? t('modulePages.moderation.noReason')} + + {formatDate(entry.createdAt)} +
+
+ )} +
+
+ ); +} diff --git a/apps/webui/src/components/modules/economy-form.tsx b/apps/webui/src/components/modules/economy-form.tsx new file mode 100644 index 0000000..6e4b0bc --- /dev/null +++ b/apps/webui/src/components/modules/economy-form.tsx @@ -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 { + 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 ( + initialValue={initialValue} onSave={(value) => saveEconomy(guildId, value)}> + {({ value, setValue }) => ( +
+ + + {t('modulePages.economy.title')} + {t('modulePages.economy.description')} + + +
+ + setValue((prev) => ({ ...prev, enabled: checked }))} + /> +
+
+
+ + setValue((prev) => ({ ...prev, currencyName: event.target.value }))} + /> +
+
+ + setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))} + /> +
+
+
+
+ + + + {t('modulePages.economy.amountsTitle')} + + +
+ + setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))} + /> +
+
+ + setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))} + /> +
+
+ + setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))} + /> +
+
+ + setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))} + /> +
+
+ + + setValue((prev) => ({ ...prev, workCooldownSeconds: Number(event.target.value) || 1 })) + } + /> +
+
+ + + setValue((prev) => ({ ...prev, startingBalance: Number(event.target.value) || 0 })) + } + /> +
+
+
+
+ )} + + ); +} diff --git a/apps/webui/src/components/modules/fun-form.tsx b/apps/webui/src/components/modules/fun-form.tsx new file mode 100644 index 0000000..5fe8e98 --- /dev/null +++ b/apps/webui/src/components/modules/fun-form.tsx @@ -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 { + 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 ( + initialValue={initialValue} onSave={(value) => saveFun(guildId, value)}> + {({ value, setValue }) => ( + + + {t('modulePages.fun.title')} + {t('modulePages.fun.description')} + + +
+
+ +

{t('modulePages.fun.enabledHint')}

+
+ setValue((prev) => ({ ...prev, enabled: checked }))} + /> +
+
+
+ +

{t('modulePages.fun.mediaEnabledHint')}

+
+ setValue((prev) => ({ ...prev, mediaEnabled: checked }))} + /> +
+
+
+ )} + + ); +} diff --git a/apps/webui/src/components/modules/leveling-form.tsx b/apps/webui/src/components/modules/leveling-form.tsx new file mode 100644 index 0000000..0bd8102 --- /dev/null +++ b/apps/webui/src/components/modules/leveling-form.tsx @@ -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 { + 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 { + return Object.entries(map) + .map(([id, multiplier]) => `${id}:${multiplier}`) + .join('\n'); +} + +function fromMultiplierText(text: string): { value: Record; error?: string } { + const value: Record = {}; + 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 { + 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 ( + + initialValue={toLocal(initialValue)} + onSave={(value) => saveLeveling(guildId, value)} + > + {({ value, setValue }) => ( +
+ + + {t('modulePages.leveling.title')} + {t('modulePages.leveling.description')} + + +
+ + setValue((prev) => ({ ...prev, enabled: checked }))} + /> +
+ +
+
+ + setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))} + /> +
+
+ + setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))} + /> +
+
+ + + setValue((prev) => ({ ...prev, textCooldownSeconds: Number(event.target.value) || 1 })) + } + /> +
+
+ +
+ + + setValue((prev) => ({ ...prev, voiceXpPerMinute: Number(event.target.value) || 0 })) + } + /> +
+ +
+ +