[deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s

This commit is contained in:
Pascal Prießnitz
2025-12-04 16:43:38 +01:00
parent 311f5a87f1
commit 22caa79b54
60 changed files with 2652 additions and 2999 deletions

1
public/ts/app.ts Normal file
View File

@@ -0,0 +1 @@
import './core/app.js';

View File

@@ -0,0 +1,22 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function initAdminSection(guildId: string) {
const section = document.getElementById('section-admin');
if (!section) return;
section.innerHTML = '<p class="muted">Lade Admin-Daten...</p>';
try {
const data: any = await api.settings(guildId);
section.innerHTML = `
<h2 class="section-title">Admin</h2>
<div class="card">
<p class="muted">Rohdaten (nur Admin):</p>
<pre style="white-space:pre-wrap;max-height:320px;overflow:auto;">${JSON.stringify(data, null, 2)}</pre>
</div>
`;
} catch (err) {
console.error(err);
section.innerHTML = '<div class="empty-state">Admin-Daten konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Admin-Daten', true);
}
}

View File

@@ -0,0 +1,88 @@
import { api } from '../services/api.js';
import { getConfig, getState, setState } from '../state/store.js';
import { showToast } from '../ui/toast.js';
import { renderOverview } from './overview.js';
import { initTicketsSection } from './tickets/index.js';
import { initModulesSection } from './modules/index.js';
import { initEventsSection } from './events/index.js';
import { initAdminSection } from './admin/index.js';
import { renderSettingsSection } from './settings.js';
let overviewInterval: number | null = null;
let ticketsInterval: number | null = null;
async function populateGuildSelect() {
const select = document.getElementById('guildSelect') as HTMLSelectElement | null;
const cfg = getConfig();
if (!select || !cfg) return;
select.innerHTML = `<option>Loading...</option>`;
try {
const data = await api.guilds();
select.innerHTML = '';
data.guilds.forEach((g) => {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.name;
if (g.id === cfg.initialGuildId) opt.selected = true;
select.appendChild(opt);
});
const current = select.value || cfg.initialGuildId || data.guilds[0]?.id;
setState({ guildId: current || undefined });
select.value = current || '';
} catch (err) {
console.error(err);
showToast('Guilds konnten nicht geladen werden', true);
}
}
function registerGuildChange() {
const select = document.getElementById('guildSelect') as HTMLSelectElement | null;
if (!select) return;
select.addEventListener('change', () => {
const guildId = select.value;
setState({ guildId });
refreshSections();
});
}
async function refreshSections() {
const { guildId } = getState();
if (!guildId) return;
await renderOverview(guildId);
await initTicketsSection(guildId);
await initModulesSection(guildId);
await renderSettingsSection(guildId);
await initEventsSection(guildId);
const cfg = getConfig();
if (cfg?.isAdmin) {
await initAdminSection(guildId);
}
}
function setupPolling() {
const { guildId } = getState();
if (overviewInterval) window.clearInterval(overviewInterval);
if (ticketsInterval) window.clearInterval(ticketsInterval);
overviewInterval = window.setInterval(() => {
const current = getState().guildId;
if (current) renderOverview(current);
}, 10000);
ticketsInterval = window.setInterval(() => {
const current = getState().guildId;
if (current) initTicketsSection(current);
}, 12000);
}
export function initDashboardView() {
const cfg = getConfig();
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn && cfg) {
logoutBtn.addEventListener('click', () => (window.location.href = `${cfg.baseAuth}/logout`));
}
populateGuildSelect().then(() => {
registerGuildChange();
refreshSections();
setupPolling();
});
}

View File

@@ -0,0 +1,39 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function initEventsSection(guildId: string) {
const section = document.getElementById('section-events');
if (!section) return;
section.innerHTML = '<p class="muted">Lade Events...</p>';
try {
const data: any = await api.events(guildId);
const events = data?.events || data || [];
section.innerHTML = '<h2 class="section-title">Events</h2>';
if (!events.length) {
section.innerHTML += '<div class="empty-state">Keine Events geplant.</div>';
return;
}
const list = document.createElement('div');
list.className = 'ticket-list';
events.forEach((ev: any) => {
const item = document.createElement('div');
item.className = 'ticket-item';
item.innerHTML = `
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:750;">${ev.title || 'Event'}</div>
<div class="muted">${ev.date || ''}</div>
</div>
<span class="pill">${ev.status || 'open'}</span>
</div>
<div class="muted">${ev.description || ''}</div>
`;
list.appendChild(item);
});
section.appendChild(list);
} catch (err) {
console.error(err);
section.innerHTML = '<div class="empty-state">Events konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Events', true);
}
}

