diff --git a/apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts b/apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts new file mode 100644 index 0000000..c4fdeaf --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts @@ -0,0 +1,46 @@ +import { BirthdayConfigDashboardPatchSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { getBirthdayDashboard, updateBirthdayDashboard } from '@/lib/module-configs/birthdays'; +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 getBirthdayDashboard(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 = BirthdayConfigDashboardPatchSchema.parse(body); + + const before = await getBirthdayDashboard(guildId); + const after = await updateBirthdayDashboard(guildId, patch); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'birthdays.update', + path: `/dashboard/${guildId}/birthdays`, + before, + after + }); + + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts new file mode 100644 index 0000000..300ed0e --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts @@ -0,0 +1,44 @@ +import { GiveawayActionSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { endGiveawayDashboard, setGiveawayPaused } from '@/lib/module-configs/giveaways'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; giveawayId: string }>; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId, giveawayId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const action = GiveawayActionSchema.parse(body.action); + + let giveaway; + if (action === 'end') { + giveaway = await endGiveawayDashboard(guildId, giveawayId); + } else if (action === 'pause') { + giveaway = await setGiveawayPaused(guildId, giveawayId, true); + } else { + giveaway = await setGiveawayPaused(guildId, giveawayId, false); + } + + if (!giveaway) { + return NextResponse.json({ error: 'Giveaway not found or already ended' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: `giveaways.${action}`, + path: `/dashboard/${guildId}/giveaways`, + after: giveaway + }); + + return NextResponse.json(giveaway); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/route.ts new file mode 100644 index 0000000..c4e552f --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/route.ts @@ -0,0 +1,32 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { deleteGiveawayDashboard } from '@/lib/module-configs/giveaways'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; giveawayId: string }>; +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const { guildId, giveawayId } = await params; + try { + const session = await requireGuildAccess(guildId); + const deleted = await deleteGiveawayDashboard(guildId, giveawayId); + if (!deleted) { + return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'giveaways.delete', + path: `/dashboard/${guildId}/giveaways`, + before: { giveawayId } + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts b/apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts new file mode 100644 index 0000000..a767c32 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts @@ -0,0 +1,47 @@ +import { GiveawayCreateDashboardSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { createGiveawayDashboard, GiveawayCreateTimeoutError, listGiveaways } from '@/lib/module-configs/giveaways'; +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 giveaways = await listGiveaways(guildId); + return NextResponse.json({ giveaways }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = GiveawayCreateDashboardSchema.parse(body); + + const giveaway = await createGiveawayDashboard(guildId, session.user.id, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'giveaways.create', + path: `/dashboard/${guildId}/giveaways`, + after: giveaway + }); + + return NextResponse.json(giveaway, { status: 201 }); + } catch (error) { + if (error instanceof GiveawayCreateTimeoutError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts new file mode 100644 index 0000000..7d7fc52 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts @@ -0,0 +1,59 @@ +import { SelfRolePanelDashboardUpdateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { deleteSelfRolePanel, updateSelfRolePanel } from '@/lib/module-configs/selfroles'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; panelId: string }>; +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId, panelId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const patch = SelfRolePanelDashboardUpdateSchema.parse(body); + + const updated = await updateSelfRolePanel(guildId, panelId, patch); + if (!updated) { + return NextResponse.json({ error: 'Panel not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'selfroles.update', + path: `/dashboard/${guildId}/selfroles`, + after: updated + }); + + return NextResponse.json(updated); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const { guildId, panelId } = await params; + try { + const session = await requireGuildAccess(guildId); + const deleted = await deleteSelfRolePanel(guildId, panelId); + if (!deleted) { + return NextResponse.json({ error: 'Panel not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'selfroles.delete', + path: `/dashboard/${guildId}/selfroles`, + before: { panelId } + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts new file mode 100644 index 0000000..62fdc23 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts @@ -0,0 +1,44 @@ +import { SelfRolePanelDashboardCreateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { createSelfRolePanel, listSelfRolePanels } from '@/lib/module-configs/selfroles'; +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 panels = await listSelfRolePanels(guildId); + return NextResponse.json({ panels }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = SelfRolePanelDashboardCreateSchema.parse(body); + + const panel = await createSelfRolePanel(guildId, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'selfroles.create', + path: `/dashboard/${guildId}/selfroles`, + after: panel + }); + + return NextResponse.json(panel, { status: 201 }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts b/apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts new file mode 100644 index 0000000..3fa890a --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts @@ -0,0 +1,46 @@ +import { StarboardConfigDashboardPatchSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { getStarboardDashboard, updateStarboardDashboard } from '@/lib/module-configs/starboard'; +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 getStarboardDashboard(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 = StarboardConfigDashboardPatchSchema.parse(body); + + const before = await getStarboardDashboard(guildId); + const after = await updateStarboardDashboard(guildId, patch); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'starboard.update', + path: `/dashboard/${guildId}/starboard`, + before, + after + }); + + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tags/[tagId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tags/[tagId]/route.ts new file mode 100644 index 0000000..3872d57 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tags/[tagId]/route.ts @@ -0,0 +1,62 @@ +import { TagDashboardUpdateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { deleteTag, TagConflictError, updateTag } from '@/lib/module-configs/tags'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; tagId: string }>; +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId, tagId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const patch = TagDashboardUpdateSchema.parse(body); + + const updated = await updateTag(guildId, tagId, patch); + if (!updated) { + return NextResponse.json({ error: 'Tag not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tags.update', + path: `/dashboard/${guildId}/tags`, + after: updated + }); + + return NextResponse.json(updated); + } catch (error) { + if (error instanceof TagConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return toApiErrorResponse(error); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const { guildId, tagId } = await params; + try { + const session = await requireGuildAccess(guildId); + const deleted = await deleteTag(guildId, tagId); + if (!deleted) { + return NextResponse.json({ error: 'Tag not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tags.delete', + path: `/dashboard/${guildId}/tags`, + before: { tagId } + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts new file mode 100644 index 0000000..d7b2dcf --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts @@ -0,0 +1,47 @@ +import { TagDashboardCreateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { createTag, listTags, TagConflictError } from '@/lib/module-configs/tags'; +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 tags = await listTags(guildId); + return NextResponse.json({ tags }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = TagDashboardCreateSchema.parse(body); + + const tag = await createTag(guildId, session.user.id, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tags.create', + path: `/dashboard/${guildId}/tags`, + after: tag + }); + + return NextResponse.json(tag, { status: 201 }); + } catch (error) { + if (error instanceof TagConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts new file mode 100644 index 0000000..24d2ce0 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts @@ -0,0 +1,46 @@ +import { TempVoiceConfigDashboardPatchSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { getTempVoiceDashboard, updateTempVoiceDashboard } from '@/lib/module-configs/tempvoice'; +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 getTempVoiceDashboard(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 = TempVoiceConfigDashboardPatchSchema.parse(body); + + const before = await getTempVoiceDashboard(guildId); + const after = await updateTempVoiceDashboard(guildId, patch); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tempvoice.update', + path: `/dashboard/${guildId}/tempvoice`, + before, + after + }); + + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts new file mode 100644 index 0000000..059842a --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts @@ -0,0 +1,66 @@ +import { TicketCategoryDashboardUpdateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { + deleteTicketCategory, + TicketCategoryConflictError, + updateTicketCategory +} from '@/lib/module-configs/tickets'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; categoryId: string }>; +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId, categoryId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const patch = TicketCategoryDashboardUpdateSchema.parse(body); + + const updated = await updateTicketCategory(guildId, categoryId, patch); + if (!updated) { + return NextResponse.json({ error: 'Category not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tickets.category.update', + path: `/dashboard/${guildId}/tickets`, + after: updated + }); + + return NextResponse.json(updated); + } catch (error) { + if (error instanceof TicketCategoryConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return toApiErrorResponse(error); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const { guildId, categoryId } = await params; + try { + const session = await requireGuildAccess(guildId); + const deleted = await deleteTicketCategory(guildId, categoryId); + if (!deleted) { + return NextResponse.json({ error: 'Category not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tickets.category.delete', + path: `/dashboard/${guildId}/tickets`, + before: { categoryId } + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts new file mode 100644 index 0000000..97d44f3 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts @@ -0,0 +1,47 @@ +import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { createTicketCategory, listTicketCategories, TicketCategoryConflictError } from '@/lib/module-configs/tickets'; +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 categories = await listTicketCategories(guildId); + return NextResponse.json({ categories }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = TicketCategoryDashboardCreateSchema.parse(body); + + const category = await createTicketCategory(guildId, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tickets.category.create', + path: `/dashboard/${guildId}/tickets`, + after: category + }); + + return NextResponse.json(category, { status: 201 }); + } catch (error) { + if (error instanceof TicketCategoryConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts new file mode 100644 index 0000000..9c6a074 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts @@ -0,0 +1,46 @@ +import { TicketConfigDashboardPatchSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { getTicketConfigDashboard, updateTicketConfigDashboard } from '@/lib/module-configs/tickets'; +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 getTicketConfigDashboard(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 = TicketConfigDashboardPatchSchema.parse(body); + + const before = await getTicketConfigDashboard(guildId); + const after = await updateTicketConfigDashboard(guildId, patch); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'tickets.update', + path: `/dashboard/${guildId}/tickets`, + before, + after + }); + + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx new file mode 100644 index 0000000..00b1ad3 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function GiveawaysLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx b/apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx new file mode 100644 index 0000000..bfda909 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx @@ -0,0 +1,22 @@ +import { GiveawaysManager } from '@/components/modules/giveaways-manager'; +import { getLocale, t } from '@/lib/i18n'; +import { listGiveaways } from '@/lib/module-configs/giveaways'; + +interface GiveawaysPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function GiveawaysPage({ params }: GiveawaysPageProps) { + const { guildId } = await params; + const [locale, giveaways] = await Promise.all([getLocale(), listGiveaways(guildId)]); + + return ( +
+
+

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

+

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

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx new file mode 100644 index 0000000..66431d8 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function SelfRolesLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx b/apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx new file mode 100644 index 0000000..424e7da --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx @@ -0,0 +1,22 @@ +import { SelfRolesManager } from '@/components/modules/selfroles-manager'; +import { getLocale, t } from '@/lib/i18n'; +import { listSelfRolePanels } from '@/lib/module-configs/selfroles'; + +interface SelfRolesPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function SelfRolesPage({ params }: SelfRolesPageProps) { + const { guildId } = await params; + const [locale, panels] = await Promise.all([getLocale(), listSelfRolePanels(guildId)]); + + return ( +
+
+

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

+

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

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx new file mode 100644 index 0000000..8662b3f --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function TagsLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/tags/page.tsx b/apps/webui/src/app/dashboard/[guildId]/tags/page.tsx new file mode 100644 index 0000000..1341970 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/tags/page.tsx @@ -0,0 +1,22 @@ +import { TagsManager } from '@/components/modules/tags-manager'; +import { getLocale, t } from '@/lib/i18n'; +import { listTags } from '@/lib/module-configs/tags'; + +interface TagsPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function TagsPage({ params }: TagsPageProps) { + const { guildId } = await params; + const [locale, tags] = await Promise.all([getLocale(), listTags(guildId)]); + + return ( +
+
+

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

+

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

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx new file mode 100644 index 0000000..8025a52 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function TicketsLoading() { + return ( +
+
+ + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx new file mode 100644 index 0000000..ef785c1 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx @@ -0,0 +1,28 @@ +import { TicketCategoriesManager } from '@/components/modules/ticket-categories-manager'; +import { TicketConfigForm } from '@/components/modules/ticket-config-form'; +import { getLocale, t } from '@/lib/i18n'; +import { getTicketConfigDashboard, listTicketCategories } from '@/lib/module-configs/tickets'; + +interface TicketsPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function TicketsPage({ params }: TicketsPageProps) { + const { guildId } = await params; + const [locale, config, categories] = await Promise.all([ + getLocale(), + getTicketConfigDashboard(guildId), + listTicketCategories(guildId) + ]); + + return ( +
+
+

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

+

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

+
+ + +
+ ); +} diff --git a/apps/webui/src/components/modules/giveaways-manager.tsx b/apps/webui/src/components/modules/giveaways-manager.tsx new file mode 100644 index 0000000..f650d3a --- /dev/null +++ b/apps/webui/src/components/modules/giveaways-manager.tsx @@ -0,0 +1,286 @@ +'use client'; + +import type { GiveawayDashboard } from '@nexumi/shared'; +import { Pause, Play, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslations } from '@/components/locale-provider'; +import { Badge } from '@/components/ui/badge'; +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'; + +const EMPTY_DRAFT = { + channelId: '', + prize: '', + winnerCount: '1', + endsAt: '', + requiredRoleId: '', + requiredLevel: '', + requiredMemberDays: '' +}; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +interface GiveawaysManagerProps { + guildId: string; + initialGiveaways: GiveawayDashboard[]; +} + +export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManagerProps) { + const t = useTranslations(); + const [giveaways, setGiveaways] = useState(initialGiveaways); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [creating, setCreating] = useState(false); + const [pendingId, setPendingId] = useState(null); + + async function handleCreate() { + if (!draft.channelId.trim() || !draft.prize.trim() || !draft.endsAt) { + toast.error(t('modulePages.giveaways.formIncomplete')); + return; + } + setCreating(true); + try { + const response = await fetch(`/api/guilds/${guildId}/giveaways`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channelId: draft.channelId.trim(), + prize: draft.prize.trim(), + winnerCount: Number(draft.winnerCount) || 1, + endsAt: new Date(draft.endsAt).toISOString(), + requiredRoleId: draft.requiredRoleId.trim() || undefined, + requiredLevel: draft.requiredLevel.trim() ? Number(draft.requiredLevel) : undefined, + requiredMemberDays: draft.requiredMemberDays.trim() ? Number(draft.requiredMemberDays) : undefined + }) + }); + const body = (await response.json().catch(() => null)) as (GiveawayDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setGiveaways((prev) => [body, ...prev]); + setDraft(EMPTY_DRAFT); + toast.success(t('modulePages.giveaways.created')); + } catch { + toast.error(t('common.saveError')); + } finally { + setCreating(false); + } + } + + async function handleAction(giveaway: GiveawayDashboard, action: 'end' | 'pause' | 'unpause') { + setPendingId(giveaway.id); + try { + const response = await fetch(`/api/guilds/${guildId}/giveaways/${giveaway.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + const body = (await response.json().catch(() => null)) as (GiveawayDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setGiveaways((prev) => prev.map((entry) => (entry.id === giveaway.id ? body : entry))); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setPendingId(null); + } + } + + async function handleDelete(giveaway: GiveawayDashboard) { + if (!window.confirm(t('modulePages.giveaways.confirmDelete'))) { + return; + } + setPendingId(giveaway.id); + try { + const response = await fetch(`/api/guilds/${guildId}/giveaways/${giveaway.id}`, { method: 'DELETE' }); + if (!response.ok) { + toast.error(t('common.saveError')); + return; + } + setGiveaways((prev) => prev.filter((entry) => entry.id !== giveaway.id)); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setPendingId(null); + } + } + + return ( +
+ + + {t('modulePages.giveaways.createTitle')} + {t('modulePages.giveaways.createDescription')} + + +
+
+ + setDraft((prev) => ({ ...prev, channelId: event.target.value }))} + placeholder="123456789012345678" + /> +
+
+ + setDraft((prev) => ({ ...prev, prize: event.target.value }))} /> +
+
+ + setDraft((prev) => ({ ...prev, winnerCount: event.target.value }))} + /> +
+
+ + setDraft((prev) => ({ ...prev, endsAt: event.target.value }))} + /> +
+
+ + setDraft((prev) => ({ ...prev, requiredRoleId: event.target.value }))} + placeholder={t('common.optional')} + /> +
+
+ + setDraft((prev) => ({ ...prev, requiredLevel: event.target.value }))} + placeholder={t('common.optional')} + /> +
+
+ + setDraft((prev) => ({ ...prev, requiredMemberDays: event.target.value }))} + placeholder={t('common.optional')} + /> +
+
+ +
+
+ + + + {t('modulePages.giveaways.listTitle')} + {t('modulePages.giveaways.listDescription')} + + + {giveaways.length === 0 && ( +

{t('modulePages.giveaways.empty')}

+ )} + {giveaways.map((giveaway) => { + const ended = Boolean(giveaway.endedAt); + return ( +
+
+
+

{giveaway.prize}

+

+ #{giveaway.channelId} · {t('modulePages.giveaways.winnerCount')}: {giveaway.winnerCount} ·{' '} + {t('modulePages.giveaways.entrants')}: {giveaway.entrants.length} +

+
+
+ {ended ? ( + {t('modulePages.giveaways.statusEnded')} + ) : giveaway.paused ? ( + {t('modulePages.giveaways.statusPaused')} + ) : ( + {t('modulePages.giveaways.statusActive')} + )} +
+
+

+ {ended + ? `${t('modulePages.giveaways.endedAt')}: ${formatDate(giveaway.endedAt!)}` + : `${t('modulePages.giveaways.endsAt')}: ${formatDate(giveaway.endsAt)}`} +

+ {ended && giveaway.winners.length > 0 && ( +

+ {t('modulePages.giveaways.winners')}: {giveaway.winners.map((id) => `<@${id}>`).join(', ')} +

+ )} + {!ended && ( +
+ + + +
+ )} + {ended && ( +
+ +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/webui/src/components/modules/selfroles-manager.tsx b/apps/webui/src/components/modules/selfroles-manager.tsx new file mode 100644 index 0000000..9fd5d53 --- /dev/null +++ b/apps/webui/src/components/modules/selfroles-manager.tsx @@ -0,0 +1,318 @@ +'use client'; + +import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared'; +import { Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +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 { Textarea } from '@/components/ui/textarea'; + +const MODES: SelfRoleMode[] = ['BUTTONS', 'DROPDOWN', 'REACTIONS']; +const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY']; + +function rolesToText(roles: SelfRoleEntry[]): string { + return roles.map((role) => [role.roleId, role.label, role.emoji ?? '', role.description ?? ''].join(' | ')).join('\n'); +} + +function textToRoles(text: string): SelfRoleEntry[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [roleId, label, emoji, description] = line.split('|').map((part) => part.trim()); + return { + roleId: roleId ?? '', + label: label || roleId || '', + emoji: emoji || undefined, + description: description || undefined + }; + }) + .filter((role) => role.roleId.length > 0); +} + +interface LocalPanel extends SelfRolePanelDashboard { + clientKey: string; + rolesText: string; + saving?: boolean; +} + +const EMPTY_DRAFT = { + channelId: '', + title: '', + description: '', + mode: 'BUTTONS' as SelfRoleMode, + behavior: 'NORMAL' as SelfRoleBehavior, + rolesText: '' +}; + +interface SelfRolesManagerProps { + guildId: string; + initialPanels: SelfRolePanelDashboard[]; +} + +export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerProps) { + const t = useTranslations(); + const [panels, setPanels] = useState( + initialPanels.map((panel, index) => ({ + ...panel, + clientKey: panel.id ?? `existing-${index}`, + rolesText: rolesToText(panel.roles) + })) + ); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [creating, setCreating] = useState(false); + + async function handleCreate() { + const roles = textToRoles(draft.rolesText); + if (!draft.channelId.trim() || !draft.title.trim() || roles.length === 0) { + toast.error(t('modulePages.selfroles.formIncomplete')); + return; + } + setCreating(true); + try { + const response = await fetch(`/api/guilds/${guildId}/selfroles`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channelId: draft.channelId.trim(), + title: draft.title.trim(), + description: draft.description.trim() || null, + mode: draft.mode, + behavior: draft.behavior, + roles + }) + }); + const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setPanels((prev) => [ + { ...body, clientKey: body.id ?? `new-${Date.now()}`, rolesText: rolesToText(body.roles) }, + ...prev + ]); + setDraft(EMPTY_DRAFT); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setCreating(false); + } + } + + async function handleSave(panel: LocalPanel) { + setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: true } : entry))); + try { + const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channelId: panel.channelId, + title: panel.title, + description: panel.description, + mode: panel.mode, + behavior: panel.behavior, + roles: textToRoles(panel.rolesText) + }) + }); + const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry))); + } + } + + async function handleDelete(panel: LocalPanel) { + if (!window.confirm(t('modulePages.selfroles.confirmDelete'))) { + return; + } + try { + const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, { method: 'DELETE' }); + if (!response.ok) { + toast.error(t('common.saveError')); + return; + } + setPanels((prev) => prev.filter((entry) => entry.clientKey !== panel.clientKey)); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } + } + + return ( +
+ + + {t('modulePages.selfroles.createTitle')} + {t('modulePages.selfroles.createDescription')} + + +
+
+ + setDraft((prev) => ({ ...prev, channelId: event.target.value }))} placeholder="123456789012345678" /> +
+
+ + setDraft((prev) => ({ ...prev, title: event.target.value }))} /> +
+
+ + +
+
+ + +
+
+
+ + setDraft((prev) => ({ ...prev, description: event.target.value }))} /> +
+
+ +