feat: vollständiges HeroUI v3 Redesign — Types, Utils, Layout, Sections, Dark SaaS Design
Some checks failed
Deploy Discord Bot / deploy (push) Has been cancelled

This commit is contained in:
Pepe44DEV
2026-07-01 04:46:56 +02:00
parent e9b0f25d71
commit dd6ae54306
14 changed files with 925 additions and 974 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3,134 +3,30 @@
@custom-variant dark (&:is(.dark *));
:root {
color-scheme: dark;
--papo-bg: #080b12;
--papo-surface: #101522;
--papo-surface-2: #171d2b;
--papo-border: rgba(255, 255, 255, 0.08);
--papo-text: #f6efe7;
--papo-muted: #b6ab9b;
--papo-accent: #f97316;
--papo-accent-soft: rgba(249, 115, 22, 0.18);
}
html,
body,
#root {
html, body, #root {
min-height: 100%;
}
body {
margin: 0;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background:
radial-gradient(circle at top left, rgba(249, 115, 22, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(255, 163, 82, 0.08), transparent 24%),
linear-gradient(180deg, #090c14 0%, #070910 48%, #05070c 100%);
color: var(--papo-text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
.papo-shell {
min-height: 100vh;
color: var(--papo-text);
}
.papo-grid {
display: grid;
gap: 1rem;
}
.papo-card {
border: 1px solid var(--papo-border);
background: linear-gradient(180deg, rgba(18, 24, 38, 0.96), rgba(10, 14, 24, 0.94));
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.28);
}
.papo-section-title {
margin: 0;
font-size: 1.65rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.papo-section-subtitle {
margin: 0.35rem 0 0;
color: var(--papo-muted);
font-size: 0.95rem;
}
.papo-sidebar {
background:
linear-gradient(180deg, rgba(7, 10, 17, 0.98), rgba(6, 8, 14, 0.96));
border-right: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(18px);
}
.papo-logo {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 18px;
background: linear-gradient(140deg, #ffbf7f 0%, #f97316 62%, #d8580b 100%);
color: #1e1105;
font-size: 1.65rem;
font-weight: 900;
box-shadow: 0 18px 28px rgba(249, 115, 22, 0.32);
}
.papo-nav-button[data-active="true"] {
background: linear-gradient(90deg, rgba(249, 115, 22, 0.28), rgba(249, 115, 22, 0.08));
border-color: rgba(249, 115, 22, 0.45);
color: #fff6ee;
}
.papo-icon-badge {
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
border-radius: 14px;
background: rgba(249, 115, 22, 0.12);
color: #ffb778;
border: 1px solid rgba(249, 115, 22, 0.18);
}
.papo-chart {
width: 88px;
height: 28px;
}
.papo-chart path {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.papo-scroll {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(249, 115, 22, 0.35) transparent;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.papo-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.papo-scroll::-webkit-scrollbar-thumb {
background: rgba(249, 115, 22, 0.3);
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
}

View File

@@ -0,0 +1,82 @@
import { Avatar, Button, Card, CardContent, Chip, Divider } from '@heroui/react';
import { LogOut, Server } from 'lucide-react';
import { navItems } from '../../utils/constants';
import type { NavKey, Guild, User } from '../../types';
type Props = {
user: User | null;
guilds: Guild[];
currentGuildId: string;
section: NavKey;
onSectionChange: (key: NavKey) => void;
onGuildChange: (id: string) => void;
onLogout: () => void;
};
export function Sidebar({ user, guilds, currentGuildId, section, onSectionChange, onGuildChange, onLogout }: Props) {
return (
<aside className="flex h-full flex-col gap-2 border-r border-default-100 bg-gradient-to-b from-default-50/40 to-background p-4">
<div className="mb-4 flex items-center gap-3 px-2">
<div className="flex size-11 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-400 to-primary-600 text-xl font-black text-white shadow-lg shadow-primary-500/25">
P
</div>
<div>
<div className="text-lg font-bold">Papo</div>
<div className="text-tiny uppercase tracking-widest text-default-400">Dashboard</div>
</div>
</div>
<Card className="border border-default-100 bg-default-50/20">
<CardContent className="gap-2 p-3">
<div className="flex items-center gap-2 text-tiny text-default-500">
<Server size={14} />
Server
</div>
<select
className="w-full rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary-400"
value={currentGuildId}
onChange={(e) => onGuildChange(e.target.value)}
>
{guilds.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</CardContent>
</Card>
<Divider className="my-2" />
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto">
{navItems
.filter((item) => item.key !== 'admin' || user?.isAdmin)
.map((item) => (
<Button
key={item.key}
className={`justify-start px-3 ${section === item.key ? 'bg-primary-500/15 text-primary-400 font-semibold' : 'text-default-500 hover:text-foreground'}`}
radius="lg"
variant="light"
startContent={item.icon}
onPress={() => onSectionChange(item.key)}
>
{item.label}
</Button>
))}
</nav>
<Divider className="my-2" />
<Card className="border border-default-100 bg-default-50/20">
<CardContent className="flex flex-row items-center gap-3 p-3">
<Avatar name={user?.username} size="sm" />
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-semibold">{user?.username}</div>
<div className="text-tiny text-default-400">Online</div>
</div>
<Button isIconOnly color="danger" radius="lg" size="sm" variant="light" onPress={onLogout}>
<LogOut size={16} />
</Button>
</CardContent>
</Card>
</aside>
);
}

View File

@@ -0,0 +1,24 @@
import { Chip } from '@heroui/react';
type Props = {
guildName: string;
statusMessage?: string;
children?: React.ReactNode;
};
export function Topbar({ guildName, statusMessage, children }: Props) {
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{guildName}</h1>
<p className="mt-1 text-small text-default-500">Bot-Dashboard Verwaltung</p>
</div>
<div className="flex items-center gap-3">
{statusMessage && (
<Chip color="warning" size="sm" variant="flat">{statusMessage}</Chip>
)}
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
icon: ReactNode;
label: string;
value: number;
};
export function ActivityTile({ icon, label, value }: Props) {
return (
<Card className="border border-default-100 bg-default-50/20">
<CardContent className="flex flex-row items-center justify-between gap-4 p-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary-500/10 text-primary-400">
{icon}
</div>
<div>
<div className="text-tiny uppercase tracking-widest text-default-500">{label}</div>
<div className="text-2xl font-black">{value}</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,18 @@
import { Card, CardHeader, CardContent } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
title: string;
children: ReactNode;
};
export function FormPanel({ title, children }: Props) {
return (
<Card className="border border-default-100 bg-default-50/20">
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">{title}</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 px-5 py-5">{children}</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,21 @@
import { Card, CardHeader, CardContent } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
title: string;
children: ReactNode;
className?: string;
};
export function ListPanel({ title, children, className }: Props) {
return (
<Card className={`border border-default-100 bg-default-50/20 ${className ?? ''}`}>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">{title}</h3>
</CardHeader>
<CardContent className="px-5 py-5">
{children}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import { Card, CardContent } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
lines?: number;
children?: ReactNode;
};
export function LoadingSkeleton({ lines = 3, children }: Props) {
if (children) {
return (
<div className="flex flex-col gap-3">
{Array.from({ length: lines }).map((_, i) => (
<Card key={i} className="animate-pulse border border-default-100 bg-default-50/30">
<CardContent className="p-4">{children}</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="flex flex-col gap-3">
{Array.from({ length: lines }).map((_, i) => (
<Card key={i} className="animate-pulse border border-default-100 bg-default-50/30">
<CardContent className="p-5">
<div className="h-4 w-3/4 rounded-lg bg-default-200/50" />
{i < 2 && <div className="mt-3 h-3 w-1/2 rounded-lg bg-default-200/30" />}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Card, CardHeader, CardContent } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
title: string;
subtitle?: string;
children: ReactNode;
action?: ReactNode;
};
export function SectionCard({ title, subtitle, children, action }: Props) {
return (
<Card className="border border-default-100 bg-gradient-to-b from-default-50/40 to-background">
<CardHeader className="flex items-start justify-between gap-4 px-6 pt-6 pb-0">
<div className="min-w-0">
<h2 className="text-xl font-bold tracking-tight">{title}</h2>
{subtitle && <p className="mt-1 text-small text-default-500">{subtitle}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</CardHeader>
<CardContent className="px-6 py-5">{children}</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,22 @@
import { Card, CardContent } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
icon: ReactNode;
label: string;
value: string;
};
export function StatCard({ icon, label, value }: Props) {
return (
<Card className="border border-default-100 bg-default-50/20">
<CardContent className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-2 text-tiny uppercase tracking-widest text-default-500">
<span className="text-primary-400">{icon}</span>
{label}
</div>
<div className="text-2xl font-bold tracking-tight">{value}</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,93 @@
export type AppConfig = {
baseRoot?: string;
baseApi?: string;
baseAuth?: string;
baseDashboard?: string;
initialGuildId?: string;
};
export type User = {
username: string;
discriminator?: string;
isAdmin?: boolean;
};
export type Guild = {
id: string;
name: string;
icon?: string;
};
export type NavKey =
| 'overview'
| 'tickets'
| 'automod'
| 'welcome'
| 'dynamicvoice'
| 'birthday'
| 'reactionroles'
| 'statuspage'
| 'serverstats'
| 'settings'
| 'modules'
| 'events'
| 'admin';
export type TicketRecord = {
id: string;
topic?: string;
status?: string;
category?: string;
priority?: string;
createdAt?: string;
claimedById?: string | null;
};
export type StatusService = {
id: string;
name?: string;
url?: string;
status?: string;
uptimePct?: number;
lastCheckedAt?: string;
};
export type EventItem = {
id: string;
title: string;
description?: string;
startsAt?: string;
reminderMinutes?: number;
channelId?: string;
};
export type ReactionRoleSet = {
id: string;
title?: string;
description?: string;
channelId?: string;
messageId?: string;
entries?: Array<{ emoji: string; roleId: string; label?: string; description?: string }>;
};
export type ModuleItem = {
key: string;
name: string;
description?: string;
enabled: boolean;
};
export type LogEntry = {
level?: string;
category?: string;
message?: string;
timestamp?: string;
};
export type SettingsState = Record<string, any>;
export type NavItem = {
key: NavKey;
label: string;
icon: React.ReactNode;
};

29
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { AppConfig } from '../types';
const config: AppConfig = (window as any).__PAPO__ || {};
export function apiUrl(path: string) {
const base = config.baseApi || '/api';
return `${base}${path}`;
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(apiUrl(path), {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers || {})
}
});
if (response.status === 401) {
window.location.href = `${config.baseAuth || '/auth'}/discord`;
throw new Error('unauthorized');
}
if (!response.ok) {
throw new Error(`request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import {
Activity, AudioLines, CalendarDays, Home, Logs, MessageSquare,
Puzzle, RadioTower, ScanSearch, Settings, Shield, Sparkles, Tag,
Ticket, Wrench
} from 'lucide-react';
import type { NavItem } from '../types';
export const navItems: NavItem[] = [
{ key: 'overview', label: 'Übersicht', icon: <Home size={20} /> },
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={20} /> },
{ key: 'automod', label: 'Automod', icon: <Shield size={20} /> },
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={20} /> },
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={20} /> },
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={20} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={20} /> },
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={20} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={20} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={20} /> },
{ key: 'modules', label: 'Module', icon: <Puzzle size={20} /> },
{ key: 'events', label: 'Events', icon: <CalendarDays size={20} /> },
{ key: 'admin', label: 'Admin', icon: <Wrench size={20} /> }
];

View File

@@ -0,0 +1,14 @@
import type { Guild } from '../types';
export function formatDate(value?: string | number | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.toLocaleDateString('de-DE')} ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
}
export function guildIconUrl(guild?: Guild | null) {
if (!guild) return undefined;
if (guild.icon) return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
return undefined;
}