feat: vollständiges HeroUI v3 Redesign — Types, Utils, Layout, Sections, Dark SaaS Design
Some checks failed
Deploy Discord Bot / deploy (push) Has been cancelled
Some checks failed
Deploy Discord Bot / deploy (push) Has been cancelled
This commit is contained in:
1367
frontend/src/App.tsx
1367
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
82
frontend/src/components/layout/Sidebar.tsx
Normal file
82
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/layout/Topbar.tsx
Normal file
24
frontend/src/components/layout/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/shared/ActivityTile.tsx
Normal file
26
frontend/src/components/shared/ActivityTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/shared/FormPanel.tsx
Normal file
18
frontend/src/components/shared/FormPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/shared/ListPanel.tsx
Normal file
21
frontend/src/components/shared/ListPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/shared/LoadingSkeleton.tsx
Normal file
34
frontend/src/components/shared/LoadingSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/shared/SectionCard.tsx
Normal file
24
frontend/src/components/shared/SectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/shared/StatCard.tsx
Normal file
22
frontend/src/components/shared/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/types/index.ts
Normal file
93
frontend/src/types/index.ts
Normal 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
29
frontend/src/utils/api.ts
Normal 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>;
|
||||
}
|
||||
23
frontend/src/utils/constants.tsx
Normal file
23
frontend/src/utils/constants.tsx
Normal 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} /> }
|
||||
];
|
||||
14
frontend/src/utils/formatters.ts
Normal file
14
frontend/src/utils/formatters.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user