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:
14
apps/webui/src/app/api/public/stats/route.ts
Normal file
14
apps/webui/src/app/api/public/stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
apps/webui/src/app/api/public/status/route.ts
Normal file
14
apps/webui/src/app/api/public/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
apps/webui/src/app/impressum/page.tsx
Normal file
46
apps/webui/src/app/impressum/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
17
apps/webui/src/app/privacy/page.tsx
Normal file
17
apps/webui/src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/webui/src/app/status/page.tsx
Normal file
120
apps/webui/src/app/status/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/webui/src/app/terms/page.tsx
Normal file
17
apps/webui/src/app/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user