View File

@@ -0,0 +1,56 @@
import { api } from '../services/api.js';
import { getConfig } from '../state/store.js';
import { showToast } from '../ui/toast.js';
export async function initSelectionView() {
const cfg = getConfig();
const grid = document.getElementById('guildGrid');
const logoutBtn = document.getElementById('logoutBtn');
const userInfo = document.getElementById('userInfo');
if (logoutBtn && cfg) {
logoutBtn.addEventListener('click', () => {
window.location.href = `${cfg.baseAuth}/logout`;
});
}
try {
const me = await api.me();
if (userInfo && me?.user) userInfo.textContent = `${me.user.username}#${me.user.discriminator}`;
} catch {
// ignore
}
if (!grid || !cfg) return;
grid.innerHTML = '<div class="muted">Lade Guilds...</div>';
try {
const data = await api.guilds();
grid.innerHTML = '';
(data.guilds || []).forEach((g) => {
const card = document.createElement('div');
card.className = 'card clickable';
card.innerHTML = `
<div class="row">
<img src="${g.icon ? `https://cdn.discordapp.com/icons/${g.id}/${g.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}" alt="icon" style="width:42px;height:42px;border-radius:12px;object-fit:cover;"/>
<div>
<div style="font-weight:700;">${g.name}</div>
<div class="muted">ID: ${g.id}</div>
</div>
</div>
<div style="margin-top:10px;" class="pill">Zum Dashboard</div>
`;
card.addEventListener('click', () => {
const qs = g.id ? `?guildId=${encodeURIComponent(g.id)}` : '';
window.location.href = `${cfg.baseDashboard}${qs}`;
});
grid.appendChild(card);
});
if (!data.guilds?.length) {
grid.innerHTML = '<div class="empty-state">Bot ist in keiner Guild. Bitte Bot einladen.</div>';
}
} catch (err) {
console.error(err);
grid.innerHTML = '<div class="empty-state">Fehler beim Laden der Guilds</div>';
showToast('Guilds konnten nicht geladen werden', true);
}
}

View File

@@ -0,0 +1,22 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderDynamicVoiceModule(guildId: string) {
const container = document.getElementById('module-dynamicvoice');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Dynamic Voice...</p>';
try {
const data: any = await api.dynamicVoice(guildId);
const cfg = data?.config || data?.dynamicVoiceConfig || {};
container.innerHTML = `
<h3 class="label">Dynamic Voice</h3>
<p class="muted">Lobby: ${cfg.lobbyChannelId || '-'}</p>
<p class="muted">Template: ${cfg.template || '-'}</p>
<p class="muted">User-Limit: ${cfg.userLimit ?? '-'}</p>
`;
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Dynamic Voice konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden von Dynamic Voice', true);
}
}

View File

@@ -0,0 +1,99 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
import { setSwitch, getSwitch } from '../../ui/switch.js';
import { renderWelcomeModule } from './welcome.js';
import { renderLoggingModule } from './logging.js';
import { renderReactionRolesModule } from './reactionRoles.js';
import { renderDynamicVoiceModule } from './dynamicVoice.js';
import { renderStatuspageModule } from './statuspage.js';
import { renderServerStatsModule } from './serverstats.js';
export async function initModulesSection(guildId: string) {
const section = document.getElementById('section-modules');
if (!section) return;
section.innerHTML = `
<h2 class="section-title">Module</h2>
<div class="card">
<div class="module-list" id="module-toggles"></div>
</div>
<div class="grid" style="margin-top:16px;">
<div class="card" id="module-welcome"></div>
<div class="card" id="module-logging"></div>
<div class="card" id="module-reactionroles"></div>
<div class="card" id="module-dynamicvoice"></div>
<div class="card" id="module-statuspage"></div>
<div class="card" id="module-serverstats"></div>
</div>
`;
await Promise.all([
renderModuleToggles(guildId),
renderWelcomeModule(guildId),
renderLoggingModule(guildId),
renderReactionRolesModule(guildId),
renderDynamicVoiceModule(guildId),
renderStatuspageModule(guildId),
renderServerStatsModule(guildId)
]);
}
async function renderModuleToggles(guildId: string) {
const container = document.getElementById('module-toggles');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Module...</p>';
try {
const data: any = await api.modules(guildId);
const modules = data?.modules || data || {};
container.innerHTML = '';
const entries: Array<{ key: string; label: string; desc: string }> = [
{ key: 'ticketsEnabled', label: 'Tickets', desc: 'Ticket-System aktivieren' },
{ key: 'automodEnabled', label: 'Automod', desc: 'Moderations-Filter' },
{ key: 'welcomeEnabled', label: 'Welcome', desc: 'Begrueßungsnachrichten' },
{ key: 'musicEnabled', label: 'Musik', desc: 'Musiksteuerung' },
{ key: 'levelingEnabled', label: 'Leveling', desc: 'XP/Level System' },
{ key: 'statuspageEnabled', label: 'Statuspage', desc: 'Statusberichte' },
{ key: 'serverStatsEnabled', label: 'Server Stats', desc: 'Stat-Channel' },
{ key: 'birthdayEnabled', label: 'Birthday', desc: 'Geburtstagsmodul' },
{ key: 'reactionRolesEnabled', label: 'Reaction Roles', desc: 'Selbstzuweisbare Rollen' },
{ key: 'eventsEnabled', label: 'Events', desc: 'Event-Planung' },
{ key: 'dynamicVoiceEnabled', label: 'Dynamic Voice', desc: 'Dynamische Voice Channels' }
];
entries.forEach((entry) => {
const row = document.createElement('div');
row.className = 'module-item';
row.innerHTML = `
<div class="module-meta">
<div class="module-title">${entry.label}</div>
<div class="module-desc">${entry.desc}</div>
</div>
<div class="switch ${modules[entry.key] ? 'on' : ''}" data-key="${entry.key}"></div>
`;
const toggle = row.querySelector('.switch') as HTMLElement;
toggle.addEventListener('click', async () => {
setSwitch(toggle, !getSwitch(toggle));
await saveModules(guildId);
});
container.appendChild(row);
});
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Module konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Module', true);
}
}
async function saveModules(guildId: string) {
const toggles = Array.from(document.querySelectorAll<HTMLElement>('#module-toggles .switch'));
const payload: Record<string, unknown> = { guildId };
toggles.forEach((t) => {
const key = t.dataset.key;
if (!key) return;
payload[key] = t.classList.contains('on');
});
try {
await api.saveSettings(payload);
showToast('Module gespeichert');
} catch (err) {
console.error(err);
showToast('Module speichern fehlgeschlagen', true);
}
}

View File

@@ -0,0 +1,22 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderLoggingModule(guildId: string) {
const container = document.getElementById('module-logging');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Logging...</p>';
try {
const data: any = await api.settings(guildId);
const cfg = data?.settings?.loggingConfig || data?.loggingConfig || {};
container.innerHTML = `
<h3 class="label">Logging</h3>
<p class="muted">Channel: ${cfg.logChannelId || '-'}</p>
<p class="muted">Join/Leave: ${cfg.categories?.joinLeave ? 'an' : 'aus'}</p>
<p class="muted">System: ${cfg.categories?.system ? 'an' : 'aus'}</p>
`;
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Logging konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden von Logging', true);
}
}

View File

@@ -0,0 +1,33 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderReactionRolesModule(guildId: string) {
const container = document.getElementById('module-reactionroles');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Reaction Roles...</p>';
try {
const data: any = await api.reactionRoles(guildId);
const entries = data?.entries || data?.reactionRoles || [];
container.innerHTML = '<h3 class="label">Reaction Roles</h3>';
if (!entries.length) {
container.innerHTML += '<div class="empty-state">Keine Reaction Roles.</div>';
return;
}
const list = document.createElement('div');
list.className = 'ticket-list';
entries.slice(0, 3).forEach((e: any) => {
const item = document.createElement('div');
item.className = 'ticket-item';
item.innerHTML = `
<div style="font-weight:750;">${e.title || e.messageId || 'Eintrag'}</div>
<div class="muted">${e.channelId || ''}</div>
`;
list.appendChild(item);
});
container.appendChild(list);
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Reaction Roles konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Reaction Roles', true);
}
}

View File

@@ -0,0 +1,23 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderServerStatsModule(guildId: string) {
const container = document.getElementById('module-serverstats');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Server Stats...</p>';
try {
const data: any = await api.serverStats(guildId);
const cfg = data?.config || data || {};
const items = cfg.items || [];
container.innerHTML = `
<h3 class="label">Server Stats</h3>
<p class="muted">Kategorie: ${cfg.categoryId || '-'}</p>
<p class="muted">Refresh: ${cfg.refresh || '-'}m</p>
<p class="muted">Items: ${items.length}</p>
`;
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Server Stats konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Server Stats', true);
}
}

View File

@@ -0,0 +1,23 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderStatuspageModule(guildId: string) {
const container = document.getElementById('module-statuspage');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Statuspage...</p>';
try {
const data: any = await api.statuspage(guildId);
const cfg = data?.config || data || {};
const services = cfg.services || [];
container.innerHTML = `
<h3 class="label">Statuspage</h3>
<p class="muted">Channel: ${cfg.channelId || '-'}</p>
<p class="muted">Intervall: ${cfg.interval || '-'}m</p>
<p class="muted">Services: ${services.length}</p>
`;
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Statuspage konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden der Statuspage', true);
}
}

View File

@@ -0,0 +1,22 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderWelcomeModule(guildId: string) {
const container = document.getElementById('module-welcome');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Welcome...</p>';
try {
const data: any = await api.settings(guildId);
const cfg = data?.settings?.welcomeConfig || data?.welcomeConfig || {};
container.innerHTML = `
<h3 class="label">Welcome</h3>
<p class="muted">Channel: ${cfg.channelId || '-'}</p>
<p class="muted">Embed Titel: ${cfg.embedTitle || '-'}</p>
<p class="muted">Status: ${data?.settings?.welcomeEnabled ? 'aktiv' : 'inaktiv'}</p>
`;
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Welcome konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden von Welcome', true);
}
}

View File

@@ -0,0 +1,33 @@
import { api } from '../services/api.js';
import { showToast } from '../ui/toast.js';
export async function renderOverview(guildId: string) {
const section = document.getElementById('section-overview');
if (!section) return;
section.innerHTML = '<p class="muted">Lade Uebersicht...</p>';
try {
const data: any = await api.overview(guildId);
const stats = data?.stats || {};
section.innerHTML = `
<h2 class="section-title">Uebersicht</h2>
<div class="grid">
<div class="card">
<p class="label">Tickets offen</p>
<p class="stat">${stats.openTickets ?? '-'}</p>
</div>
<div class="card">
<p class="label">Module aktiv</p>
<p class="stat">${stats.activeModules ?? '-'}</p>
</div>
<div class="card">
<p class="label">Events geplant</p>
<p class="stat">${stats.events ?? '-'}</p>
</div>
</div>
`;
} catch (err) {
console.error(err);
section.innerHTML = '<div class="empty-state">Uebersicht konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden der Uebersicht', true);
}
}

View File

@@ -0,0 +1,22 @@
import { api } from '../services/api.js';
import { showToast } from '../ui/toast.js';
export async function renderSettingsSection(guildId: string) {
const section = document.getElementById('section-settings');
if (!section) return;
section.innerHTML = '<p class="muted">Lade Einstellungen...</p>';
try {
const data: any = await api.settings(guildId);
const settings = data?.settings || {};
section.innerHTML = `
<h2 class="section-title">Einstellungen</h2>
<div class="card">
<pre style="white-space:pre-wrap;">${JSON.stringify(settings, null, 2)}</pre>
</div>
`;
} catch (err) {
console.error(err);
section.innerHTML = '<div class="empty-state">Einstellungen konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Einstellungen', true);
}
}

View File

@@ -0,0 +1,38 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderAutomations(guildId: string) {
const container = document.getElementById('tickets-automations');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Automationen...</p>';
try {
const data: any = await api.automations(guildId);
const rules = data?.rules || data || [];
if (!rules.length) {
container.innerHTML = '<div class="empty-state">Keine Regeln angelegt.</div>';
return;
}
const list = document.createElement('div');
list.className = 'ticket-list';
rules.forEach((r: any) => {
const item = document.createElement('div');
item.className = 'ticket-item';
item.innerHTML = `
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:750;">${r.name || 'Regel'}</div>
<div class="muted">${r.condition?.type || r.condition?.status || ''}</div>
</div>
<span class="pill">${r.active ? 'aktiv' : 'inaktiv'}</span>
</div>
`;
list.appendChild(item);
});
container.innerHTML = '<h3 class="label">Automationen</h3>';
container.appendChild(list);
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Automationen konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Automationen', true);
}
}

View File

@@ -0,0 +1,29 @@
import { renderTicketList } from './list.js';
import { renderPipeline } from './pipeline.js';
import { renderSla } from './sla.js';
import { renderAutomations } from './automations.js';
import { renderKb } from './kb.js';
export async function initTicketsSection(guildId: string) {
const section = document.getElementById('section-tickets');
if (!section) return;
section.innerHTML = `
<h2 class="section-title">Tickets</h2>
<div class="tickets-grid">
<div class="card" id="tickets-list"></div>
<div class="card" id="tickets-pipeline"></div>
<div class="card" id="tickets-sla"></div>
</div>
<div class="grid" style="margin-top:16px;">
<div class="card" id="tickets-automations"></div>
<div class="card" id="tickets-kb"></div>
</div>
`;
await Promise.all([
renderTicketList(guildId),
renderPipeline(guildId),
renderSla(guildId),
renderAutomations(guildId),
renderKb(guildId)
]);
}

View File

@@ -0,0 +1,33 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderKb(guildId: string) {
const container = document.getElementById('tickets-kb');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Knowledge Base...</p>';
try {
const data: any = await api.kb(guildId);
const entries = data?.articles || data?.kb || [];
if (!entries.length) {
container.innerHTML = '<div class="empty-state">Keine KB-Eintraege.</div>';
return;
}
const list = document.createElement('div');
list.className = 'ticket-list';
entries.slice(0, 4).forEach((k: any) => {
const item = document.createElement('div');
item.className = 'ticket-item';
item.innerHTML = `
<div style="font-weight:750;">${k.title || 'Artikel'}</div>
<div class="muted">${k.keywords || ''}</div>
`;
list.appendChild(item);
});
container.innerHTML = '<h3 class="label">Knowledge Base</h3>';
container.appendChild(list);
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">KB konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden der KB', true);
}
}

View File

@@ -0,0 +1,39 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderTicketList(guildId: string) {
const container = document.getElementById('tickets-list');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Tickets...</p>';
try {
const data: any = await api.tickets(guildId);
const tickets = data?.tickets || [];
if (!tickets.length) {
container.innerHTML = '<div class="empty-state">Keine Tickets</div>';
return;
}
const list = document.createElement('div');
list.className = 'ticket-list';
tickets.slice(0, 5).forEach((t: any) => {
const item = document.createElement('div');
item.className = 'ticket-item';
item.innerHTML = `
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:750;font-size:15px;">${t.title || t.id}</div>
<div class="muted">${t.user || ''}</div>
</div>
<div class="ticket-status status-${t.status || 'open'}">${t.status || 'open'}</div>
</div>
<div class="muted">${t.description || ''}</div>
`;
list.appendChild(item);
});
container.innerHTML = '<h3 class="label">Aktuelle Tickets</h3>';
container.appendChild(list);
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Tickets konnten nicht geladen werden.</div>';
showToast('Fehler beim Laden der Tickets', true);
}
}

View File

@@ -0,0 +1,33 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderPipeline(guildId: string) {
const container = document.getElementById('tickets-pipeline');
if (!container) return;
container.innerHTML = '<p class="muted">Lade Pipeline...</p>';
try {
const data: any = await api.pipeline(guildId);
const lanes = data?.lanes || [];
container.innerHTML = '<h3 class="label">Pipeline</h3>';
if (!lanes.length) {
container.innerHTML += '<div class="empty-state">Keine Pipeline-Daten</div>';
return;
}
const grid = document.createElement('div');
grid.className = 'grid';
lanes.forEach((lane: any) => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<p class="label">${lane.name || 'Lane'}</p>
<p class="stat">${lane.count ?? 0}</p>
`;
grid.appendChild(card);
});
container.appendChild(grid);
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">Pipeline konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden der Pipeline', true);
}
}

