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>
|
||||
);
|
||||
}
|
||||
39
apps/webui/src/components/site/legal-shell.tsx
Normal file
39
apps/webui/src/components/site/legal-shell.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { SiteShell } from '@/components/site/site-shell';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
|
||||
export async function LegalShell({
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<div className="mx-auto grid w-full max-w-[1200px] gap-8 px-6 py-12 lg:grid-cols-[220px_1fr]">
|
||||
<aside className="space-y-2 text-sm">
|
||||
<p className="px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(locale, 'legal.navTitle')}
|
||||
</p>
|
||||
<Link href="/impressum" className="block rounded-md px-2 py-1.5 hover:bg-accent">
|
||||
{t(locale, 'legal.impressum.title')}
|
||||
</Link>
|
||||
<Link href="/privacy" className="block rounded-md px-2 py-1.5 hover:bg-accent">
|
||||
{t(locale, 'legal.privacy.title')}
|
||||
</Link>
|
||||
<Link href="/terms" className="block rounded-md px-2 py-1.5 hover:bg-accent">
|
||||
{t(locale, 'legal.terms.title')}
|
||||
</Link>
|
||||
</aside>
|
||||
<article className="space-y-6">
|
||||
<h1 className="text-3xl font-semibold">{title}</h1>
|
||||
{children}
|
||||
</article>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
31
apps/webui/src/components/site/site-footer.tsx
Normal file
31
apps/webui/src/components/site/site-footer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import Link from 'next/link';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
|
||||
export async function SiteFooter() {
|
||||
const locale = await getLocale();
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border">
|
||||
<div className="mx-auto flex w-full max-w-[1200px] flex-col gap-4 px-6 py-8 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>© {new Date().getFullYear()} Nexumi</p>
|
||||
<nav className="flex flex-wrap gap-4">
|
||||
<Link href="/status" className="hover:text-foreground">
|
||||
{t(locale, 'site.footer.status')}
|
||||
</Link>
|
||||
<Link href="/impressum" className="hover:text-foreground">
|
||||
{t(locale, 'site.footer.impressum')}
|
||||
</Link>
|
||||
<Link href="/privacy" className="hover:text-foreground">
|
||||
{t(locale, 'site.footer.privacy')}
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-foreground">
|
||||
{t(locale, 'site.footer.terms')}
|
||||
</Link>
|
||||
<Link href="/dashboard" className="hover:text-foreground">
|
||||
{t(locale, 'site.footer.dashboard')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
62
apps/webui/src/components/site/site-header.tsx
Normal file
62
apps/webui/src/components/site/site-header.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function SiteHeader({
|
||||
inviteUrl,
|
||||
supportUrl,
|
||||
isLoggedIn
|
||||
}: {
|
||||
inviteUrl: string;
|
||||
supportUrl?: string;
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto flex h-14 w-full max-w-[1200px] items-center justify-between px-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||
<Image src="/nexumi-logo.svg" alt="Nexumi" width={28} height={28} priority />
|
||||
Nexumi
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/status">{t('site.nav.status')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href={inviteUrl} target="_blank" rel="noreferrer">
|
||||
{t('site.nav.invite')}
|
||||
</a>
|
||||
</Button>
|
||||
{supportUrl ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href={supportUrl} target="_blank" rel="noreferrer">
|
||||
{t('site.nav.support')}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild size="sm">
|
||||
<Link href={isLoggedIn ? '/dashboard' : '/login'}>
|
||||
{isLoggedIn ? t('site.nav.dashboard') : t('site.nav.login')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('userMenu.theme')}
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
21
apps/webui/src/components/site/site-shell.tsx
Normal file
21
apps/webui/src/components/site/site-shell.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { SiteFooter } from '@/components/site/site-footer';
|
||||
import { SiteHeader } from '@/components/site/site-header';
|
||||
import { buildBotInviteUrl, env } from '@/lib/env';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function SiteShell({ children }: { children: ReactNode }) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||
<SiteHeader
|
||||
inviteUrl={buildBotInviteUrl()}
|
||||
supportUrl={env.SUPPORT_SERVER_URL}
|
||||
isLoggedIn={Boolean(session)}
|
||||
/>
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,28 +3,20 @@ import { z } from 'zod';
|
||||
|
||||
config();
|
||||
|
||||
const emptyToUndefined = (value: unknown) =>
|
||||
value === '' || value === undefined || value === null ? undefined : value;
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url(),
|
||||
BOT_CLIENT_ID: z.string().min(1),
|
||||
BOT_CLIENT_SECRET: z.string().min(1),
|
||||
/**
|
||||
* Discord bot token. The WebUI does not run a discord.js Client/gateway
|
||||
* connection, but uses this token for narrow, on-demand Discord REST calls
|
||||
* (e.g. building a guild backup snapshot) and to enqueue BullMQ jobs that
|
||||
* the bot process picks up for actions that require a live Client
|
||||
* (posting messages, restoring a backup).
|
||||
*/
|
||||
BOT_TOKEN: z.string().min(1),
|
||||
WEBUI_URL: z.string().url().default('http://localhost:3000'),
|
||||
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().optional()),
|
||||
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'),
|
||||
/**
|
||||
* Comma-separated Discord user IDs that are treated as global bot owners
|
||||
* for the (future) owner panel. Optional for now.
|
||||
*/
|
||||
OWNER_USER_IDS: z.preprocess(
|
||||
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
|
||||
z
|
||||
@@ -38,8 +30,21 @@ const EnvSchema = z.object({
|
||||
.filter((id) => id.length > 0)
|
||||
: []
|
||||
)
|
||||
)
|
||||
),
|
||||
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||
LEGAL_OPERATOR_NAME: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
LEGAL_OPERATOR_ADDRESS: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
LEGAL_OPERATOR_EMAIL: z.preprocess(emptyToUndefined, z.string().email().optional()),
|
||||
LEGAL_OPERATOR_PHONE: z.preprocess(emptyToUndefined, z.string().min(1).optional())
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
export type Env = typeof env;
|
||||
|
||||
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
|
||||
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('permissions', '8');
|
||||
url.searchParams.set('scope', 'bot applications.commands');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
24
apps/webui/src/lib/public-stats.ts
Normal file
24
apps/webui/src/lib/public-stats.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { prisma } from './prisma';
|
||||
|
||||
export async function getPublicStats() {
|
||||
const [guildCount, memberLevels, memberEconomies, users] = await Promise.all([
|
||||
prisma.guild.count(),
|
||||
prisma.memberLevel.findMany({ select: { userId: true }, distinct: ['userId'] }),
|
||||
prisma.memberEconomy.findMany({ select: { userId: true }, distinct: ['userId'] }),
|
||||
prisma.user.count()
|
||||
]);
|
||||
|
||||
const unique = new Set<string>();
|
||||
for (const row of memberLevels) {
|
||||
unique.add(row.userId);
|
||||
}
|
||||
for (const row of memberEconomies) {
|
||||
unique.add(row.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
guildCount,
|
||||
approximateUserCount: Math.max(unique.size, users),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
56
apps/webui/src/lib/public-status.ts
Normal file
56
apps/webui/src/lib/public-status.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { BOT_STATUS_REDIS_KEY, BotStatusPayloadSchema, PRESENCE_REDIS_KEY } from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
import { redis } from './redis';
|
||||
|
||||
export async function getPublicStatus() {
|
||||
const [rawStatus, rawPresence, updates] = await Promise.all([
|
||||
redis.get(BOT_STATUS_REDIS_KEY),
|
||||
redis.get(PRESENCE_REDIS_KEY),
|
||||
prisma.changelogEntry.findMany({
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 10
|
||||
})
|
||||
]);
|
||||
|
||||
let botStatus = null;
|
||||
if (rawStatus) {
|
||||
const parsed = BotStatusPayloadSchema.safeParse(JSON.parse(rawStatus));
|
||||
botStatus = parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
let maintenanceMode = false;
|
||||
if (rawPresence) {
|
||||
try {
|
||||
const presence = JSON.parse(rawPresence) as { maintenanceMode?: boolean };
|
||||
maintenanceMode = Boolean(presence.maintenanceMode);
|
||||
} catch {
|
||||
maintenanceMode = false;
|
||||
}
|
||||
} else {
|
||||
const row = await prisma.botPresenceConfig.findUnique({ where: { id: 'singleton' } });
|
||||
maintenanceMode = row?.maintenanceMode ?? false;
|
||||
}
|
||||
|
||||
const degraded = !botStatus;
|
||||
const overall = maintenanceMode ? 'maintenance' : degraded ? 'degraded' : 'operational';
|
||||
|
||||
return {
|
||||
ok: !maintenanceMode,
|
||||
overall,
|
||||
maintenanceMode,
|
||||
degraded,
|
||||
shards: botStatus?.shards ?? [],
|
||||
apiLatencyMs: botStatus?.apiLatencyMs ?? null,
|
||||
guildCount: botStatus?.guildCount ?? (await prisma.guild.count()),
|
||||
uptimeSeconds: botStatus?.uptimeSeconds ?? null,
|
||||
updates: updates.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
title: entry.title,
|
||||
body: entry.body,
|
||||
publishedAt: entry.publishedAt.toISOString(),
|
||||
isIncident: entry.version.toLowerCase().startsWith('incident')
|
||||
})),
|
||||
updatedAt: botStatus?.updatedAt ?? new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -36,26 +36,86 @@
|
||||
"integrations": "Integrationen"
|
||||
},
|
||||
"modules": {
|
||||
"moderation": { "label": "Moderation", "description": "Bans, Verwarnungen, Timeouts und das Fall-System" },
|
||||
"automod": { "label": "Auto-Moderation", "description": "Spam-, Raid- und Nuke-Schutz" },
|
||||
"logging": { "label": "Logging", "description": "Log-Kanäle für Server-Events" },
|
||||
"welcome": { "label": "Willkommen & Abschied", "description": "Join- und Leave-Nachrichten, Autoroles" },
|
||||
"verification": { "label": "Verifizierung", "description": "Button- und Captcha-Verifizierung" },
|
||||
"leveling": { "label": "Leveling & XP", "description": "Text- und Voice-XP, Rollen-Belohnungen" },
|
||||
"economy": { "label": "Economy", "description": "Währung, Shop und Spiele" },
|
||||
"fun": { "label": "Fun & Spiele", "description": "Minispiele und Medien-Commands" },
|
||||
"giveaways": { "label": "Giveaways", "description": "Button-basierte Giveaways" },
|
||||
"tickets": { "label": "Tickets", "description": "Support-Ticket-Panels und Transkripte" },
|
||||
"selfroles": { "label": "Self Roles", "description": "Reaktions-, Button- und Dropdown-Rollenpanels" },
|
||||
"tags": { "label": "Tags", "description": "Custom Commands und Auto-Responder" },
|
||||
"starboard": { "label": "Starboard", "description": "Beliebte Nachrichten hervorheben" },
|
||||
"suggestions": { "label": "Vorschläge", "description": "Community-Vorschläge mit Abstimmung" },
|
||||
"birthdays": { "label": "Geburtstage", "description": "Geburtstags-Ankündigungen und -Rollen" },
|
||||
"tempvoice": { "label": "Temp-Voice", "description": "Join-to-Create Voice-Kanäle" },
|
||||
"stats": { "label": "Server-Statistiken", "description": "Automatische Statistik-Kanäle und Invites" },
|
||||
"feeds": { "label": "Social Feeds", "description": "Twitch-, YouTube-, RSS- und Reddit-Benachrichtigungen" },
|
||||
"scheduler": { "label": "Scheduler", "description": "Geplante und wiederkehrende Ankündigungen" },
|
||||
"guildbackup": { "label": "Backups", "description": "Server-Backup und -Wiederherstellung" }
|
||||
"moderation": {
|
||||
"label": "Moderation",
|
||||
"description": "Bans, Verwarnungen, Timeouts und das Fall-System"
|
||||
},
|
||||
"automod": {
|
||||
"label": "Auto-Moderation",
|
||||
"description": "Spam-, Raid- und Nuke-Schutz"
|
||||
},
|
||||
"logging": {
|
||||
"label": "Logging",
|
||||
"description": "Log-Kanäle für Server-Events"
|
||||
},
|
||||
"welcome": {
|
||||
"label": "Willkommen & Abschied",
|
||||
"description": "Join- und Leave-Nachrichten, Autoroles"
|
||||
},
|
||||
"verification": {
|
||||
"label": "Verifizierung",
|
||||
"description": "Button- und Captcha-Verifizierung"
|
||||
},
|
||||
"leveling": {
|
||||
"label": "Leveling & XP",
|
||||
"description": "Text- und Voice-XP, Rollen-Belohnungen"
|
||||
},
|
||||
"economy": {
|
||||
"label": "Economy",
|
||||
"description": "Währung, Shop und Spiele"
|
||||
},
|
||||
"fun": {
|
||||
"label": "Fun & Spiele",
|
||||
"description": "Minispiele und Medien-Commands"
|
||||
},
|
||||
"giveaways": {
|
||||
"label": "Giveaways",
|
||||
"description": "Button-basierte Giveaways"
|
||||
},
|
||||
"tickets": {
|
||||
"label": "Tickets",
|
||||
"description": "Support-Ticket-Panels und Transkripte"
|
||||
},
|
||||
"selfroles": {
|
||||
"label": "Self Roles",
|
||||
"description": "Reaktions-, Button- und Dropdown-Rollenpanels"
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags",
|
||||
"description": "Custom Commands und Auto-Responder"
|
||||
},
|
||||
"starboard": {
|
||||
"label": "Starboard",
|
||||
"description": "Beliebte Nachrichten hervorheben"
|
||||
},
|
||||
"suggestions": {
|
||||
"label": "Vorschläge",
|
||||
"description": "Community-Vorschläge mit Abstimmung"
|
||||
},
|
||||
"birthdays": {
|
||||
"label": "Geburtstage",
|
||||
"description": "Geburtstags-Ankündigungen und -Rollen"
|
||||
},
|
||||
"tempvoice": {
|
||||
"label": "Temp-Voice",
|
||||
"description": "Join-to-Create Voice-Kanäle"
|
||||
},
|
||||
"stats": {
|
||||
"label": "Server-Statistiken",
|
||||
"description": "Automatische Statistik-Kanäle und Invites"
|
||||
},
|
||||
"feeds": {
|
||||
"label": "Social Feeds",
|
||||
"description": "Twitch-, YouTube-, RSS- und Reddit-Benachrichtigungen"
|
||||
},
|
||||
"scheduler": {
|
||||
"label": "Scheduler",
|
||||
"description": "Geplante und wiederkehrende Ankündigungen"
|
||||
},
|
||||
"guildbackup": {
|
||||
"label": "Backups",
|
||||
"description": "Server-Backup und -Wiederherstellung"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Nexumi",
|
||||
@@ -141,7 +201,11 @@
|
||||
"addEscalation": "Regel hinzufügen",
|
||||
"warnCount": "Anzahl Verwarnungen",
|
||||
"action": "Aktion",
|
||||
"actions": { "TIMEOUT": "Timeout", "KICK": "Kick", "BAN": "Ban" },
|
||||
"actions": {
|
||||
"TIMEOUT": "Timeout",
|
||||
"KICK": "Kick",
|
||||
"BAN": "Ban"
|
||||
},
|
||||
"durationMs": "Dauer (ms)",
|
||||
"reason": "Grund",
|
||||
"reasonPlaceholder": "Optionaler Standardgrund",
|
||||
@@ -197,7 +261,11 @@
|
||||
"welcomeEnabledLabel": "Willkommensnachricht aktiviert",
|
||||
"welcomeChannelId": "Willkommens-Kanal-ID",
|
||||
"welcomeType": "Nachrichtentyp",
|
||||
"type": { "TEXT": "Text", "EMBED": "Embed", "IMAGE": "Bild-Karte" },
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Bild-Karte"
|
||||
},
|
||||
"welcomeContent": "Nachrichtentext",
|
||||
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
||||
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||
@@ -337,7 +405,11 @@
|
||||
"channelId": "Kanal-ID",
|
||||
"panelTitle": "Panel-Titel",
|
||||
"mode": "Modus",
|
||||
"modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reaktionen" },
|
||||
"modes": {
|
||||
"BUTTONS": "Buttons",
|
||||
"DROPDOWN": "Dropdown",
|
||||
"REACTIONS": "Reaktionen"
|
||||
},
|
||||
"behavior": "Verhalten",
|
||||
"behaviors": {
|
||||
"NORMAL": "Normal (frei umschaltbar)",
|
||||
@@ -361,7 +433,10 @@
|
||||
"createDescription": "Custom Commands und Auto-Responder, ausgelöst per Name oder Stichwort.",
|
||||
"name": "Name",
|
||||
"responseType": "Antworttyp",
|
||||
"responseTypes": { "TEXT": "Text", "EMBED": "Embed" },
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
},
|
||||
"triggerWord": "Auslöse-Wort (optional)",
|
||||
"content": "Inhalt",
|
||||
"allowedRoleIds": "Erlaubte Rollen-IDs",
|
||||
@@ -620,5 +695,88 @@
|
||||
"forbidden": "Du hast keinen Zugriff auf diesen Server.",
|
||||
"notFound": "Nicht gefunden.",
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte erneut versuchen."
|
||||
},
|
||||
"site": {
|
||||
"nav": {
|
||||
"status": "Status",
|
||||
"invite": "Einladen",
|
||||
"support": "Support",
|
||||
"login": "Anmelden",
|
||||
"dashboard": "Dashboard",
|
||||
"theme": "Design"
|
||||
},
|
||||
"footer": {
|
||||
"status": "Status",
|
||||
"impressum": "Impressum",
|
||||
"privacy": "Datenschutz",
|
||||
"terms": "Nutzungsbedingungen",
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
"hero": {
|
||||
"subtitle": "Selbst gehosteter Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.",
|
||||
"invite": "Bot einladen",
|
||||
"login": "Zum Dashboard",
|
||||
"dashboard": "Dashboard öffnen",
|
||||
"support": "Support-Server"
|
||||
},
|
||||
"stats": {
|
||||
"guilds": "Server",
|
||||
"users": "Nutzer (ca.)"
|
||||
},
|
||||
"features": {
|
||||
"title": "Module",
|
||||
"subtitle": "Funktionen, die Nexumi pro Server bereitstellt."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"subtitle": "Verfügbarkeit, Shard-Status und aktuelle Hinweise.",
|
||||
"overall": {
|
||||
"operational": "Betriebsbereit",
|
||||
"maintenance": "Wartung",
|
||||
"degraded": "Eingeschränkt"
|
||||
},
|
||||
"latency": "API-Latenz",
|
||||
"guilds": "Server",
|
||||
"uptime": "Bot-Uptime",
|
||||
"shardsTitle": "Shards",
|
||||
"shardsEmpty": "Keine Shard-Daten vom Bot (Redis bot:status fehlt oder ist abgelaufen).",
|
||||
"shardLabel": "Shard {id}",
|
||||
"shardStatus": "Status",
|
||||
"updatesTitle": "Updates & Incidents",
|
||||
"updatesEmpty": "Noch keine Einträge.",
|
||||
"incident": "Incident"
|
||||
},
|
||||
"legal": {
|
||||
"navTitle": "Rechtliches",
|
||||
"scaffoldNotice": "Gerüsttext — bitte vor Produktion rechtlich prüfen und anpassen.",
|
||||
"impressum": {
|
||||
"title": "Impressum",
|
||||
"intro": "Angaben gemäß § 5 DDG.",
|
||||
"name": "Anbieter",
|
||||
"address": "Anschrift",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"notConfigured": "Betreiberdaten fehlen. Bitte LEGAL_OPERATOR_NAME, LEGAL_OPERATOR_ADDRESS und LEGAL_OPERATOR_EMAIL in der .env setzen.",
|
||||
"p1": "Verantwortlich für den Inhalt dieser Website und den Betrieb von Nexumi ist der oben genannte Anbieter.",
|
||||
"p2": "Für Inhalte externer Links übernehmen wir keine Haftung. Für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Datenschutzerklärung",
|
||||
"p1": "Nexumi verarbeitet Discord-Daten, soweit dies für den Betrieb des Bots und des Dashboards erforderlich ist (z. B. Server-IDs, Nutzer-IDs, Moderationsfälle, Modul-Einstellungen).",
|
||||
"p2": "Die Anmeldung am Dashboard erfolgt über Discord OAuth2 (Scopes identify und guilds). Es werden Sitzungsdaten in Redis gespeichert.",
|
||||
"p3": "Daten werden in der selbst gehosteten PostgreSQL-Datenbank gespeichert. Aufbewahrungsdauern sind über Modul-Einstellungen bzw. Löschanfragen steuerbar.",
|
||||
"p4": "Es findet keine Weitergabe an Dritte statt, außer soweit für den Betrieb technisch erforderlich (z. B. Discord API, optional Sentry).",
|
||||
"p5": "Betroffene können Auskunft, Berichtigung und Löschung verlangen. Kontakt über die im Impressum genannte Adresse oder den Bot-Command /gdpr sofern verfügbar."
|
||||
},
|
||||
"terms": {
|
||||
"title": "Nutzungsbedingungen",
|
||||
"p1": "Nexumi wird als selbst gehosteter Discord-Bot bereitgestellt. Die Nutzung erfolgt auf eigene Verantwortung.",
|
||||
"p2": "Missbrauch, Spam oder Verstöße gegen die Discord Terms of Service können zum Ausschluss (Blacklist) führen.",
|
||||
"p3": "Wir übernehmen keine Gewähr für unterbrechungsfreien Betrieb. Wartungsfenster werden nach Möglichkeit angekündigt.",
|
||||
"p4": "Es gilt das Recht der Bundesrepublik Deutschland, soweit zwingende Verbraucherschutzvorschriften nichts anderes vorsehen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,26 +36,86 @@
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"modules": {
|
||||
"moderation": { "label": "Moderation", "description": "Bans, warns, timeouts and the case system" },
|
||||
"automod": { "label": "Auto-Moderation", "description": "Spam, raid and nuke protection" },
|
||||
"logging": { "label": "Logging", "description": "Server event log channels" },
|
||||
"welcome": { "label": "Welcome & Leave", "description": "Join and leave messages, autoroles" },
|
||||
"verification": { "label": "Verification", "description": "Button and captcha verification" },
|
||||
"leveling": { "label": "Leveling & XP", "description": "Text and voice XP, level rewards" },
|
||||
"economy": { "label": "Economy", "description": "Currency, shop and games" },
|
||||
"fun": { "label": "Fun & Games", "description": "Mini games and media commands" },
|
||||
"giveaways": { "label": "Giveaways", "description": "Button-based giveaways" },
|
||||
"tickets": { "label": "Tickets", "description": "Support ticket panels and transcripts" },
|
||||
"selfroles": { "label": "Self Roles", "description": "Reaction, button and dropdown role panels" },
|
||||
"tags": { "label": "Tags", "description": "Custom commands and auto-responders" },
|
||||
"starboard": { "label": "Starboard", "description": "Highlight popular messages" },
|
||||
"suggestions": { "label": "Suggestions", "description": "Community suggestion voting" },
|
||||
"birthdays": { "label": "Birthdays", "description": "Birthday announcements and roles" },
|
||||
"tempvoice": { "label": "Temp Voice", "description": "Join-to-create voice channels" },
|
||||
"stats": { "label": "Server Stats", "description": "Auto-updating stat channels and invites" },
|
||||
"feeds": { "label": "Social Feeds", "description": "Twitch, YouTube, RSS and Reddit notifications" },
|
||||
"scheduler": { "label": "Scheduler", "description": "Scheduled and recurring announcements" },
|
||||
"guildbackup": { "label": "Backups", "description": "Server backup and restore" }
|
||||
"moderation": {
|
||||
"label": "Moderation",
|
||||
"description": "Bans, warns, timeouts and the case system"
|
||||
},
|
||||
"automod": {
|
||||
"label": "Auto-Moderation",
|
||||
"description": "Spam, raid and nuke protection"
|
||||
},
|
||||
"logging": {
|
||||
"label": "Logging",
|
||||
"description": "Server event log channels"
|
||||
},
|
||||
"welcome": {
|
||||
"label": "Welcome & Leave",
|
||||
"description": "Join and leave messages, autoroles"
|
||||
},
|
||||
"verification": {
|
||||
"label": "Verification",
|
||||
"description": "Button and captcha verification"
|
||||
},
|
||||
"leveling": {
|
||||
"label": "Leveling & XP",
|
||||
"description": "Text and voice XP, level rewards"
|
||||
},
|
||||
"economy": {
|
||||
"label": "Economy",
|
||||
"description": "Currency, shop and games"
|
||||
},
|
||||
"fun": {
|
||||
"label": "Fun & Games",
|
||||
"description": "Mini games and media commands"
|
||||
},
|
||||
"giveaways": {
|
||||
"label": "Giveaways",
|
||||
"description": "Button-based giveaways"
|
||||
},
|
||||
"tickets": {
|
||||
"label": "Tickets",
|
||||
"description": "Support ticket panels and transcripts"
|
||||
},
|
||||
"selfroles": {
|
||||
"label": "Self Roles",
|
||||
"description": "Reaction, button and dropdown role panels"
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags",
|
||||
"description": "Custom commands and auto-responders"
|
||||
},
|
||||
"starboard": {
|
||||
"label": "Starboard",
|
||||
"description": "Highlight popular messages"
|
||||
},
|
||||
"suggestions": {
|
||||
"label": "Suggestions",
|
||||
"description": "Community suggestion voting"
|
||||
},
|
||||
"birthdays": {
|
||||
"label": "Birthdays",
|
||||
"description": "Birthday announcements and roles"
|
||||
},
|
||||
"tempvoice": {
|
||||
"label": "Temp Voice",
|
||||
"description": "Join-to-create voice channels"
|
||||
},
|
||||
"stats": {
|
||||
"label": "Server Stats",
|
||||
"description": "Auto-updating stat channels and invites"
|
||||
},
|
||||
"feeds": {
|
||||
"label": "Social Feeds",
|
||||
"description": "Twitch, YouTube, RSS and Reddit notifications"
|
||||
},
|
||||
"scheduler": {
|
||||
"label": "Scheduler",
|
||||
"description": "Scheduled and recurring announcements"
|
||||
},
|
||||
"guildbackup": {
|
||||
"label": "Backups",
|
||||
"description": "Server backup and restore"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Nexumi",
|
||||
@@ -141,7 +201,11 @@
|
||||
"addEscalation": "Add rule",
|
||||
"warnCount": "Warning count",
|
||||
"action": "Action",
|
||||
"actions": { "TIMEOUT": "Timeout", "KICK": "Kick", "BAN": "Ban" },
|
||||
"actions": {
|
||||
"TIMEOUT": "Timeout",
|
||||
"KICK": "Kick",
|
||||
"BAN": "Ban"
|
||||
},
|
||||
"durationMs": "Duration (ms)",
|
||||
"reason": "Reason",
|
||||
"reasonPlaceholder": "Optional default reason",
|
||||
@@ -197,7 +261,11 @@
|
||||
"welcomeEnabledLabel": "Welcome message enabled",
|
||||
"welcomeChannelId": "Welcome channel ID",
|
||||
"welcomeType": "Message type",
|
||||
"type": { "TEXT": "Text", "EMBED": "Embed", "IMAGE": "Image card" },
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Image card"
|
||||
},
|
||||
"welcomeContent": "Message content",
|
||||
"contentPlaceholder": "Welcome {user} to {server}!",
|
||||
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||
@@ -337,7 +405,11 @@
|
||||
"channelId": "Channel ID",
|
||||
"panelTitle": "Panel title",
|
||||
"mode": "Mode",
|
||||
"modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reactions" },
|
||||
"modes": {
|
||||
"BUTTONS": "Buttons",
|
||||
"DROPDOWN": "Dropdown",
|
||||
"REACTIONS": "Reactions"
|
||||
},
|
||||
"behavior": "Behavior",
|
||||
"behaviors": {
|
||||
"NORMAL": "Normal (toggle freely)",
|
||||
@@ -361,7 +433,10 @@
|
||||
"createDescription": "Custom commands and auto-responders triggered by name or keyword.",
|
||||
"name": "Name",
|
||||
"responseType": "Response type",
|
||||
"responseTypes": { "TEXT": "Text", "EMBED": "Embed" },
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
},
|
||||
"triggerWord": "Trigger word (optional)",
|
||||
"content": "Content",
|
||||
"allowedRoleIds": "Allowed role IDs",
|
||||
@@ -620,5 +695,88 @@
|
||||
"forbidden": "You do not have access to this server.",
|
||||
"notFound": "Not found.",
|
||||
"generic": "Something went wrong. Please try again."
|
||||
},
|
||||
"site": {
|
||||
"nav": {
|
||||
"status": "Status",
|
||||
"invite": "Invite",
|
||||
"support": "Support",
|
||||
"login": "Log in",
|
||||
"dashboard": "Dashboard",
|
||||
"theme": "Theme"
|
||||
},
|
||||
"footer": {
|
||||
"status": "Status",
|
||||
"impressum": "Legal notice",
|
||||
"privacy": "Privacy",
|
||||
"terms": "Terms",
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
"hero": {
|
||||
"subtitle": "Self-hosted Discord bot with moderation, community, and integration modules plus a web dashboard.",
|
||||
"invite": "Invite bot",
|
||||
"login": "Open dashboard",
|
||||
"dashboard": "Open dashboard",
|
||||
"support": "Support server"
|
||||
},
|
||||
"stats": {
|
||||
"guilds": "Servers",
|
||||
"users": "Users (approx.)"
|
||||
},
|
||||
"features": {
|
||||
"title": "Modules",
|
||||
"subtitle": "Features Nexumi provides per server."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"subtitle": "Availability, shard status, and recent notices.",
|
||||
"overall": {
|
||||
"operational": "Operational",
|
||||
"maintenance": "Maintenance",
|
||||
"degraded": "Degraded"
|
||||
},
|
||||
"latency": "API latency",
|
||||
"guilds": "Servers",
|
||||
"uptime": "Bot uptime",
|
||||
"shardsTitle": "Shards",
|
||||
"shardsEmpty": "No shard data from the bot (Redis bot:status missing or expired).",
|
||||
"shardLabel": "Shard {id}",
|
||||
"shardStatus": "Status",
|
||||
"updatesTitle": "Updates & incidents",
|
||||
"updatesEmpty": "No entries yet.",
|
||||
"incident": "Incident"
|
||||
},
|
||||
"legal": {
|
||||
"navTitle": "Legal",
|
||||
"scaffoldNotice": "Scaffold text — review and adapt before production use.",
|
||||
"impressum": {
|
||||
"title": "Legal notice",
|
||||
"intro": "Information according to German Telemedia Act (DDG).",
|
||||
"name": "Operator",
|
||||
"address": "Address",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"notConfigured": "Operator details missing. Set LEGAL_OPERATOR_NAME, LEGAL_OPERATOR_ADDRESS, and LEGAL_OPERATOR_EMAIL in .env.",
|
||||
"p1": "The operator named above is responsible for this website and for running Nexumi.",
|
||||
"p2": "We are not responsible for external links. Operators of linked sites are solely responsible for their content."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy policy",
|
||||
"p1": "Nexumi processes Discord data as required to operate the bot and dashboard (e.g. guild IDs, user IDs, moderation cases, module settings).",
|
||||
"p2": "Dashboard login uses Discord OAuth2 (identify and guilds scopes). Session data is stored in Redis.",
|
||||
"p3": "Data is stored in the self-hosted PostgreSQL database. Retention can be configured per module or via deletion requests.",
|
||||
"p4": "Data is not shared with third parties except where technically required (e.g. Discord API, optional Sentry).",
|
||||
"p5": "Data subjects may request access, correction, and deletion via the contact in the legal notice or bot commands where available."
|
||||
},
|
||||
"terms": {
|
||||
"title": "Terms of service",
|
||||
"p1": "Nexumi is provided as a self-hosted Discord bot. Use is at your own risk.",
|
||||
"p2": "Abuse, spam, or violations of Discord Terms of Service may result in blacklisting.",
|
||||
"p3": "We do not guarantee uninterrupted service. Maintenance windows are announced when possible.",
|
||||
"p4": "German law applies unless mandatory consumer protection rules require otherwise."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user