Refactor giveaway job processing and update environment documentation
- Enhanced the handling of the `giveawayCreate` job in the bot's job processing for improved consistency. - Clarified the usage of `BOT_TOKEN` in the `.env.example` file for both the bot and WebUI. - Integrated the `bullmq` dependency into the WebUI package to bolster job management capabilities.
This commit is contained in:
46
apps/webui/src/app/api/guilds/[guildId]/stats/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/stats/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/suggestions/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/suggestions/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/birthdays/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/birthdays/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function BirthdaysLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/birthdays/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/birthdays/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.birthdays.label')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.birthdays.description')}</p>
|
||||||
|
</div>
|
||||||
|
<BirthdaysForm guildId={guildId} initialValue={config} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/starboard/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/starboard/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function StarboardLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/starboard/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/starboard/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.starboard.label')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.starboard.description')}</p>
|
||||||
|
</div>
|
||||||
|
<StarboardForm guildId={guildId} initialValue={config} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/stats/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/stats/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function StatsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/stats/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/stats/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.stats.label')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.stats.description')}</p>
|
||||||
|
</div>
|
||||||
|
<StatsForm guildId={guildId} initialValue={config} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/tempvoice/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/tempvoice/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function TempVoiceLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/tempvoice/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/tempvoice/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.tempvoice.label')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.tempvoice.description')}</p>
|
||||||
|
</div>
|
||||||
|
<TempVoiceForm guildId={guildId} initialValue={config} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
apps/webui/src/components/modules/birthdays-form.tsx
Normal file
78
apps/webui/src/components/modules/birthdays-form.tsx
Normal file
@@ -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<SettingsSaveResult> {
|
||||||
|
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 (
|
||||||
|
<SettingsForm<BirthdayConfigDashboard> initialValue={initialValue} onSave={(value) => saveBirthdays(guildId, value)}>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.birthdays.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
|
||||||
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label>
|
||||||
|
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label>
|
||||||
|
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={timezoneId}>{t('modulePages.birthdays.timezone')}</Label>
|
||||||
|
<Input id={timezoneId} value={value.timezone} onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))} placeholder="Europe/Berlin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.birthdays.message')}</Label>
|
||||||
|
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
apps/webui/src/components/modules/starboard-form.tsx
Normal file
118
apps/webui/src/components/modules/starboard-form.tsx
Normal file
@@ -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<SettingsSaveResult> {
|
||||||
|
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 (
|
||||||
|
<SettingsForm<LocalValue> initialValue={toLocal(initialValue)} onSave={(value) => saveStarboard(guildId, value)}>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.starboard.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.starboard.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
|
||||||
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={channelId}>{t('modulePages.starboard.channelId')}</Label>
|
||||||
|
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
|
||||||
|
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
|
||||||
|
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
|
||||||
|
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={ignoredId}>{t('modulePages.starboard.ignoredChannelIds')}</Label>
|
||||||
|
<Textarea id={ignoredId} value={value.ignoredChannelIdsText} onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))} />
|
||||||
|
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.idsHint')}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/webui/src/components/modules/stats-form.tsx
Normal file
89
apps/webui/src/components/modules/stats-form.tsx
Normal file
@@ -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<SettingsSaveResult> {
|
||||||
|
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 (
|
||||||
|
<SettingsForm<StatsConfigDashboard> initialValue={initialValue} onSave={(value) => saveStats(guildId, value)}>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.stats.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.stats.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<Label>{t('modulePages.stats.enabledLabel')}</Label>
|
||||||
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={membersId}>{t('modulePages.stats.membersChannelId')}</Label>
|
||||||
|
<Input id={membersId} value={value.membersChannelId} onChange={(event) => setValue((prev) => ({ ...prev, membersChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={membersTemplateId}>{t('modulePages.stats.membersTemplate')}</Label>
|
||||||
|
<Input id={membersTemplateId} value={value.membersTemplate} onChange={(event) => setValue((prev) => ({ ...prev, membersTemplate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={onlineId}>{t('modulePages.stats.onlineChannelId')}</Label>
|
||||||
|
<Input id={onlineId} value={value.onlineChannelId} onChange={(event) => setValue((prev) => ({ ...prev, onlineChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={onlineTemplateId}>{t('modulePages.stats.onlineTemplate')}</Label>
|
||||||
|
<Input id={onlineTemplateId} value={value.onlineTemplate} onChange={(event) => setValue((prev) => ({ ...prev, onlineTemplate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={boostsId}>{t('modulePages.stats.boostsChannelId')}</Label>
|
||||||
|
<Input id={boostsId} value={value.boostsChannelId} onChange={(event) => setValue((prev) => ({ ...prev, boostsChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={boostsTemplateId}>{t('modulePages.stats.boostsTemplate')}</Label>
|
||||||
|
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('modulePages.stats.templateHint')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/webui/src/components/modules/tempvoice-form.tsx
Normal file
79
apps/webui/src/components/modules/tempvoice-form.tsx
Normal file
@@ -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<SettingsSaveResult> {
|
||||||
|
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 (
|
||||||
|
<SettingsForm<TempVoiceConfigDashboard> initialValue={initialValue} onSave={(value) => saveTempVoice(guildId, value)}>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.tempvoice.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.tempvoice.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<Label>{t('modulePages.tempvoice.enabledLabel')}</Label>
|
||||||
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={hubId}>{t('modulePages.tempvoice.hubChannelId')}</Label>
|
||||||
|
<Input id={hubId} value={value.hubChannelId} onChange={(event) => setValue((prev) => ({ ...prev, hubChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={categoryId}>{t('modulePages.tempvoice.categoryId')}</Label>
|
||||||
|
<Input id={categoryId} value={value.categoryId} onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={nameId}>{t('modulePages.tempvoice.defaultName')}</Label>
|
||||||
|
<Input id={nameId} value={value.defaultName} onChange={(event) => setValue((prev) => ({ ...prev, defaultName: event.target.value }))} placeholder="{user}'s Channel" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={limitId}>{t('modulePages.tempvoice.defaultLimit')}</Label>
|
||||||
|
<Input id={limitId} type="number" min={0} max={99} value={value.defaultLimit} onChange={(event) => setValue((prev) => ({ ...prev, defaultLimit: Number(event.target.value) || 0 }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('modulePages.tempvoice.defaultLimitHint')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user