deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
9 changed files with 589 additions and 9 deletions
Showing only changes of commit 8c02b95934 - Show all commits

View File

@@ -1,6 +1,6 @@
# Nexumi
Nexumi is a self-hosted public Discord bot with dashboard architecture, built as a TypeScript monorepo.
Nexumi is a public Discord bot with dashboard architecture, built as a TypeScript monorepo.
## Phase 1 scope

View File

@@ -11,7 +11,7 @@ const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'sw
export const metadata: Metadata = {
title: 'Nexumi — Discord Bot',
description: 'Self-hosted Discord bot with dashboard, moderation, and community modules'
description: 'Discord bot with dashboard, moderation, and community modules'
};
export default async function RootLayout({ children }: { children: ReactNode }) {

View File

@@ -737,7 +737,7 @@
},
"landing": {
"hero": {
"subtitle": "Selbst gehosteter Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.",
"subtitle": "Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.",
"invite": "Bot einladen",
"login": "Zum Dashboard",
"dashboard": "Dashboard öffnen",
@@ -789,13 +789,13 @@
"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.",
"p3": "Daten werden in unserer 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.",
"p1": "Nexumi wird als 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."

View File

@@ -737,7 +737,7 @@
},
"landing": {
"hero": {
"subtitle": "Self-hosted Discord bot with moderation, community, and integration modules plus a web dashboard.",
"subtitle": "Discord bot with moderation, community, and integration modules plus a web dashboard.",
"invite": "Invite bot",
"login": "Open dashboard",
"dashboard": "Open dashboard",
@@ -789,13 +789,13 @@
"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.",
"p3": "Data is stored in our 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.",
"p1": "Nexumi is provided as a 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."

View File

@@ -1,6 +1,6 @@
# Nexumi Vollständige Feature-Spezifikation (Cursor-Prompt)
Baue „Nexumi", einen vollwertigen, selbst gehosteten, **öffentlichen** Discord-Bot mit Web-Dashboard. Domain: `https://nexumi.de`. Repository: `https://git.hexahost.dev/smueller/Nexumi`. Der Name „Nexumi" wird konsistent verwendet: Bot-Präsenz, WebUI-Branding, Embed-Footer, Docker-Image-Namen, Dokumentation. Halte dich an diese Spezifikation. Frage nach, bevor du Module weglässt oder den Stack änderst.
Baue „Nexumi", einen vollwertigen, **öffentlichen** Discord-Bot mit Web-Dashboard. Domain: `https://nexumi.de`. Repository: `https://git.hexahost.dev/smueller/Nexumi`. Der Name „Nexumi" wird konsistent verwendet: Bot-Präsenz, WebUI-Branding, Embed-Footer, Docker-Image-Namen, Dokumentation. Halte dich an diese Spezifikation. Frage nach, bevor du Module weglässt oder den Stack änderst.
## Tech-Stack & Architektur

View File

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Nexumi logo">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#181C2B"/>
<stop offset="1" stop-color="#0C0E15"/>
</linearGradient>
<linearGradient id="mark" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#6366F1"/>
<stop offset="1" stop-color="#8B5CF6"/>
</linearGradient>
</defs>
<!-- App-Icon-Grundflaeche -->
<rect width="512" height="512" rx="116" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="509" height="509" rx="114.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
<!-- Glow: gestaffelte Deckkraft statt Filter, rendersicher -->
<g fill="none" stroke="url(#mark)" stroke-linecap="round" stroke-linejoin="round">
<path d="M170 362 V150 L342 362 V150" stroke-width="78" stroke-opacity="0.10"/>
<path d="M170 362 V150 L342 362 V150" stroke-width="52" stroke-opacity="0.16"/>
</g>
<!-- Kanten des N -->
<path d="M170 362 V150 L342 362 V150" fill="none" stroke="url(#mark)"
stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Knoten -->
<g fill="url(#mark)">
<circle cx="170" cy="150" r="34"/>
<circle cx="170" cy="362" r="34"/>
<circle cx="342" cy="362" r="34"/>
<circle cx="342" cy="150" r="34"/>
</g>
<!-- Aktiver Knoten oben rechts -->
<circle cx="342" cy="150" r="14" fill="#C7D2FE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,327 @@
:root {
--background: #f8f8fb;
--foreground: #111116;
--card: #ffffff;
--muted: #f1f1f5;
--muted-foreground: #6b7280;
--primary: #6366f1;
--primary-foreground: #ffffff;
--secondary: #f1f1f5;
--secondary-foreground: #111116;
--border: #e4e4ea;
--radius: 0.625rem;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0d;
--foreground: #e6e6ea;
--card: #131318;
--muted: #1b1b22;
--muted-foreground: #9a9aa5;
--secondary: #1b1b22;
--secondary-foreground: #e6e6ea;
--border: #23232b;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
background: var(--background);
color: var(--foreground);
line-height: 1.5;
}
a {
color: inherit;
text-decoration: none;
}
.shell {
display: flex;
min-height: 100vh;
flex-direction: column;
}
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.border-b {
border-bottom: 1px solid var(--border);
}
.border-t {
border-top: 1px solid var(--border);
}
header .bar {
display: flex;
height: 3.5rem;
align-items: center;
justify-content: space-between;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.brand img {
width: 28px;
height: 28px;
}
nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.btn {
display: inline-flex;
height: 2.25rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: calc(var(--radius) - 2px);
padding: 0 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.btn-lg {
height: 2.75rem;
padding: 0 1.25rem;
font-size: 1rem;
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover {
filter: brightness(0.95);
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
}
.btn-outline {
background: transparent;
border-color: var(--border);
}
.btn-ghost {
background: transparent;
}
.btn-ghost:hover,
.btn-outline:hover,
.btn-secondary:hover {
background: var(--muted);
}
main {
flex: 1;
}
.hero {
display: flex;
flex-direction: column;
gap: 2rem;
padding-top: 4rem;
padding-bottom: 4rem;
}
@media (min-width: 640px) {
.hero {
padding-top: 5rem;
padding-bottom: 5rem;
}
}
.hero-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.hero-brand img {
width: 48px;
height: 48px;
}
.hero h1 {
margin: 0;
font-size: 2.25rem;
font-weight: 600;
letter-spacing: -0.025em;
}
@media (min-width: 640px) {
.hero h1 {
font-size: 3rem;
}
}
.hero p {
margin: 0;
max-width: 42rem;
font-size: 1.125rem;
color: var(--muted-foreground);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.stats {
display: grid;
gap: 1rem;
padding-top: 3rem;
padding-bottom: 3rem;
}
@media (min-width: 640px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
}
.card-label {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--muted-foreground);
}
.card-value {
margin: 0;
font-size: 1.875rem;
font-weight: 600;
}
.features {
padding-top: 3rem;
padding-bottom: 3rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.features > .intro h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.features > .intro p {
margin: 0.5rem 0 0;
color: var(--muted-foreground);
}
.group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.group h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.grid {
display: grid;
gap: 0.75rem;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.module-title {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.module-desc {
margin: 0;
font-size: 0.875rem;
color: var(--muted-foreground);
}
footer .bar {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 2rem;
padding-bottom: 2rem;
font-size: 0.875rem;
color: var(--muted-foreground);
}
@media (min-width: 640px) {
footer .bar {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
footer nav {
gap: 1rem;
}
footer a:hover {
color: var(--foreground);
}

24
landingpage/config.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
/**
* Public site config for Plesk (nexumi.de).
* No secrets here — only URLs and the Discord application client ID (public).
*/
return [
// Discord application client ID (used for bot invite link)
'bot_client_id' => '1529428118010462278',
// Dashboard (Traefik / WebUI)
'dashboard_url' => 'https://dashboard.nexumi.de',
// Optional support invite
'support_url' => 'https://discord.gg/RZ9JcQrPDz',
// Live stats from the WebUI public API (falls back to placeholders on failure)
'stats_api_url' => 'https://dashboard.nexumi.de/api/public/stats',
// Footer legal pages (still hosted on the dashboard app)
'status_url' => 'https://dashboard.nexumi.de/status',
'impressum_url' => 'https://dashboard.nexumi.de/impressum',
'privacy_url' => 'https://dashboard.nexumi.de/privacy',
'terms_url' => 'https://dashboard.nexumi.de/terms',
];

192
landingpage/index.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
$config = require __DIR__ . '/config.php';
$clientId = (string) $config['bot_client_id'];
$inviteUrl = 'https://discord.com/api/oauth2/authorize?' . http_build_query([
'client_id' => $clientId,
'permissions' => '8',
'scope' => 'bot applications.commands',
], '', '&', PHP_QUERY_RFC3986);
$dashboardUrl = rtrim((string) $config['dashboard_url'], '/');
$loginUrl = $dashboardUrl . '/login';
$supportUrl = isset($config['support_url']) && $config['support_url'] !== ''
? (string) $config['support_url']
: null;
$guildCount = 0;
$userCount = 0;
$statsApi = (string) ($config['stats_api_url'] ?? '');
if ($statsApi !== '') {
$ctx = stream_context_create([
'http' => [
'timeout' => 3,
'header' => "Accept: application/json\r\n",
],
]);
$raw = @file_get_contents($statsApi, false, $ctx);
if ($raw !== false) {
$json = json_decode($raw, true);
if (is_array($json)) {
$guildCount = (int) ($json['guildCount'] ?? 0);
$userCount = (int) ($json['approximateUserCount'] ?? 0);
}
}
}
$year = (int) date('Y');
$groups = [
'moderation' => [
'label' => 'Moderation',
'modules' => [
['label' => 'Moderation', 'description' => 'Bans, Verwarnungen, Timeouts und das Fall-System'],
['label' => 'Auto-Moderation', 'description' => 'Spam-, Raid- und Nuke-Schutz'],
['label' => 'Logging', 'description' => 'Log-Kanäle für Server-Events'],
],
],
'engagement' => [
'label' => 'Engagement',
'modules' => [
['label' => 'Willkommen & Abschied', 'description' => 'Join- und Leave-Nachrichten, Autoroles'],
['label' => 'Verifizierung', 'description' => 'Button- und Captcha-Verifizierung'],
['label' => 'Leveling & XP', 'description' => 'Text- und Voice-XP, Rollen-Belohnungen'],
['label' => 'Economy', 'description' => 'Währung, Shop und Spiele'],
['label' => 'Fun & Spiele', 'description' => 'Minispiele und Medien-Commands'],
],
],
'community' => [
'label' => 'Community',
'modules' => [
['label' => 'Giveaways', 'description' => 'Button-basierte Giveaways'],
['label' => 'Tickets', 'description' => 'Support-Ticket-Panels und Transkripte'],
['label' => 'Self Roles', 'description' => 'Reaktions-, Button- und Dropdown-Rollenpanels'],
['label' => 'Starboard', 'description' => 'Beliebte Nachrichten hervorheben'],
['label' => 'Vorschläge', 'description' => 'Community-Vorschläge mit Abstimmung'],
['label' => 'Geburtstage', 'description' => 'Geburtstags-Ankündigungen und -Rollen'],
],
],
'utility' => [
'label' => 'Utility',
'modules' => [
['label' => 'Tags', 'description' => 'Custom Commands und Auto-Responder'],
['label' => 'Temp-Voice', 'description' => 'Join-to-Create Voice-Kanäle'],
],
],
'integrations' => [
'label' => 'Integrationen',
'modules' => [
['label' => 'Server-Statistiken', 'description' => 'Automatische Statistik-Kanäle und Invites'],
['label' => 'Social Feeds', 'description' => 'Twitch-, YouTube-, RSS- und Reddit-Benachrichtigungen'],
['label' => 'Scheduler', 'description' => 'Geplante und wiederkehrende Ankündigungen'],
['label' => 'Backups', 'description' => 'Server-Backup und -Wiederherstellung'],
],
],
];
function h(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nexumi</title>
<meta name="description" content="Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.">
<link rel="icon" href="assets/nexumi-logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<div class="shell">
<header class="border-b">
<div class="container bar">
<a class="brand" href="/">
<img src="assets/nexumi-logo.svg" alt="Nexumi" width="28" height="28">
Nexumi
</a>
<nav>
<a class="btn btn-ghost" href="<?= h((string) $config['status_url']) ?>">Status</a>
<a class="btn btn-ghost" href="<?= h($inviteUrl) ?>" target="_blank" rel="noreferrer">Einladen</a>
<?php if ($supportUrl): ?>
<a class="btn btn-ghost" href="<?= h($supportUrl) ?>" target="_blank" rel="noreferrer">Support</a>
<?php endif; ?>
<a class="btn btn-primary" href="<?= h($loginUrl) ?>">Anmelden</a>
</nav>
</div>
</header>
<main>
<section class="border-b">
<div class="container hero">
<div class="hero-brand">
<img src="assets/nexumi-logo.svg" alt="" width="48" height="48">
<h1>Nexumi</h1>
</div>
<p>Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.</p>
<div class="actions">
<a class="btn btn-lg btn-primary" href="<?= h($inviteUrl) ?>" target="_blank" rel="noreferrer">Bot einladen</a>
<a class="btn btn-lg btn-secondary" href="<?= h($loginUrl) ?>">Zum Dashboard</a>
<?php if ($supportUrl): ?>
<a class="btn btn-lg btn-outline" href="<?= h($supportUrl) ?>" target="_blank" rel="noreferrer">Support-Server</a>
<?php endif; ?>
</div>
</div>
</section>
<section class="border-b">
<div class="container stats">
<div class="card">
<p class="card-label">Server</p>
<p class="card-value"><?= h(number_format($guildCount, 0, ',', '.')) ?></p>
</div>
<div class="card">
<p class="card-label">Nutzer (ca.)</p>
<p class="card-value"><?= h(number_format($userCount, 0, ',', '.')) ?></p>
</div>
</div>
</section>
<section>
<div class="container features">
<div class="intro">
<h2>Module</h2>
<p>Funktionen, die Nexumi pro Server bereitstellt.</p>
</div>
<?php foreach ($groups as $group): ?>
<div class="group">
<h3><?= h($group['label']) ?></h3>
<div class="grid">
<?php foreach ($group['modules'] as $module): ?>
<div class="card">
<p class="module-title"><?= h($module['label']) ?></p>
<p class="module-desc"><?= h($module['description']) ?></p>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
</main>
<footer class="border-t">
<div class="container bar">
<p>© <?= $year ?> Nexumi</p>
<nav>
<a href="<?= h((string) $config['status_url']) ?>">Status</a>
<a href="<?= h((string) $config['impressum_url']) ?>">Impressum</a>
<a href="<?= h((string) $config['privacy_url']) ?>">Datenschutz</a>
<a href="<?= h((string) $config['terms_url']) ?>">Nutzungsbedingungen</a>
<a href="<?= h($dashboardUrl . '/dashboard') ?>">Dashboard</a>
</nav>
</div>
</footer>
</div>
</body>
</html>