feat: Dark Mode + Support Login, Registration, Music, Ticket Actions, KB/Automation CRUD, Service/Stat-Item-Management
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:
@@ -5,9 +5,10 @@ import {
|
|||||||
TextArea
|
TextArea
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import {
|
import {
|
||||||
Activity, AudioLines, Bot, Cable, CalendarDays, ChevronRight, Command,
|
Activity, AudioLines, Bot, Cable, CalendarDays, ChevronRight, ClipboardList,
|
||||||
Home, LifeBuoy, Logs, MessageSquare, Puzzle, RadioTower, ScanSearch,
|
Command, Home, LifeBuoy, LogIn, Logs, MessageSquare, Music, Pencil,
|
||||||
Settings, Shield, Sparkles, Tag, Ticket, UserRound, Users, Wrench
|
Puzzle, RadioTower, ScanSearch, Settings, Shield, Sparkles, Tag,
|
||||||
|
Ticket, Trash2, UserRound, Users, Wrench
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiFetch } from './utils/api';
|
import { apiFetch } from './utils/api';
|
||||||
import { formatDate, guildIconUrl } from './utils/formatters';
|
import { formatDate, guildIconUrl } from './utils/formatters';
|
||||||
@@ -22,7 +23,9 @@ import { ActivityTile } from './components/shared/ActivityTile';
|
|||||||
import { LoadingSkeleton } from './components/shared/LoadingSkeleton';
|
import { LoadingSkeleton } from './components/shared/LoadingSkeleton';
|
||||||
import type {
|
import type {
|
||||||
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
|
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
|
||||||
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState
|
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
||||||
|
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
||||||
|
RegisterApplication, MusicSession
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
||||||
@@ -59,6 +62,20 @@ function App() {
|
|||||||
const [statsDraft, setStatsDraft] = useState<any>(null);
|
const [statsDraft, setStatsDraft] = useState<any>(null);
|
||||||
const [reactionDraft, setReactionDraft] = useState({ title: '', channelId: '', entries: '' });
|
const [reactionDraft, setReactionDraft] = useState({ title: '', channelId: '', entries: '' });
|
||||||
|
|
||||||
|
const [supportLogin, setSupportLogin] = useState<{ config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null>(null);
|
||||||
|
const [registerForms, setRegisterForms] = useState<RegisterForm[]>([]);
|
||||||
|
const [registerApps, setRegisterApps] = useState<RegisterApplication[]>([]);
|
||||||
|
const [formDraft, setFormDraft] = useState({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
|
||||||
|
const [editingFormId, setEditingFormId] = useState<string | null>(null);
|
||||||
|
const [registerTab, setRegisterTab] = useState('forms');
|
||||||
|
const [musicStatus, setMusicStatus] = useState<{ activeGuilds: number; sessions: MusicSession[] }>({ activeGuilds: 0, sessions: [] });
|
||||||
|
const [kbEditDraft, setKbEditDraft] = useState<{ id: string; title: string; keywords: string; content: string } | null>(null);
|
||||||
|
const [automationEditDraft, setAutomationEditDraft] = useState<{ id: string; name: string; conditionValue: string; actionValue: string } | null>(null);
|
||||||
|
const [statusServiceDraft, setStatusServiceDraft] = useState<{ id?: string; name: string; url: string; status: string }>({ name: '', url: '', status: 'unknown' });
|
||||||
|
const [statsItemDraft, setStatsItemDraft] = useState<{ id?: string; label: string; type: string }>({ label: '', type: 'members' });
|
||||||
|
const [ticketDetail, setTicketDetail] = useState<TicketRecord | null>(null);
|
||||||
|
const [ticketMessages, setTicketMessages] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash.replace('#', '') as NavKey;
|
const hash = window.location.hash.replace('#', '') as NavKey;
|
||||||
if (navItems.some((item) => item.key === hash)) setSection(hash);
|
if (navItems.some((item) => item.key === hash)) setSection(hash);
|
||||||
@@ -82,7 +99,8 @@ function App() {
|
|||||||
setStatusMessage('Lade Daten...');
|
setStatusMessage('Lade Daten...');
|
||||||
try {
|
try {
|
||||||
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
|
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
|
||||||
birthdayRes, reactionRes, statusRes, statsRes, eventsRes] = await Promise.all([
|
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
|
||||||
|
registerFormsRes, registerAppsRes] = await Promise.all([
|
||||||
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
|
||||||
@@ -93,10 +111,14 @@ function App() {
|
|||||||
apiFetch<any>(`/reactionroles?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/reactionroles?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/statuspage?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/statuspage?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/server-stats?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/server-stats?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`)
|
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`),
|
||||||
|
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
|
||||||
|
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
|
||||||
|
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`)
|
||||||
]);
|
]);
|
||||||
setGuildInfo(guildInfoRes.guild || null);
|
setGuildInfo(guildInfoRes.guild || null);
|
||||||
setOverview(overviewRes);
|
setOverview(overviewRes);
|
||||||
|
setMusicStatus(overviewRes.music || { activeGuilds: 0, sessions: [] });
|
||||||
setActivity(activityRes.activity || {});
|
setActivity(activityRes.activity || {});
|
||||||
setLogs(logsRes.logs || []);
|
setLogs(logsRes.logs || []);
|
||||||
setSettings(settingsRes.settings || {});
|
setSettings(settingsRes.settings || {});
|
||||||
@@ -108,6 +130,9 @@ function App() {
|
|||||||
setStatsDraft(statsRes.config || { items: [] });
|
setStatsDraft(statsRes.config || { items: [] });
|
||||||
setStatusDraft(statusRes.config || { services: [] });
|
setStatusDraft(statusRes.config || { services: [] });
|
||||||
setEvents(eventsRes.events || []);
|
setEvents(eventsRes.events || []);
|
||||||
|
setSupportLogin(supportLoginRes);
|
||||||
|
setRegisterForms(registerFormsRes.forms || []);
|
||||||
|
setRegisterApps(registerAppsRes.applications || []);
|
||||||
setReactionDraft({ title: '', channelId: '', entries: '' });
|
setReactionDraft({ title: '', channelId: '', entries: '' });
|
||||||
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
@@ -228,6 +253,136 @@ function App() {
|
|||||||
await loadTicketData(currentGuildId);
|
await loadTicketData(currentGuildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateKbArticle(id: string) {
|
||||||
|
if (!kbEditDraft) return;
|
||||||
|
await apiFetch(`/kb/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ guildId: currentGuildId, title: kbEditDraft.title, keywords: kbEditDraft.keywords, content: kbEditDraft.content })
|
||||||
|
});
|
||||||
|
setKbEditDraft(null);
|
||||||
|
setStatusMessage('KB-Artikel aktualisiert');
|
||||||
|
await loadTicketData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKbArticle(id: string) {
|
||||||
|
await apiFetch(`/kb/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||||
|
setStatusMessage('KB-Artikel gelöscht');
|
||||||
|
await loadTicketData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAutomation(id: string) {
|
||||||
|
if (!automationEditDraft) return;
|
||||||
|
await apiFetch(`/automations/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ guildId: currentGuildId, name: automationEditDraft.name, condition: { category: automationEditDraft.conditionValue }, action: { type: 'reminder', message: automationEditDraft.actionValue }, active: true })
|
||||||
|
});
|
||||||
|
setAutomationEditDraft(null);
|
||||||
|
setStatusMessage('Automation aktualisiert');
|
||||||
|
await loadTicketData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAutomation(id: string) {
|
||||||
|
await apiFetch(`/automations/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||||
|
setStatusMessage('Automation gelöscht');
|
||||||
|
await loadTicketData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSupportLogin() {
|
||||||
|
if (!supportLogin) return;
|
||||||
|
await apiFetch('/tickets/support-login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ guildId: currentGuildId, ...supportLogin.config })
|
||||||
|
});
|
||||||
|
setStatusMessage('Support Login gespeichert');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm() {
|
||||||
|
const fields: RegisterFormField[] = formDraft.fields.split('\n').filter(Boolean).map((line) => {
|
||||||
|
const parts = line.split('|').map((s) => s.trim());
|
||||||
|
return { label: parts[0] || 'Feld', type: (parts[1] || 'text') as any, required: parts[2] === 'required', options: parts[3] ? parts[3].split(',').map((s) => s.trim()) : undefined };
|
||||||
|
});
|
||||||
|
const body: any = { guildId: currentGuildId, name: formDraft.name, description: formDraft.description, reviewChannelId: formDraft.reviewChannelId || undefined, notifyRoleIds: formDraft.notifyRoleIds.split(',').map((s) => s.trim()).filter(Boolean), fields, isActive: true };
|
||||||
|
if (editingFormId) {
|
||||||
|
await apiFetch(`/register/forms/${editingFormId}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
setStatusMessage('Formular aktualisiert');
|
||||||
|
} else {
|
||||||
|
await apiFetch('/register/forms', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
setStatusMessage('Formular erstellt');
|
||||||
|
}
|
||||||
|
setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' });
|
||||||
|
setEditingFormId(null);
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteForm(id: string) {
|
||||||
|
await apiFetch(`/register/forms/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||||
|
setStatusMessage('Formular gelöscht');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFormPanel(formId: string) {
|
||||||
|
if (!supportLogin?.config?.panelChannelId) { setStatusMessage('Bitte zuerst Support Login konfigurieren'); return; }
|
||||||
|
await apiFetch(`/register/forms/${formId}/panel`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: supportLogin.config.panelChannelId }) });
|
||||||
|
setStatusMessage('Panel gesendet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addStatusService() {
|
||||||
|
if (!statusServiceDraft.name) return;
|
||||||
|
await apiFetch('/statuspage/service', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ guildId: currentGuildId, name: statusServiceDraft.name, url: statusServiceDraft.url, status: statusServiceDraft.status })
|
||||||
|
});
|
||||||
|
setStatusServiceDraft({ name: '', url: '', status: 'unknown' });
|
||||||
|
setStatusMessage('Service hinzugefügt');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStatusService(id: string) {
|
||||||
|
await apiFetch(`/statuspage/service/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||||
|
setStatusMessage('Service entfernt');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addStatsItem() {
|
||||||
|
if (!statsItemDraft.label) return;
|
||||||
|
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
|
||||||
|
const items = [...(draft.items || []), { key: statsItemDraft.label.toLowerCase().replace(/\s+/g, '_'), label: statsItemDraft.label, type: statsItemDraft.type }];
|
||||||
|
const updated = { ...draft, items };
|
||||||
|
setStatsDraft(updated);
|
||||||
|
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
|
||||||
|
setStatsItemDraft({ label: '', type: 'members' });
|
||||||
|
setStatusMessage('Stat-Item hinzugefügt');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStatsItem(index: number) {
|
||||||
|
const draft = statsDraft || { enabled: true, categoryName: '', refreshMinutes: 10, items: [] };
|
||||||
|
const items = (draft.items || []).filter((_: any, i: number) => i !== index);
|
||||||
|
const updated = { ...draft, items };
|
||||||
|
setStatsDraft(updated);
|
||||||
|
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: updated }) });
|
||||||
|
setStatusMessage('Stat-Item entfernt');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTicketMessages(ticketId: string) {
|
||||||
|
const res = await apiFetch<any>(`/tickets/${ticketId}/messages`);
|
||||||
|
setTicketMessages(res.messages || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTicketStatus(ticketId: string, status: string) {
|
||||||
|
await apiFetch(`/tickets/${ticketId}/status`, { method: 'POST', body: JSON.stringify({ status }) });
|
||||||
|
setStatusMessage('Status aktualisiert');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTicket(ticketId: string) {
|
||||||
|
await apiFetch(`/tickets/${ticketId}/close`, { method: 'POST', body: JSON.stringify({ guildId: currentGuildId }) });
|
||||||
|
setStatusMessage('Ticket geschlossen');
|
||||||
|
await loadGuildData(currentGuildId);
|
||||||
|
}
|
||||||
|
|
||||||
const selectedGuild = guilds.find((g) => g.id === currentGuildId) || null;
|
const selectedGuild = guilds.find((g) => g.id === currentGuildId) || null;
|
||||||
const moduleFlags = guildInfo?.modules || {};
|
const moduleFlags = guildInfo?.modules || {};
|
||||||
|
|
||||||
@@ -418,7 +573,35 @@ function App() {
|
|||||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||||
<ListPanel title="Offene Tickets">
|
<ListPanel title="Offene Tickets">
|
||||||
{tickets.length ? tickets.map((t, i) => (
|
{tickets.length ? tickets.map((t, i) => (
|
||||||
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">{t.topic || 'Ticket'} · {t.status || 'open'}</div>
|
<Card key={i} className="mb-3 border border-default-100 bg-default-50/20">
|
||||||
|
<CardContent className="flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold text-small">{t.topic || 'Ticket'}</div>
|
||||||
|
<Chip size="sm" variant="flat" color={t.status === 'open' ? 'warning' : t.status === 'closed' ? 'default' : 'primary'}>
|
||||||
|
{t.status || 'open'}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
<div className="text-tiny text-default-400">
|
||||||
|
{t.category || 'Allgemein'} · {formatDate(t.createdAt)}
|
||||||
|
{t.claimedById ? ` · Claimed` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
className="rounded-lg border border-default-200 bg-default-50 px-2 py-1 text-tiny outline-none"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => { if (e.target.value) updateTicketStatus(t.id, e.target.value); }}
|
||||||
|
>
|
||||||
|
<option value="">Status ändern</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="in-progress">In Progress</option>
|
||||||
|
<option value="waiting">Warten auf User</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
<Button size="sm" variant="flat" color="danger" onPress={() => closeTicket(t.id)}>Schließen</Button>
|
||||||
|
<Button size="sm" variant="flat" onPress={() => { setTicketDetail(t); loadTicketMessages(t.id); }}>Details</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)) : <p className="text-small text-default-400">Keine Tickets</p>}
|
)) : <p className="text-small text-default-400">Keine Tickets</p>}
|
||||||
</ListPanel>
|
</ListPanel>
|
||||||
<ListPanel title="Support-Übersicht">
|
<ListPanel title="Support-Übersicht">
|
||||||
@@ -426,6 +609,31 @@ function App() {
|
|||||||
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">{t.category || 'Allgemein'} · {formatDate(t.createdAt)}</div>
|
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">{t.category || 'Allgemein'} · {formatDate(t.createdAt)}</div>
|
||||||
)) : <p className="text-small text-default-400">Keine Daten</p>}
|
)) : <p className="text-small text-default-400">Keine Daten</p>}
|
||||||
</ListPanel>
|
</ListPanel>
|
||||||
|
{ticketDetail && (
|
||||||
|
<Card className="border border-default-100 xl:col-span-2">
|
||||||
|
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||||
|
<h3 className="text-lg font-bold">{ticketDetail.topic || 'Ticket-Details'}</h3>
|
||||||
|
<Button size="sm" variant="light" onPress={() => setTicketDetail(null)}>Schließen</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3 p-5">
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-small">
|
||||||
|
<div><span className="text-default-400">Status:</span> {ticketDetail.status}</div>
|
||||||
|
<div><span className="text-default-400">Priorität:</span> {ticketDetail.priority || 'normal'}</div>
|
||||||
|
<div><span className="text-default-400">Kategorie:</span> {ticketDetail.category || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-1" />
|
||||||
|
<h4 className="text-small font-semibold">Nachrichten ({ticketMessages.length})</h4>
|
||||||
|
<div className="max-h-60 space-y-2 overflow-auto">
|
||||||
|
{ticketMessages.length ? ticketMessages.map((msg, i) => (
|
||||||
|
<div key={i} className="rounded-lg bg-default-100/40 px-3 py-2 text-small">
|
||||||
|
<span className="text-tiny text-default-400">{msg.author?.username || msg.authorId}: </span>
|
||||||
|
{msg.content || '(Embed)'}
|
||||||
|
</div>
|
||||||
|
)) : <p className="text-tiny text-default-400">Keine Nachrichten geladen</p>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -434,7 +642,16 @@ function App() {
|
|||||||
{['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].map((state) => (
|
{['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].map((state) => (
|
||||||
<ListPanel key={state} title={state.replaceAll('_', ' ')}>
|
<ListPanel key={state} title={state.replaceAll('_', ' ')}>
|
||||||
{(pipeline[state] || []).length ? (pipeline[state] || []).map((t, i) => (
|
{(pipeline[state] || []).length ? (pipeline[state] || []).map((t, i) => (
|
||||||
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">{t.topic || t.id}</div>
|
<Card key={i} className="mb-2 border border-default-100 bg-default-50/20">
|
||||||
|
<CardContent className="p-3 text-small">
|
||||||
|
<div className="font-medium">{t.topic || t.id}</div>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
<Button size="sm" variant="flat" className="!h-6 !min-w-0 !px-2 text-tiny" onPress={() => updateTicketStatus(t.id, state === 'neu' ? 'in-progress' : state === 'in_bearbeitung' ? 'warten_auf_user' : state === 'warten_auf_user' ? 'erledigt' : 'closed')}>
|
||||||
|
{state === 'erledigt' ? 'Schließen' : 'Weiter'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)) : <p className="text-small text-default-400">Keine Tickets</p>}
|
)) : <p className="text-small text-default-400">Keine Tickets</p>}
|
||||||
</ListPanel>
|
</ListPanel>
|
||||||
))}
|
))}
|
||||||
@@ -464,17 +681,44 @@ function App() {
|
|||||||
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
|
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
|
||||||
<ListPanel title="Regeln">
|
<ListPanel title="Regeln">
|
||||||
{automations.length ? automations.map((rule, i) => (
|
{automations.length ? automations.map((rule, i) => (
|
||||||
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
|
<Card key={i} className="mb-3 border border-default-100 bg-default-50/20">
|
||||||
{rule.name || 'Automation'} · {rule.active === false ? 'inaktiv' : 'aktiv'}
|
<CardContent className="flex flex-col gap-2 p-4">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold">{rule.name || 'Automation'}</div>
|
||||||
|
<Chip size="sm" variant="flat" color={rule.active !== false ? 'success' : 'default'}>
|
||||||
|
{rule.active !== false ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setAutomationEditDraft({ id: rule.id, name: rule.name || '', conditionValue: rule.condition?.category || '', actionValue: rule.action?.message || '' })}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteAutomation(rule.id)}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)) : <p className="text-small text-default-400">Keine Regeln</p>}
|
)) : <p className="text-small text-default-400">Keine Regeln</p>}
|
||||||
</ListPanel>
|
</ListPanel>
|
||||||
<FormPanel title="Neue Automation">
|
{automationEditDraft ? (
|
||||||
<Input label="Name" value={automationDraft.name} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, name: v }))} />
|
<FormPanel title="Automation bearbeiten">
|
||||||
<Input label="Kategorie / Zustand" value={automationDraft.conditionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, conditionValue: v }))} />
|
<Input label="Name" value={automationEditDraft.name} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, name: v }))} />
|
||||||
<TextArea label="Aktion / Nachricht" value={automationDraft.actionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, actionValue: v }))} />
|
<Input label="Kategorie / Zustand" value={automationEditDraft.conditionValue} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, conditionValue: v }))} />
|
||||||
<Button color="primary" onPress={saveAutomation}>Automation speichern</Button>
|
<TextArea label="Aktion / Nachricht" value={automationEditDraft.actionValue} onValueChange={(v) => setAutomationEditDraft((s) => ({ ...s, actionValue: v }))} />
|
||||||
</FormPanel>
|
<div className="flex gap-2">
|
||||||
|
<Button color="primary" onPress={() => updateAutomation(automationEditDraft.id)}>Aktualisieren</Button>
|
||||||
|
<Button variant="flat" onPress={() => setAutomationEditDraft(null)}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</FormPanel>
|
||||||
|
) : (
|
||||||
|
<FormPanel title="Neue Automation">
|
||||||
|
<Input label="Name" value={automationDraft.name} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, name: v }))} />
|
||||||
|
<Input label="Kategorie / Zustand" value={automationDraft.conditionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, conditionValue: v }))} />
|
||||||
|
<TextArea label="Aktion / Nachricht" value={automationDraft.actionValue} onValueChange={(v) => setAutomationDraft((s) => ({ ...s, actionValue: v }))} />
|
||||||
|
<Button color="primary" onPress={saveAutomation}>Automation speichern</Button>
|
||||||
|
</FormPanel>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -482,22 +726,103 @@ function App() {
|
|||||||
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
|
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
|
||||||
<ListPanel title="Artikel">
|
<ListPanel title="Artikel">
|
||||||
{kbArticles.length ? kbArticles.map((article, i) => (
|
{kbArticles.length ? kbArticles.map((article, i) => (
|
||||||
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
|
<Card key={i} className="mb-3 border border-default-100 bg-default-50/20">
|
||||||
{article.title || 'Artikel'} · {(article.keywords || []).join(', ')}
|
<CardContent className="flex flex-col gap-2 p-4">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold">{article.title || 'Artikel'}</div>
|
||||||
|
<Chip size="sm" variant="flat">{article.keywords?.length || 0} Keywords</Chip>
|
||||||
|
</div>
|
||||||
|
<div className="text-small text-default-400">{article.content?.slice(0, 120)}...</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="flat" startContent={<Pencil size={14} />} onPress={() => setKbEditDraft({ id: article.id, title: article.title || '', keywords: (article.keywords || []).join(', '), content: article.content || '' })}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="flat" color="danger" startContent={<Trash2 size={14} />} onPress={() => deleteKbArticle(article.id)}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)) : <p className="text-small text-default-400">Keine Artikel</p>}
|
)) : <p className="text-small text-default-400">Keine Artikel</p>}
|
||||||
</ListPanel>
|
</ListPanel>
|
||||||
<FormPanel title="Neuer KB-Artikel">
|
{kbEditDraft ? (
|
||||||
<Input label="Titel" value={kbDraft.title} onValueChange={(v) => setKbDraft((s) => ({ ...s, title: v }))} />
|
<FormPanel title="Artikel bearbeiten">
|
||||||
<Input label="Keywords" value={kbDraft.keywords} onValueChange={(v) => setKbDraft((s) => ({ ...s, keywords: v }))} />
|
<Input label="Titel" value={kbEditDraft.title} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, title: v }))} />
|
||||||
<TextArea label="Inhalt" minRows={5} value={kbDraft.content} onValueChange={(v) => setKbDraft((s) => ({ ...s, content: v }))} />
|
<Input label="Keywords" value={kbEditDraft.keywords} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, keywords: v }))} />
|
||||||
<Button color="primary" onPress={saveKbArticle}>Artikel speichern</Button>
|
<TextArea label="Inhalt" minRows={5} value={kbEditDraft.content} onValueChange={(v) => setKbEditDraft((s) => ({ ...s, content: v }))} />
|
||||||
</FormPanel>
|
<div className="flex gap-2">
|
||||||
|
<Button color="primary" onPress={() => updateKbArticle(kbEditDraft.id)}>Aktualisieren</Button>
|
||||||
|
<Button variant="flat" onPress={() => setKbEditDraft(null)}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</FormPanel>
|
||||||
|
) : (
|
||||||
|
<FormPanel title="Neuer KB-Artikel">
|
||||||
|
<Input label="Titel" value={kbDraft.title} onValueChange={(v) => setKbDraft((s) => ({ ...s, title: v }))} />
|
||||||
|
<Input label="Keywords" value={kbDraft.keywords} onValueChange={(v) => setKbDraft((s) => ({ ...s, keywords: v }))} />
|
||||||
|
<TextArea label="Inhalt" minRows={5} value={kbDraft.content} onValueChange={(v) => setKbDraft((s) => ({ ...s, content: v }))} />
|
||||||
|
<Button color="primary" onPress={saveKbArticle}>Artikel speichern</Button>
|
||||||
|
</FormPanel>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section === 'supportlogin' && (
|
||||||
|
<SectionCard title="Support Login" subtitle="Login-Panel für Supporter konfigurieren">
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[420px_1fr]">
|
||||||
|
<FormPanel title="Panel-Konfiguration">
|
||||||
|
<Switch
|
||||||
|
isSelected={supportLogin?.config?.autoRefresh !== false}
|
||||||
|
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, autoRefresh: v } } : s)}
|
||||||
|
>
|
||||||
|
Auto-Refresh aktiv
|
||||||
|
</Switch>
|
||||||
|
<Input
|
||||||
|
label="Panel Channel ID"
|
||||||
|
value={supportLogin?.config?.panelChannelId || ''}
|
||||||
|
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, panelChannelId: v } } : s)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Titel"
|
||||||
|
value={supportLogin?.config?.title || 'Support Login'}
|
||||||
|
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, title: v } } : s)}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label="Beschreibung"
|
||||||
|
value={supportLogin?.config?.description || ''}
|
||||||
|
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, description: v } } : s)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Login Button Label"
|
||||||
|
value={supportLogin?.config?.loginLabel || 'Ich bin jetzt im Support'}
|
||||||
|
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, loginLabel: v } } : s)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Logout Button Label"
|
||||||
|
value={supportLogin?.config?.logoutLabel || 'Ich bin nicht mehr im Support'}
|
||||||
|
onValueChange={(v) => setSupportLogin((s) => s ? { ...s, config: { ...s.config, logoutLabel: v } } : s)}
|
||||||
|
/>
|
||||||
|
<Separator className="my-1" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button color="primary" onPress={saveSupportLogin}>Speichern & Panel senden</Button>
|
||||||
|
</div>
|
||||||
|
</FormPanel>
|
||||||
|
<ListPanel title="Aktive Supporter">
|
||||||
|
{supportLogin?.status?.active?.length ? supportLogin.status.active.map((s, i) => (
|
||||||
|
<div key={i} className="mb-2 flex items-center gap-3 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
|
||||||
|
<Chip color="success" size="sm" variant="dot" />
|
||||||
|
{s.username || s.userId}
|
||||||
|
</div>
|
||||||
|
)) : <p className="text-small text-default-400">Keine aktiven Supporter</p>}
|
||||||
|
<p className="mt-3 text-tiny text-default-400">
|
||||||
|
Support Role ID: {supportLogin?.supportRoleId || 'Nicht gesetzt'}
|
||||||
|
</p>
|
||||||
|
</ListPanel>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'automod' && (
|
{section === 'automod' && (
|
||||||
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
|
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
@@ -608,10 +933,25 @@ function App() {
|
|||||||
</FormPanel>
|
</FormPanel>
|
||||||
<ListPanel title="Services">
|
<ListPanel title="Services">
|
||||||
{((statusDraft?.services || statuspage?.services || []) as StatusService[]).length ? ((statusDraft?.services || statuspage?.services || []) as StatusService[]).map((service, i) => (
|
{((statusDraft?.services || statuspage?.services || []) as StatusService[]).length ? ((statusDraft?.services || statuspage?.services || []) as StatusService[]).map((service, i) => (
|
||||||
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
|
<Card key={i} className="mb-2 border border-default-100 bg-default-50/20">
|
||||||
{service.name || 'Service'} · {service.status || 'unknown'} · {service.url || ''}
|
<CardContent className="flex items-center justify-between p-3 text-small">
|
||||||
</div>
|
<div>
|
||||||
|
<span className="font-medium">{service.name || 'Service'}</span>
|
||||||
|
<span className="ml-2 text-default-400">· {service.status} · {service.url || ''}</span>
|
||||||
|
</div>
|
||||||
|
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatusService(service.id)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)) : <p className="text-small text-default-400">Keine Services</p>}
|
)) : <p className="text-small text-default-400">Keine Services</p>}
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<h4 className="text-small font-semibold mb-2">Service hinzufügen</h4>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input placeholder="Name" value={statusServiceDraft.name} onValueChange={(v) => setStatusServiceDraft((s) => ({ ...s, name: v }))} />
|
||||||
|
<Input placeholder="URL" value={statusServiceDraft.url} onValueChange={(v) => setStatusServiceDraft((s) => ({ ...s, url: v }))} />
|
||||||
|
<Button size="sm" color="primary" onPress={addStatusService}>Hinzufügen</Button>
|
||||||
|
</div>
|
||||||
</ListPanel>
|
</ListPanel>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -628,15 +968,159 @@ function App() {
|
|||||||
</FormPanel>
|
</FormPanel>
|
||||||
<ListPanel title="Items">
|
<ListPanel title="Items">
|
||||||
{((statsDraft?.items || serverStats?.items || []) as any[]).length ? ((statsDraft?.items || serverStats?.items || []) as any[]).map((item, i) => (
|
{((statsDraft?.items || serverStats?.items || []) as any[]).length ? ((statsDraft?.items || serverStats?.items || []) as any[]).map((item, i) => (
|
||||||
<div key={i} className="mb-2 rounded-xl border border-default-100 bg-default-50/20 px-4 py-3 text-small">
|
<Card key={i} className="mb-2 border border-default-100 bg-default-50/20">
|
||||||
{item.label || item.key || 'Stat'} · {item.type || '-'}
|
<CardContent className="flex items-center justify-between p-3 text-small">
|
||||||
</div>
|
<div>
|
||||||
|
<span className="font-medium">{item.label || item.key || 'Stat'}</span>
|
||||||
|
<span className="ml-2 text-default-400">· {item.type || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteStatsItem(i)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)) : <p className="text-small text-default-400">Keine Items</p>}
|
)) : <p className="text-small text-default-400">Keine Items</p>}
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<h4 className="text-small font-semibold mb-2">Item hinzufügen</h4>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input placeholder="Label" value={statsItemDraft.label} onValueChange={(v) => setStatsItemDraft((s) => ({ ...s, label: v }))} />
|
||||||
|
<select
|
||||||
|
className="rounded-xl border border-default-200 bg-default-50 px-3 py-2 text-sm outline-none"
|
||||||
|
value={statsItemDraft.type}
|
||||||
|
onChange={(e) => setStatsItemDraft((s) => ({ ...s, type: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="members">Mitglieder</option>
|
||||||
|
<option value="channels">Channels</option>
|
||||||
|
<option value="roles">Rollen</option>
|
||||||
|
<option value="boosts">Boosts</option>
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
<Button size="sm" color="primary" onPress={addStatsItem}>Hinzufügen</Button>
|
||||||
|
</div>
|
||||||
</ListPanel>
|
</ListPanel>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section === 'register' && (
|
||||||
|
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Anträge verwalten.">
|
||||||
|
<Tabs aria-label="Register Tabs" color="primary" selectedKey={registerTab} variant="bordered" onSelectionChange={(key) => setRegisterTab(String(key))}>
|
||||||
|
<Tab key="forms" title="Formulare" />
|
||||||
|
<Tab key="apps" title="Anträge" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{registerTab === 'forms' && (
|
||||||
|
<div className="mt-5 grid gap-4 xl:grid-cols-[1fr_420px]">
|
||||||
|
<ListPanel title="Bestehende Formulare">
|
||||||
|
{registerForms.length ? registerForms.map((f) => (
|
||||||
|
<Card key={f.id} className="mb-3 border border-default-100 bg-default-50/20">
|
||||||
|
<CardContent className="flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold">{f.name}</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button isIconOnly size="sm" variant="light" onPress={() => {
|
||||||
|
setFormDraft({ name: f.name, description: f.description || '', reviewChannelId: f.reviewChannelId || '', notifyRoleIds: (f.notifyRoleIds || []).join(', '), fields: (f.fields || []).map((fd) => `${fd.label}|${fd.type}${fd.required ? '|required' : ''}${fd.options ? '|' + fd.options.join(',') : ''}`).join('\n') });
|
||||||
|
setEditingFormId(f.id);
|
||||||
|
}}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button isIconOnly size="sm" variant="light" color="danger" onPress={() => deleteForm(f.id)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-small text-default-400">{f.description || 'Keine Beschreibung'}</div>
|
||||||
|
<div className="flex items-center gap-2 text-tiny">
|
||||||
|
<Chip size="sm" variant="flat" color={f.isActive ? 'success' : 'default'}>{f.isActive ? 'Aktiv' : 'Inaktiv'}</Chip>
|
||||||
|
<span className="text-default-400">{f.fields?.length || 0} Felder</span>
|
||||||
|
<Button size="sm" variant="flat" onPress={() => sendFormPanel(f.id)}>Panel senden</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)) : <p className="text-small text-default-400">Keine Formulare</p>}
|
||||||
|
</ListPanel>
|
||||||
|
<FormPanel title={editingFormId ? 'Formular bearbeiten' : 'Neues Formular'}>
|
||||||
|
<Input label="Name" value={formDraft.name} onValueChange={(v) => setFormDraft((s) => ({ ...s, name: v }))} />
|
||||||
|
<Input label="Beschreibung" value={formDraft.description} onValueChange={(v) => setFormDraft((s) => ({ ...s, description: v }))} />
|
||||||
|
<Input label="Review Channel ID" value={formDraft.reviewChannelId} onValueChange={(v) => setFormDraft((s) => ({ ...s, reviewChannelId: v }))} />
|
||||||
|
<Input label="Benachrichtigungs-Rollen (Komma-getrennt)" value={formDraft.notifyRoleIds} onValueChange={(v) => setFormDraft((s) => ({ ...s, notifyRoleIds: v }))} />
|
||||||
|
<TextArea label="Felder (label|type|required|options)" minRows={6} value={formDraft.fields} onValueChange={(v) => setFormDraft((s) => ({ ...s, fields: v }))} />
|
||||||
|
<p className="text-tiny text-default-400">
|
||||||
|
Pro Zeile: label | type (text/paragraph/select/multi) | required | option1,option2
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button color="primary" onPress={saveForm}>{editingFormId ? 'Aktualisieren' : 'Erstellen'}</Button>
|
||||||
|
{editingFormId && <Button variant="flat" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>Abbrechen</Button>}
|
||||||
|
</div>
|
||||||
|
</FormPanel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{registerTab === 'apps' && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<ListPanel title="Eingegangene Anträge">
|
||||||
|
{registerApps.length ? registerApps.map((app) => (
|
||||||
|
<Card key={app.id} className="mb-3 border border-default-100 bg-default-50/20">
|
||||||
|
<CardContent className="flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold">{app.username || app.userId}</div>
|
||||||
|
<Chip size="sm" variant="flat" color={app.status === 'approved' ? 'success' : app.status === 'rejected' ? 'danger' : 'warning'}>
|
||||||
|
{app.status}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
<div className="text-tiny text-default-400">{formatDate(app.createdAt)}</div>
|
||||||
|
{app.answers?.length ? (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{app.answers.map((a, i) => (
|
||||||
|
<div key={i} className="text-small">
|
||||||
|
<span className="text-default-500">{a.label || 'Frage'}: </span>
|
||||||
|
{a.value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)) : <p className="text-small text-default-400">Keine Anträge</p>}
|
||||||
|
</ListPanel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'music' && (
|
||||||
|
<SectionCard title="Musik-Status" subtitle="Aktuelle Wiedergabe und Queues pro Guild.">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<StatCard icon={<Music size={18} />} label="Aktive Guilds" value={String(musicStatus.activeGuilds)} />
|
||||||
|
</div>
|
||||||
|
{musicStatus.sessions.length ? musicStatus.sessions.map((session) => (
|
||||||
|
<Card key={session.guildId} className="border border-default-100 bg-default-50/20">
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold">{session.guildId}</div>
|
||||||
|
<Chip size="sm" variant="flat">
|
||||||
|
Loop: {session.loop} · Queue: {session.queueLength}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
{session.nowPlaying ? (
|
||||||
|
<div className="rounded-xl bg-default-100/50 px-3 py-2 text-small">
|
||||||
|
<span className="text-default-500">Jetzt läuft: </span>
|
||||||
|
<a href={session.nowPlaying.url} target="_blank" rel="noopener noreferrer" className="text-primary-400 hover:underline">
|
||||||
|
{session.nowPlaying.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-small text-default-400">Keine aktive Wiedergabe</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)) : <p className="text-small text-default-400">Keine aktiven Musik-Sessions</p>}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'settings' && (
|
{section === 'settings' && (
|
||||||
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
|
<SectionCard title="Einstellungen & Logging" subtitle="Globale Guild-Settings und Log-Kategorien">
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Chip } from '@heroui/react';
|
import { Button, Chip } from '@heroui/react';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
guildName: string;
|
guildName: string;
|
||||||
@@ -7,6 +9,13 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function Topbar({ guildName, statusMessage, children }: Props) {
|
export function Topbar({ guildName, statusMessage, children }: Props) {
|
||||||
|
const [dark, setDark] = useState(() => localStorage.getItem('papo-theme') !== 'light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
localStorage.setItem('papo-theme', dark ? 'dark' : 'light');
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -17,6 +26,9 @@ export function Topbar({ guildName, statusMessage, children }: Props) {
|
|||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<Chip color="warning" size="sm" variant="flat">{statusMessage}</Chip>
|
<Chip color="warning" size="sm" variant="flat">{statusMessage}</Chip>
|
||||||
)}
|
)}
|
||||||
|
<Button isIconOnly radius="lg" size="sm" variant="light" onPress={() => setDark((d) => !d)}>
|
||||||
|
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
|
</Button>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type Guild = {
|
|||||||
export type NavKey =
|
export type NavKey =
|
||||||
| 'overview'
|
| 'overview'
|
||||||
| 'tickets'
|
| 'tickets'
|
||||||
|
| 'supportlogin'
|
||||||
| 'automod'
|
| 'automod'
|
||||||
| 'welcome'
|
| 'welcome'
|
||||||
| 'dynamicvoice'
|
| 'dynamicvoice'
|
||||||
@@ -28,6 +29,8 @@ export type NavKey =
|
|||||||
| 'reactionroles'
|
| 'reactionroles'
|
||||||
| 'statuspage'
|
| 'statuspage'
|
||||||
| 'serverstats'
|
| 'serverstats'
|
||||||
|
| 'register'
|
||||||
|
| 'music'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'modules'
|
| 'modules'
|
||||||
| 'events'
|
| 'events'
|
||||||
@@ -91,3 +94,55 @@ export type NavItem = {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SupportLoginConfig = {
|
||||||
|
panelChannelId?: string;
|
||||||
|
panelMessageId?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
loginLabel?: string;
|
||||||
|
logoutLabel?: string;
|
||||||
|
autoRefresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SupportLoginStatus = {
|
||||||
|
active: { userId: string; username?: string; loggedInAt?: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterFormField = {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'paragraph' | 'select' | 'multi';
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterForm = {
|
||||||
|
id: string;
|
||||||
|
guildId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
reviewChannelId?: string;
|
||||||
|
notifyRoleIds?: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
fields: RegisterFormField[];
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterApplication = {
|
||||||
|
id: string;
|
||||||
|
formId: string;
|
||||||
|
userId: string;
|
||||||
|
username?: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
answers: { fieldId?: string; label?: string; value: string }[];
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MusicSession = {
|
||||||
|
guildId: string;
|
||||||
|
nowPlaying?: { title: string; url: string } | null;
|
||||||
|
queueLength: number;
|
||||||
|
loop: 'off' | 'song' | 'queue';
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AudioLines, CalendarDays, Home, Logs, MessageSquare,
|
Activity, AudioLines, CalendarDays, ClipboardList, Home,
|
||||||
Puzzle, RadioTower, ScanSearch, Settings, Shield, Sparkles, Tag,
|
LogIn, Music, Puzzle, RadioTower, Settings, Shield, Sparkles,
|
||||||
Ticket, Wrench
|
Tag, Ticket, Wrench
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NavItem } from '../types';
|
import type { NavItem } from '../types';
|
||||||
|
|
||||||
export const navItems: NavItem[] = [
|
export const navItems: NavItem[] = [
|
||||||
{ key: 'overview', label: 'Übersicht', icon: <Home size={20} /> },
|
{ key: 'overview', label: 'Übersicht', icon: <Home size={20} /> },
|
||||||
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={20} /> },
|
{ key: 'tickets', label: 'Ticketsystem', icon: <Ticket size={20} /> },
|
||||||
|
{ key: 'supportlogin', label: 'Support Login', icon: <LogIn size={20} /> },
|
||||||
{ key: 'automod', label: 'Automod', icon: <Shield size={20} /> },
|
{ key: 'automod', label: 'Automod', icon: <Shield size={20} /> },
|
||||||
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={20} /> },
|
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={20} /> },
|
||||||
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={20} /> },
|
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={20} /> },
|
||||||
@@ -16,6 +17,8 @@ export const navItems: NavItem[] = [
|
|||||||
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={20} /> },
|
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={20} /> },
|
||||||
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={20} /> },
|
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={20} /> },
|
||||||
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={20} /> },
|
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={20} /> },
|
||||||
|
{ key: 'register', label: 'Registrierung', icon: <ClipboardList size={20} /> },
|
||||||
|
{ key: 'music', label: 'Musik', icon: <Music size={20} /> },
|
||||||
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={20} /> },
|
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={20} /> },
|
||||||
{ key: 'modules', label: 'Module', icon: <Puzzle size={20} /> },
|
{ key: 'modules', label: 'Module', icon: <Puzzle size={20} /> },
|
||||||
{ key: 'events', label: 'Events', icon: <CalendarDays size={20} /> },
|
{ key: 'events', label: 'Events', icon: <CalendarDays size={20} /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user