diff --git a/apps/webui/src/app/api/guilds/[guildId]/stats/route.ts b/apps/webui/src/app/api/guilds/[guildId]/stats/route.ts
new file mode 100644
index 0000000..1aac9fb
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/stats/route.ts
@@ -0,0 +1,46 @@
+import { StatsConfigDashboardPatchSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getStatsDashboard, updateStatsDashboard } from '@/lib/module-configs/stats';
+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 getStatsDashboard(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 = StatsConfigDashboardPatchSchema.parse(body);
+
+ const before = await getStatsDashboard(guildId);
+ const after = await updateStatsDashboard(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'stats.update',
+ path: `/dashboard/${guildId}/stats`,
+ before,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/[suggestionId]/action/route.ts b/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/[suggestionId]/action/route.ts
new file mode 100644
index 0000000..63cbcff
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/[suggestionId]/action/route.ts
@@ -0,0 +1,34 @@
+import { SuggestionActionSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { applySuggestionAction } from '@/lib/module-configs/suggestions';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string; suggestionId: string }>;
+}
+
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const { guildId, suggestionId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const action = SuggestionActionSchema.parse(body);
+
+ const after = await applySuggestionAction(guildId, suggestionId, action, session.user.id);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: `suggestions.${action.status}`,
+ path: `/dashboard/${guildId}/suggestions`,
+ before: null,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/route.ts b/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/route.ts
new file mode 100644
index 0000000..7f351f1
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/suggestions/items/route.ts
@@ -0,0 +1,24 @@
+import { SuggestionListQuerySchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { listSuggestions } from '@/lib/module-configs/suggestions';
+
+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 = SuggestionListQuerySchema.parse({
+ status: searchParams.get('status') ?? undefined,
+ limit: searchParams.get('limit') ?? undefined
+ });
+ const suggestions = await listSuggestions(guildId, query);
+ return NextResponse.json({ suggestions });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/suggestions/route.ts b/apps/webui/src/app/api/guilds/[guildId]/suggestions/route.ts
new file mode 100644
index 0000000..302aac2
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/suggestions/route.ts
@@ -0,0 +1,46 @@
+import { SuggestionConfigDashboardPatchSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getSuggestionConfigDashboard, updateSuggestionConfigDashboard } from '@/lib/module-configs/suggestions';
+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 getSuggestionConfigDashboard(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 = SuggestionConfigDashboardPatchSchema.parse(body);
+
+ const before = await getSuggestionConfigDashboard(guildId);
+ const after = await updateSuggestionConfigDashboard(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'suggestions.update',
+ path: `/dashboard/${guildId}/suggestions`,
+ before,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/birthdays/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/birthdays/loading.tsx
new file mode 100644
index 0000000..b6eda57
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/birthdays/loading.tsx
@@ -0,0 +1,13 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function BirthdaysLoading() {
+ return (
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/birthdays/page.tsx b/apps/webui/src/app/dashboard/[guildId]/birthdays/page.tsx
new file mode 100644
index 0000000..f3803c5
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/birthdays/page.tsx
@@ -0,0 +1,22 @@
+import { BirthdaysForm } from '@/components/modules/birthdays-form';
+import { getLocale, t } from '@/lib/i18n';
+import { getBirthdayDashboard } from '@/lib/module-configs/birthdays';
+
+interface BirthdaysPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function BirthdaysPage({ params }: BirthdaysPageProps) {
+ const { guildId } = await params;
+ const [locale, config] = await Promise.all([getLocale(), getBirthdayDashboard(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modules.birthdays.label')}
+
{t(locale, 'modules.birthdays.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/starboard/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/starboard/loading.tsx
new file mode 100644
index 0000000..23df99d
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/starboard/loading.tsx
@@ -0,0 +1,13 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function StarboardLoading() {
+ return (
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/starboard/page.tsx b/apps/webui/src/app/dashboard/[guildId]/starboard/page.tsx
new file mode 100644
index 0000000..14c2f53
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/starboard/page.tsx
@@ -0,0 +1,22 @@
+import { StarboardForm } from '@/components/modules/starboard-form';
+import { getLocale, t } from '@/lib/i18n';
+import { getStarboardDashboard } from '@/lib/module-configs/starboard';
+
+interface StarboardPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function StarboardPage({ params }: StarboardPageProps) {
+ const { guildId } = await params;
+ const [locale, config] = await Promise.all([getLocale(), getStarboardDashboard(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modules.starboard.label')}
+
{t(locale, 'modules.starboard.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/stats/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/stats/loading.tsx
new file mode 100644
index 0000000..1d411a4
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/stats/loading.tsx
@@ -0,0 +1,13 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function StatsLoading() {
+ return (
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/stats/page.tsx b/apps/webui/src/app/dashboard/[guildId]/stats/page.tsx
new file mode 100644
index 0000000..8fd6afa
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/stats/page.tsx
@@ -0,0 +1,22 @@
+import { StatsForm } from '@/components/modules/stats-form';
+import { getLocale, t } from '@/lib/i18n';
+import { getStatsDashboard } from '@/lib/module-configs/stats';
+
+interface StatsPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function StatsPage({ params }: StatsPageProps) {
+ const { guildId } = await params;
+ const [locale, config] = await Promise.all([getLocale(), getStatsDashboard(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modules.stats.label')}
+
{t(locale, 'modules.stats.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/tempvoice/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/tempvoice/loading.tsx
new file mode 100644
index 0000000..5a30f8f
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/tempvoice/loading.tsx
@@ -0,0 +1,13 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function TempVoiceLoading() {
+ return (
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/tempvoice/page.tsx b/apps/webui/src/app/dashboard/[guildId]/tempvoice/page.tsx
new file mode 100644
index 0000000..101d934
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/tempvoice/page.tsx
@@ -0,0 +1,22 @@
+import { TempVoiceForm } from '@/components/modules/tempvoice-form';
+import { getLocale, t } from '@/lib/i18n';
+import { getTempVoiceDashboard } from '@/lib/module-configs/tempvoice';
+
+interface TempVoicePageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function TempVoicePage({ params }: TempVoicePageProps) {
+ const { guildId } = await params;
+ const [locale, config] = await Promise.all([getLocale(), getTempVoiceDashboard(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modules.tempvoice.label')}
+
{t(locale, 'modules.tempvoice.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/components/modules/birthdays-form.tsx b/apps/webui/src/components/modules/birthdays-form.tsx
new file mode 100644
index 0000000..363beb0
--- /dev/null
+++ b/apps/webui/src/components/modules/birthdays-form.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import type { BirthdayConfigDashboard } 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 { 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';
+
+async function saveBirthdays(guildId: string, value: BirthdayConfigDashboard): Promise {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/birthdays`, {
+ 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 BirthdaysFormProps {
+ guildId: string;
+ initialValue: BirthdayConfigDashboard;
+}
+
+export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
+ const t = useTranslations();
+ const channelId = useId();
+ const roleId = useId();
+ const timezoneId = useId();
+
+ return (
+ initialValue={initialValue} onSave={(value) => saveBirthdays(guildId, value)}>
+ {({ value, setValue }) => (
+
+
+ {t('modulePages.birthdays.title')}
+ {t('modulePages.birthdays.description')}
+
+
+
+
+ setValue((prev) => ({ ...prev, enabled }))} />
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/webui/src/components/modules/starboard-form.tsx b/apps/webui/src/components/modules/starboard-form.tsx
new file mode 100644
index 0000000..0ac7492
--- /dev/null
+++ b/apps/webui/src/components/modules/starboard-form.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import type { StarboardConfigDashboard } 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 { 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 LocalValue {
+ enabled: boolean;
+ channelId: string;
+ emoji: string;
+ threshold: number;
+ allowSelfStar: boolean;
+ ignoredChannelIdsText: string;
+}
+
+function toLocal(config: StarboardConfigDashboard): LocalValue {
+ return {
+ enabled: config.enabled,
+ channelId: config.channelId,
+ emoji: config.emoji,
+ threshold: config.threshold,
+ allowSelfStar: config.allowSelfStar,
+ ignoredChannelIdsText: config.ignoredChannelIds.join(', ')
+ };
+}
+
+function toRemote(value: LocalValue): StarboardConfigDashboard {
+ return {
+ enabled: value.enabled,
+ channelId: value.channelId,
+ emoji: value.emoji,
+ threshold: value.threshold,
+ allowSelfStar: value.allowSelfStar,
+ ignoredChannelIds: value.ignoredChannelIdsText
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ };
+}
+
+async function saveStarboard(guildId: string, value: LocalValue): Promise {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/starboard`, {
+ 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 StarboardFormProps {
+ guildId: string;
+ initialValue: StarboardConfigDashboard;
+}
+
+export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
+ const t = useTranslations();
+ const channelId = useId();
+ const emojiId = useId();
+ const thresholdId = useId();
+ const ignoredId = useId();
+
+ return (
+ initialValue={toLocal(initialValue)} onSave={(value) => saveStarboard(guildId, value)}>
+ {({ value, setValue }) => (
+
+
+ {t('modulePages.starboard.title')}
+ {t('modulePages.starboard.description')}
+
+
+
+
+ setValue((prev) => ({ ...prev, enabled }))} />
+
+
+
+
+ setValue((prev) => ({ ...prev, allowSelfStar }))} />
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/webui/src/components/modules/stats-form.tsx b/apps/webui/src/components/modules/stats-form.tsx
new file mode 100644
index 0000000..824496d
--- /dev/null
+++ b/apps/webui/src/components/modules/stats-form.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import type { StatsConfigDashboard } 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 { Switch } from '@/components/ui/switch';
+import type { SettingsSaveResult } from '@/components/settings/settings-form';
+import { SettingsForm } from '@/components/settings/settings-form';
+
+async function saveStats(guildId: string, value: StatsConfigDashboard): Promise {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/stats`, {
+ 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 StatsFormProps {
+ guildId: string;
+ initialValue: StatsConfigDashboard;
+}
+
+export function StatsForm({ guildId, initialValue }: StatsFormProps) {
+ const t = useTranslations();
+ const membersId = useId();
+ const onlineId = useId();
+ const boostsId = useId();
+ const membersTemplateId = useId();
+ const onlineTemplateId = useId();
+ const boostsTemplateId = useId();
+
+ return (
+ initialValue={initialValue} onSave={(value) => saveStats(guildId, value)}>
+ {({ value, setValue }) => (
+
+
+ {t('modulePages.stats.title')}
+ {t('modulePages.stats.description')}
+
+
+
+
+ setValue((prev) => ({ ...prev, enabled }))} />
+
+
+ {t('modulePages.stats.templateHint')}
+
+
+ )}
+
+ );
+}
diff --git a/apps/webui/src/components/modules/tempvoice-form.tsx b/apps/webui/src/components/modules/tempvoice-form.tsx
new file mode 100644
index 0000000..a335ec5
--- /dev/null
+++ b/apps/webui/src/components/modules/tempvoice-form.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import type { TempVoiceConfigDashboard } 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 { Switch } from '@/components/ui/switch';
+import type { SettingsSaveResult } from '@/components/settings/settings-form';
+import { SettingsForm } from '@/components/settings/settings-form';
+
+async function saveTempVoice(guildId: string, value: TempVoiceConfigDashboard): Promise {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tempvoice`, {
+ 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 TempVoiceFormProps {
+ guildId: string;
+ initialValue: TempVoiceConfigDashboard;
+}
+
+export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
+ const t = useTranslations();
+ const hubId = useId();
+ const categoryId = useId();
+ const nameId = useId();
+ const limitId = useId();
+
+ return (
+ initialValue={initialValue} onSave={(value) => saveTempVoice(guildId, value)}>
+ {({ value, setValue }) => (
+
+
+ {t('modulePages.tempvoice.title')}
+ {t('modulePages.tempvoice.description')}
+
+
+
+
+ setValue((prev) => ({ ...prev, enabled }))} />
+
+
+ {t('modulePages.tempvoice.defaultLimitHint')}
+
+
+ )}
+
+ );
+}