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:
1109
frontend/src/App.tsx
1109
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -3,134 +3,30 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
html, body, #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 {
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family:
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.papo-shell {
|
.scrollbar-thin {
|
||||||
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-width: 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 {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
height: 8px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.papo-scroll::-webkit-scrollbar-thumb {
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
background: rgba(249, 115, 22, 0.3);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 999px;
|
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