View File

@@ -0,0 +1,21 @@
import { api } from '../../services/api.js';
import { showToast } from '../../ui/toast.js';
export async function renderSla(guildId: string) {
const container = document.getElementById('tickets-sla');
if (!container) return;
container.innerHTML = '<p class="muted">Lade SLA...</p>';
try {
const data: any = await api.sla(guildId);
const stats = data?.stats || {};
container.innerHTML = `
<h3 class="label">SLA</h3>
<p class="stat">${stats.averageResponse ?? '-'}m</p>
<p class="muted">Durchschnittliche Antwortzeit</p>
`;
} catch (err) {
console.error(err);
container.innerHTML = '<div class="empty-state">SLA konnte nicht geladen werden.</div>';
showToast('Fehler beim Laden der SLA', true);
}
}

67
public/ts/core/app.ts Normal file
View File

@@ -0,0 +1,67 @@
import { api } from '../services/api.js';
import { initConfig, setState, getConfig } from '../state/store.js';
import { renderSidebar, initNavigation } from '../ui/navigation.js';
import { showToast } from '../ui/toast.js';
import { initSelectionView } from '../components/guildSelect.js';
import { initDashboardView } from '../components/dashboard.js';
function readConfig(): void {
const root = document.getElementById('app');
if (!root) throw new Error('App-Container fehlt');
const view = (root.dataset.view as 'selection' | 'dashboard') || 'selection';
const baseRoot = root.dataset.baseRoot || '/ucp';
const baseDashboard = root.dataset.baseDashboard || `${baseRoot}/dashboard`;
const baseAuth = root.dataset.baseAuth || `${baseRoot}/auth`;
const baseApi = root.dataset.baseApi || `${baseRoot}/api`;
const initialGuildId = root.dataset.guildId || undefined;
const isAdmin = root.dataset.userAdmin === 'true';
const userLabel = root.dataset.userName
? `${root.dataset.userName}${root.dataset.userDisc ? '#' + root.dataset.userDisc : ''}`
: undefined;
initConfig({ baseRoot, baseDashboard, baseAuth, baseApi, view, initialGuildId, isAdmin, userLabel });
}
async function ensureAuth() {
try {
const me = await api.me();
if (!me?.user) {
const cfg = getConfig();
window.location.href = (cfg?.baseAuth || '/auth') + '/discord';
return null;
}
const userInfo = document.getElementById('userInfo');
if (userInfo && me.user) userInfo.textContent = `${me.user.username}#${me.user.discriminator}`;
setState({ isAdmin: !!me.user?.isAdmin, userLabel: me.user ? `${me.user.username}#${me.user.discriminator}` : undefined });
return me;
} catch (err) {
console.error(err);
showToast('Authentifizierung fehlgeschlagen', true);
return null;
}
}
async function bootstrap() {
readConfig();
const cfg = getConfig();
if (!cfg) return;
const sidebarRoot = document.getElementById('sidebar-root');
if (sidebarRoot) renderSidebar(sidebarRoot, !!cfg.isAdmin);
if (cfg.view === 'selection') {
initSelectionView();
} else {
await ensureAuth();
initDashboardView();
initNavigation((section) => {
// Sections werden innerhalb der jeweiligen Komponenten bedient
if (section === 'admin' && !cfg.isAdmin) showToast('Kein Admin-Recht', true);
});
}
}
bootstrap().catch((err) => {
console.error(err);
showToast('Fehler beim Laden', true);
});

