Enhance bot and WebUI functionality with new features and environment updates

- Added support for new environment variables in `.env.example` for public site and legal operator information.
- Integrated core commands into the bot's command structure for improved functionality.
- Implemented a new `publishBotStatus` function to update bot status in Redis, enhancing monitoring capabilities.
- Updated the landing page to include features, invite links, and support server access, improving user experience.
- Enhanced localization with new keys for core commands and landing page elements in both English and German.
- Improved error handling and logging for bot presence updates and status publishing.
This commit is contained in:
smueller
2026-07-22 16:47:09 +02:00
parent 0b4ac0da29
commit 4852f16f79
32 changed files with 1579 additions and 83 deletions

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getPublicStats } from '@/lib/public-stats';
export async function GET() {
try {
const stats = await getPublicStats();
return NextResponse.json(stats, {
headers: { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60' }
});
} catch (error) {
console.error('[public/stats]', error);
return NextResponse.json({ error: 'Failed to load stats' }, { status: 500 });
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getPublicStatus } from '@/lib/public-status';
export async function GET() {
try {
const status = await getPublicStatus();
return NextResponse.json(status, {
headers: { 'Cache-Control': 'public, s-maxage=15, stale-while-revalidate=30' }
});
} catch (error) {
console.error('[public/status]', error);
return NextResponse.json({ error: 'Failed to load status' }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { LegalShell } from '@/components/site/legal-shell';
import { env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
export default async function ImpressumPage() {
const locale = await getLocale();
const configured = Boolean(env.LEGAL_OPERATOR_NAME && env.LEGAL_OPERATOR_ADDRESS && env.LEGAL_OPERATOR_EMAIL);
return (
<LegalShell title={t(locale, 'legal.impressum.title')}>
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.intro')}</p>
{configured ? (
<dl className="space-y-3 text-sm">
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.name')}</dt>
<dd className="text-muted-foreground">{env.LEGAL_OPERATOR_NAME}</dd>
</div>
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.address')}</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">{env.LEGAL_OPERATOR_ADDRESS}</dd>
</div>
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.email')}</dt>
<dd className="text-muted-foreground">
<a className="text-primary hover:underline" href={`mailto:${env.LEGAL_OPERATOR_EMAIL}`}>
{env.LEGAL_OPERATOR_EMAIL}
</a>
</dd>
</div>
{env.LEGAL_OPERATOR_PHONE ? (
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.phone')}</dt>
<dd className="text-muted-foreground">{env.LEGAL_OPERATOR_PHONE}</dd>
</div>
) : null}
</dl>
) : (
<p className="rounded-md border border-border bg-muted/40 px-4 py-3 text-sm">
{t(locale, 'legal.impressum.notConfigured')}
</p>
)}
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.p1')}</p>
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.p2')}</p>
</LegalShell>
);
}

View File

@@ -10,8 +10,8 @@ import './globals.css';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
export const metadata: Metadata = {
title: 'Nexumi Dashboard',
description: 'Manage your Nexumi Discord bot'
title: 'Nexumi — Discord Bot',
description: 'Self-hosted Discord bot with dashboard, moderation, and community modules'
};
export default async function RootLayout({ children }: { children: ReactNode }) {

View File

@@ -1,7 +1,111 @@
import { redirect } from 'next/navigation';
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
import Image from 'next/image';
import Link from 'next/link';
import { SiteShell } from '@/components/site/site-shell';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { buildBotInviteUrl, env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
import { getPublicStats } from '@/lib/public-stats';
import { getSession } from '@/lib/session';
export default async function RootPage() {
const session = await getSession();
redirect(session ? '/dashboard' : '/login');
const GROUP_ORDER: DashboardModuleGroup[] = [
'moderation',
'engagement',
'community',
'utility',
'integrations'
];
export default async function LandingPage() {
const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]);
const inviteUrl = buildBotInviteUrl();
const isLoggedIn = Boolean(session);
return (
<SiteShell>
<section className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col gap-8 px-6 py-16 sm:py-20">
<div className="flex items-center gap-3">
<Image src="/nexumi-logo.svg" alt="" width={48} height={48} priority />
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">Nexumi</h1>
</div>
<p className="max-w-2xl text-lg text-muted-foreground">{t(locale, 'landing.hero.subtitle')}</p>
<div className="flex flex-wrap gap-3">
<Button asChild size="lg">
<a href={inviteUrl} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.invite')}
</a>
</Button>
<Button asChild size="lg" variant="secondary">
<Link href={isLoggedIn ? '/dashboard' : '/login'}>
{isLoggedIn ? t(locale, 'landing.hero.dashboard') : t(locale, 'landing.hero.login')}
</Link>
</Button>
{env.SUPPORT_SERVER_URL ? (
<Button asChild size="lg" variant="outline">
<a href={env.SUPPORT_SERVER_URL} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.support')}
</a>
</Button>
) : null}
</div>
</div>
</section>
<section className="border-b border-border">
<div className="mx-auto grid w-full max-w-[1200px] gap-4 px-6 py-12 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.guilds')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">{stats.guildCount.toLocaleString(locale)}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.users')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">
{stats.approximateUserCount.toLocaleString(locale)}
</CardContent>
</Card>
</div>
</section>
<section>
<div className="mx-auto w-full max-w-[1200px] space-y-8 px-6 py-12">
<div>
<h2 className="text-2xl font-semibold">{t(locale, 'landing.features.title')}</h2>
<p className="mt-2 text-muted-foreground">{t(locale, 'landing.features.subtitle')}</p>
</div>
{GROUP_ORDER.map((group) => {
const modules = DASHBOARD_MODULES.filter((module) => module.group === group);
return (
<div key={group} className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t(locale, `moduleGroups.${group}`)}
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => (
<Card key={module.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t(locale, `modules.${module.id}.label`)}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{t(locale, `modules.${module.id}.description`)}
</CardContent>
</Card>
))}
</div>
</div>
);
})}
</div>
</section>
</SiteShell>
);
}

View File

@@ -0,0 +1,17 @@
import { LegalShell } from '@/components/site/legal-shell';
import { getLocale, t } from '@/lib/i18n';
export default async function PrivacyPage() {
const locale = await getLocale();
return (
<LegalShell title={t(locale, 'legal.privacy.title')}>
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
{['p1', 'p2', 'p3', 'p4', 'p5'].map((key) => (
<p key={key} className="text-sm text-muted-foreground">
{t(locale, `legal.privacy.${key}`)}
</p>
))}
</LegalShell>
);
}

View File

@@ -0,0 +1,120 @@
import { SiteShell } from '@/components/site/site-shell';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getLocale, t } from '@/lib/i18n';
import { getPublicStatus } from '@/lib/public-status';
function statusBadgeVariant(overall: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (overall === 'operational') {
return 'default';
}
if (overall === 'maintenance') {
return 'secondary';
}
return 'destructive';
}
export default async function StatusPage() {
const [locale, status] = await Promise.all([getLocale(), getPublicStatus()]);
return (
<SiteShell>
<div className="mx-auto w-full max-w-[1200px] space-y-8 px-6 py-12">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-semibold">{t(locale, 'status.title')}</h1>
<Badge variant={statusBadgeVariant(status.overall)}>
{t(locale, `status.overall.${status.overall}`)}
</Badge>
</div>
<p className="text-muted-foreground">{t(locale, 'status.subtitle')}</p>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">{t(locale, 'status.latency')}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{status.apiLatencyMs === null ? '—' : `${status.apiLatencyMs} ms`}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">{t(locale, 'status.guilds')}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">{status.guildCount}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">{t(locale, 'status.uptime')}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{status.uptimeSeconds === null
? '—'
: `${Math.floor(status.uptimeSeconds / 3600)}h ${Math.floor((status.uptimeSeconds % 3600) / 60)}m`}
</CardContent>
</Card>
</div>
<section className="space-y-3">
<h2 className="text-xl font-semibold">{t(locale, 'status.shardsTitle')}</h2>
{status.shards.length === 0 ? (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
{t(locale, 'status.shardsEmpty')}
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{status.shards.map((shard) => (
<Card key={shard.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">
{t(locale, 'status.shardLabel', { id: shard.id })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>
{t(locale, 'status.shardStatus')}: {shard.status}
</p>
<p>
{t(locale, 'status.guilds')}: {shard.guildCount}
</p>
<p>
{t(locale, 'status.latency')}: {shard.ping} ms
</p>
</CardContent>
</Card>
))}
</div>
)}
</section>
<section className="space-y-3">
<h2 className="text-xl font-semibold">{t(locale, 'status.updatesTitle')}</h2>
<Card>
<CardContent className="divide-y divide-border p-0">
{status.updates.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t(locale, 'status.updatesEmpty')}</p>
) : (
status.updates.map((entry) => (
<div key={entry.id} className="space-y-1 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">
{entry.version} {entry.title}
</p>
{entry.isIncident ? <Badge variant="destructive">{t(locale, 'status.incident')}</Badge> : null}
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{entry.body}</p>
<p className="text-xs text-muted-foreground">
{new Date(entry.publishedAt).toLocaleString(locale)}
</p>
</div>
))
)}
</CardContent>
</Card>
</section>
</div>
</SiteShell>
);
}

View File

@@ -0,0 +1,17 @@
import { LegalShell } from '@/components/site/legal-shell';
import { getLocale, t } from '@/lib/i18n';
export default async function TermsPage() {
const locale = await getLocale();
return (
<LegalShell title={t(locale, 'legal.terms.title')}>
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
{['p1', 'p2', 'p3', 'p4'].map((key) => (
<p key={key} className="text-sm text-muted-foreground">
{t(locale, `legal.terms.${key}`)}
</p>
))}
</LegalShell>
);
}