79
public/ts/services/api.ts Normal file
View File

@@ -0,0 +1,79 @@
import { getConfig } from '../state/store.js';
type FetchOptions = RequestInit & { query?: Record<string, string | number | boolean | undefined> };
function buildUrl(path: string, query?: Record<string, string | number | boolean | undefined>) {
const cfg = getConfig();
const base = cfg?.baseApi || '';
const url = new URL(path.startsWith('http') ? path : `${base}${path}`, window.location.origin);
if (query) {
Object.entries(query).forEach(([k, v]) => {
if (v === undefined || v === null) return;
url.searchParams.set(k, String(v));
});
}
return url.toString();
}
async function request<T = unknown>(path: string, options: FetchOptions = {}): Promise<T> {
const { query, headers, ...rest } = options;
const url = buildUrl(path, query);
const res = await fetch(url, {
...rest,
headers: {
'Content-Type': 'application/json',
...(headers || {})
}
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`Request failed: ${res.status} ${text}`);
}
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return (await res.json()) as T;
}
return (await res.text()) as unknown as T;
}
export const api = {
me: () => request<{ user?: { username: string; discriminator: string; isAdmin?: boolean } }>('/me'),
guilds: () => request<{ guilds: Array<{ id: string; name: string; icon?: string }> }>('/guilds'),
overview: (guildId: string) => request(`/overview`, { query: { guildId } }),
settings: (guildId: string) => request(`/settings`, { query: { guildId } }),
saveSettings: (payload: Record<string, unknown>) => request('/settings', { method: 'POST', body: JSON.stringify(payload) }),
modules: (guildId: string) => request(`/modules`, { query: { guildId } }),
tickets: (guildId: string) => request(`/tickets`, { query: { guildId } }),
pipeline: (guildId: string, filter?: string) => request(`/tickets/pipeline`, { query: { guildId, filter } }),
sla: (guildId: string, range?: number) => request(`/tickets/sla`, { query: { guildId, range } }),
automations: (guildId: string) => request(`/automations`, { query: { guildId } }),
saveAutomation: (payload: Record<string, unknown>) =>
request(payload['id'] ? `/automations/${payload['id']}` : '/automations', {
method: payload['id'] ? 'PUT' : 'POST',
body: JSON.stringify(payload)
}),
kb: (guildId: string) => request(`/kb`, { query: { guildId } }),
saveKb: (payload: Record<string, unknown>) =>
request(payload['id'] ? `/kb/${payload['id']}` : '/kb', {
method: payload['id'] ? 'PUT' : 'POST',
body: JSON.stringify(payload)
}),
reactionRoles: (guildId: string) => request(`/reactionroles`, { query: { guildId } }),
saveReactionRole: (payload: Record<string, unknown> & { id?: string }) =>
request(payload.id ? `/reactionroles/${payload.id}` : '/reactionroles', {
method: payload.id ? 'PUT' : 'POST',
body: JSON.stringify(payload)
}),
events: (guildId: string) => request(`/events`, { query: { guildId } }),
saveEvent: (payload: Record<string, unknown> & { id?: string }) =>
request(payload.id ? `/events/${payload.id}` : '/events', {
method: payload.id ? 'PUT' : 'POST',
body: JSON.stringify(payload)
}),
statuspage: (guildId: string) => request(`/statuspage`, { query: { guildId } }),
saveStatuspage: (payload: Record<string, unknown>) => request('/statuspage', { method: 'POST', body: JSON.stringify(payload) }),
serverStats: (guildId: string) => request(`/serverstats`, { query: { guildId } }),
saveServerStats: (payload: Record<string, unknown>) => request('/serverstats', { method: 'POST', body: JSON.stringify(payload) }),
dynamicVoice: (guildId: string) => request(`/dynamicvoice`, { query: { guildId } }),
saveDynamicVoice: (payload: Record<string, unknown>) => request('/dynamicvoice', { method: 'POST', body: JSON.stringify(payload) })
};

49
public/ts/state/store.ts Normal file
View File

@@ -0,0 +1,49 @@
export interface AppConfig {
baseRoot: string;
baseDashboard: string;
baseAuth: string;
baseApi: string;
view: 'selection' | 'dashboard';
initialGuildId?: string;
isAdmin?: boolean;
userLabel?: string;
}
export interface AppState {
guildId?: string;
isAdmin?: boolean;
userLabel?: string;
}
type Listener = (state: AppState) => void;
let config: AppConfig | null = null;
let state: AppState = {};
const listeners = new Set<Listener>();
export function initConfig(next: AppConfig) {
config = next;
state = {
guildId: next.initialGuildId,
isAdmin: next.isAdmin,
userLabel: next.userLabel
};
}
export function getConfig() {
return config;
}
export function getState() {
return state;
}
export function setState(partial: Partial<AppState>) {
state = { ...state, ...partial };
listeners.forEach((l) => l(state));
}
export function subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}

23
public/ts/ui/modal.ts Normal file
View File

@@ -0,0 +1,23 @@
let activeModal: HTMLElement | null = null;
let backdrop: HTMLElement | null = null;
function ensureBackdrop() {
if (backdrop) return backdrop;
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.addEventListener('click', hideModal);
document.body.appendChild(backdrop);
return backdrop;
}
export function showModal(content: HTMLElement) {
const bd = ensureBackdrop();
if (!content.parentElement) bd.appendChild(content);
activeModal = content;
bd.classList.add('show');
}
export function hideModal() {
if (backdrop) backdrop.classList.remove('show');
activeModal = null;
}

View File

@@ -0,0 +1,67 @@
import { getState, setState } from '../state/store.js';
export interface NavItem {
id: string;
label: string;
icon?: string;
requiresAdmin?: boolean;
}
const defaultNav: NavItem[] = [
{ id: 'overview', label: 'Uebersicht', icon: '[*]' },
{ id: 'tickets', label: 'Ticketsystem', icon: '[*]' },
{ id: 'modules', label: 'Module', icon: '[*]' },
{ id: 'settings', label: 'Einstellungen', icon: '[*]' },
{ id: 'events', label: 'Events', icon: '[*]' },
{ id: 'admin', label: 'Admin', icon: '[*]', requiresAdmin: true }
];
export function renderSidebar(container: HTMLElement, isAdmin: boolean) {
container.innerHTML = '';
const brand = document.createElement('div');
brand.className = 'brand';
brand.textContent = 'Papo Control';
const nav = document.createElement('div');
nav.className = 'nav';
defaultNav.forEach((item) => {
if (item.requiresAdmin && !isAdmin) return;
const a = document.createElement('a');
a.href = `#${item.id}`;
a.dataset.target = item.id;
a.innerHTML = `<span class="icon">${item.icon || ''}</span>${item.label}`;
nav.appendChild(a);
});
container.appendChild(brand);
container.appendChild(nav);
}
export function initNavigation(onChange: (section: string) => void) {
const navLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>('.nav a'));
const activate = (section: string) => {
navLinks.forEach((link) => link.classList.toggle('active', link.dataset.target === section));
document.querySelectorAll<HTMLElement>('.section').forEach((sec) => {
sec.classList.toggle('active', sec.id === `section-${section}`);
});
setState({}); // trigger listeners for potential observers
onChange(section);
};
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.dataset.target || 'overview';
history.replaceState(null, '', `#${target}`);
activate(target);
});
});
const initial = (location.hash || '#overview').replace('#', '');
activate(initial);
window.addEventListener('hashchange', () => {
const section = (location.hash || '#overview').replace('#', '');
activate(section);
});
}

14
public/ts/ui/switch.ts Normal file
View File

@@ -0,0 +1,14 @@
export function toggleSwitch(el: HTMLElement | null, force?: boolean) {
if (!el) return;
const next = force === undefined ? !el.classList.contains('on') : force;
el.classList.toggle('on', next);
}
export function getSwitch(el: HTMLElement | null) {
return el?.classList.contains('on') ?? false;
}
export function setSwitch(el: HTMLElement | null, value: boolean) {
if (!el) return;
el.classList.toggle('on', value);
}

23
public/ts/ui/toast.ts Normal file
View File

@@ -0,0 +1,23 @@
let currentTimeout: number | null = null;
export function showToast(message: string, isError = false, duration = 2500) {
let toast = document.getElementById('toast-root') as HTMLElement | null;
if (!toast) {
toast = document.createElement('div');
toast.id = 'toast-root';
document.body.appendChild(toast);
}
toast.className = `toast ${isError ? 'error' : ''}`;
toast.textContent = message;
requestAnimationFrame(() => {
toast?.classList.add('show');
});
if (currentTimeout) window.clearTimeout(currentTimeout);
currentTimeout = window.setTimeout(() => hideToast(), duration);
}
export function hideToast() {
const toast = document.getElementById('toast-root');
if (!toast) return;
toast.classList.remove('show');
}