feat: vollständiges Dashboard-Redesign mit HeroUI - monolithische App.tsx aufgelöst, 16 Seiten, Context-API, collapsible Sidebar, neues Dashboard-Layout
Some checks failed
Deploy Discord Bot / deploy (push) Has been cancelled

This commit is contained in:
Pepe44DEV
2026-07-01 06:15:56 +02:00
parent ccaf7bd4d2
commit e2d8002bf2
29 changed files with 2776 additions and 1296 deletions

View File

@@ -0,0 +1,511 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { apiFetch } from '../utils/api';
import type {
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
type AppState = {
user: User | null;
guilds: Guild[];
currentGuildId: string;
section: NavKey;
guildInfo: any;
overview: any;
activity: any;
logs: LogEntry[];
tickets: TicketRecord[];
pipeline: Record<string, TicketRecord[]>;
sla: any;
automations: any[];
kbArticles: any[];
settings: SettingsState;
modules: ModuleItem[];
birthday: any;
reactionRoles: ReactionRoleSet[];
statuspage: any;
serverStats: any;
events: EventItem[];
admin: any;
statusMessage: string;
loading: boolean;
supportLogin: { config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null;
registerForms: RegisterForm[];
registerApps: RegisterApplication[];
musicStatus: { activeGuilds: number; sessions: MusicSession[] };
};
type AppContextType = AppState & {
setCurrentGuildId: (id: string) => void;
setSection: (key: NavKey) => void;
setSettings: (s: SettingsState | ((prev: SettingsState) => SettingsState)) => void;
setBirthday: (s: any | ((prev: any) => any)) => void;
setSupportLogin: (s: any | ((prev: any) => any)) => void;
setStatusDraft: (s: any | ((prev: any) => any)) => void;
setStatsDraft: (s: any | ((prev: any) => any)) => void;
setStatusMessage: (msg: string) => void;
loadGuildData: (guildId: string) => Promise<void>;
saveSettingsPayload: (payload: Record<string, any>, okMessage: string) => Promise<void>;
saveBirthday: () => Promise<void>;
saveStatuspage: () => Promise<void>;
saveServerStats: () => Promise<void>;
toggleModule: (key: string, enabled: boolean) => Promise<void>;
handleLogout: () => void;
loadTicketData: (guildId: string) => Promise<void>;
loadTicketMessages: (ticketId: string) => Promise<void>;
updateTicketStatus: (ticketId: string, status: string) => Promise<void>;
closeTicket: (ticketId: string) => Promise<void>;
saveAutomation: () => Promise<void>;
saveKbArticle: () => Promise<void>;
updateKbArticle: (id: string) => Promise<void>;
deleteKbArticle: (id: string) => Promise<void>;
updateAutomation: (id: string) => Promise<void>;
deleteAutomation: (id: string) => Promise<void>;
saveSupportLogin: () => Promise<void>;
saveForm: () => Promise<void>;
deleteForm: (id: string) => Promise<void>;
sendFormPanel: (formId: string) => Promise<void>;
addStatusService: () => Promise<void>;
deleteStatusService: (id: string) => Promise<void>;
addStatsItem: () => Promise<void>;
deleteStatsItem: (index: number) => Promise<void>;
saveEvent: () => Promise<void>;
deleteEvent: (id: string) => Promise<void>;
saveReactionRole: () => Promise<void>;
ticketTab: string;
setTicketTab: (tab: string) => void;
automationDraft: any;
setAutomationDraft: (s: any | ((prev: any) => any)) => void;
kbDraft: any;
setKbDraft: (s: any | ((prev: any) => any)) => void;
eventDraft: any;
setEventDraft: (s: any | ((prev: any) => any)) => void;
statusDraft: any;
statsDraft: any;
reactionDraft: any;
setReactionDraft: (s: any | ((prev: any) => any)) => void;
formDraft: any;
setFormDraft: (s: any | ((prev: any) => any)) => void;
editingFormId: string | null;
setEditingFormId: (id: string | null) => void;
registerTab: string;
setRegisterTab: (tab: string) => void;
statusServiceDraft: any;
setStatusServiceDraft: (s: any | ((prev: any) => any)) => void;
statsItemDraft: any;
setStatsItemDraft: (s: any | ((prev: any) => any)) => void;
ticketDetail: TicketRecord | null;
setTicketDetail: (t: TicketRecord | null) => void;
ticketMessages: any[];
kbEditDraft: any;
setKbEditDraft: (s: any | ((prev: any) => any)) => void;
automationEditDraft: any;
setAutomationEditDraft: (s: any | ((prev: any) => any)) => void;
};
const AppContext = createContext<AppContextType | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [guilds, setGuilds] = useState<Guild[]>([]);
const [currentGuildId, setCurrentGuildId] = useState(appConfig.initialGuildId || '');
const [section, setSectionState] = useState<NavKey>('overview');
const [guildInfo, setGuildInfo] = useState<any>(null);
const [overview, setOverview] = useState<any>(null);
const [activity, setActivity] = useState<any>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [pipeline, setPipeline] = useState<Record<string, TicketRecord[]>>({});
const [sla, setSla] = useState<any>({ supporters: [], days: [] });
const [automations, setAutomations] = useState<any[]>([]);
const [kbArticles, setKbArticles] = useState<any[]>([]);
const [settings, setSettings] = useState<SettingsState>({});
const [modules, setModules] = useState<ModuleItem[]>([]);
const [birthday, setBirthday] = useState<any>({ config: {}, birthdays: [] });
const [reactionRoles, setReactionRoles] = useState<ReactionRoleSet[]>([]);
const [statuspage, setStatuspage] = useState<any>({ services: [] });
const [serverStats, setServerStats] = useState<any>({ items: [] });
const [events, setEvents] = useState<EventItem[]>([]);
const [admin, setAdmin] = useState<any>({ overview: null, activity: null, logs: [] });
const [statusMessage, setStatusMessage] = useState('');
const [ticketTab, setTicketTab] = useState('overview');
const [automationDraft, setAutomationDraft] = useState({ name: '', conditionValue: '', actionValue: '' });
const [kbDraft, setKbDraft] = useState({ title: '', keywords: '', content: '' });
const [eventDraft, setEventDraft] = useState({ title: '', description: '', channelId: '', startsAt: '' });
const [statusDraft, setStatusDraft] = useState<any>(null);
const [statsDraft, setStatsDraft] = useState<any>(null);
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[]>([]);
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
window.location.hash = key;
}, []);
useEffect(() => {
const hash = window.location.hash.replace('#', '') as NavKey;
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'admin'];
if (validKeys.includes(hash)) setSectionState(hash);
}, []);
useEffect(() => {
if (currentGuildId) loadGuildData(currentGuildId);
}, [currentGuildId]);
useEffect(() => { bootstrap(); }, []);
async function bootstrap() {
try {
const me = await apiFetch<{ user: User }>('/me');
const guildRes = await apiFetch<{ guilds: Guild[] }>('/guilds');
setUser(me.user);
setGuilds(guildRes.guilds || []);
if (!currentGuildId && guildRes.guilds?.length) setCurrentGuildId(guildRes.guilds[0].id);
} finally { setLoading(false); }
}
async function loadTicketData(guildId: string) {
try {
const [ticketRes, pipelineRes, slaRes, automationRes, kbRes] = await Promise.all([
apiFetch<any>(`/tickets?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/pipeline?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/sla?guildId=${encodeURIComponent(guildId)}&range=30`),
apiFetch<any>(`/automations?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/kb?guildId=${encodeURIComponent(guildId)}`)
]);
setTickets(ticketRes.tickets || []);
setPipeline(pipelineRes.pipeline || {});
setSla(slaRes || { supporters: [], days: [] });
setAutomations(automationRes.rules || []);
setKbArticles(kbRes.articles || []);
} catch {}
}
async function loadGuildData(guildId: string) {
setStatusMessage('Lade Daten...');
try {
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
registerFormsRes, registerAppsRes] = await Promise.all([
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/logs?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/settings?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/modules?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/birthday?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/reactionroles?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/statuspage?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/server-stats?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);
setOverview(overviewRes);
setMusicStatus(overviewRes.music || { activeGuilds: 0, sessions: [] });
setActivity(activityRes.activity || {});
setLogs(logsRes.logs || []);
setSettings(settingsRes.settings || {});
setModules(modulesRes.modules || []);
setBirthday(birthdayRes);
setReactionRoles(reactionRes.sets || []);
setStatuspage(statusRes.config || { services: [] });
setServerStats(statsRes.config || { items: [] });
setStatsDraft(statsRes.config || { items: [] });
setStatusDraft(statusRes.config || { services: [] });
setEvents(eventsRes.events || []);
setSupportLogin(supportLoginRes);
setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []);
setReactionDraft({ title: '', channelId: '', entries: '' });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
} catch { setStatusMessage('Daten konnten nicht geladen werden'); }
}
async function loadAdminData() {
if (!user?.isAdmin) return;
try {
const [overviewRes, , logsRes] = await Promise.all([
apiFetch<any>('/admin/overview'),
apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs')
]);
setAdmin({ overview: overviewRes, activity: null, logs: logsRes.logs || [] });
} catch {}
}
async function saveSettingsPayload(payload: Record<string, any>, okMessage: string) {
if (!currentGuildId) return;
await apiFetch('/settings', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, ...payload }) });
setStatusMessage(okMessage);
await loadGuildData(currentGuildId);
}
async function saveBirthday() {
await apiFetch('/birthday', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId,
enabled: birthday.config?.enabled ?? true,
channelId: birthday.config?.channelId || '',
sendHour: birthday.config?.sendHour || 9,
messageTemplate: birthday.config?.messageTemplate || ''
})
});
setStatusMessage('Birthday gespeichert');
await loadGuildData(currentGuildId);
}
async function saveStatuspage() {
await apiFetch('/statuspage', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: statusDraft }) });
setStatusMessage('Statuspage gespeichert');
await loadGuildData(currentGuildId);
}
async function saveServerStats() {
await apiFetch('/server-stats', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, config: statsDraft }) });
setStatusMessage('Server Stats gespeichert');
await loadGuildData(currentGuildId);
}
async function saveEvent() {
if (!eventDraft.title) return;
await apiFetch('/events', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId, title: eventDraft.title,
description: eventDraft.description, channelId: eventDraft.channelId || undefined,
startsAt: eventDraft.startsAt || undefined
})
});
setEventDraft({ title: '', description: '', channelId: '', startsAt: '' });
await loadGuildData(currentGuildId);
setStatusMessage('Event gespeichert');
}
async function deleteEvent(id: string) {
await apiFetch(`/events/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
await loadGuildData(currentGuildId);
}
async function saveReactionRole() {
const entries = reactionDraft.entries.split('\n').map((l) => l.trim()).filter(Boolean)
.map((line) => { const p = line.split('|').map((s) => s.trim()); return { emoji: p[0], roleId: p[1], label: p[2], description: p[3] }; })
.filter((e) => e.emoji && e.roleId);
await apiFetch('/reactionroles', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: reactionDraft.channelId, title: reactionDraft.title, entries }) });
await loadGuildData(currentGuildId);
setStatusMessage('Reaction Role gespeichert');
}
async function toggleModule(key: string, enabled: boolean) {
await saveSettingsPayload({ [key]: enabled }, `${key} aktualisiert`);
}
async function saveAutomation() {
await apiFetch('/automations', {
method: 'POST',
body: JSON.stringify({
guildId: currentGuildId, name: automationDraft.name || 'Automation',
condition: { category: automationDraft.conditionValue },
action: { type: 'reminder', message: automationDraft.actionValue || 'Reminder' }, active: true
})
});
setAutomationDraft({ name: '', conditionValue: '', actionValue: '' });
await loadTicketData(currentGuildId);
}
async function saveKbArticle() {
await apiFetch('/kb', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, title: kbDraft.title || 'Artikel', keywords: kbDraft.keywords, content: kbDraft.content })
});
setKbDraft({ title: '', keywords: '', content: '' });
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 = 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 handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
return (
<AppContext.Provider value={{
user, guilds, currentGuildId, section, guildInfo, overview, activity,
logs, tickets, pipeline, sla, automations, kbArticles, settings, modules,
birthday, reactionRoles, statuspage, serverStats, events, admin, statusMessage,
loading, supportLogin, registerForms, registerApps, musicStatus, ticketTab,
automationDraft, kbDraft, eventDraft, statusDraft, statsDraft, reactionDraft,
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
toggleModule, handleLogout, loadTicketData, loadTicketMessages,
updateTicketStatus, closeTicket, saveAutomation, saveKbArticle,
updateKbArticle, deleteKbArticle, updateAutomation, deleteAutomation,
saveSupportLogin, saveForm, deleteForm, sendFormPanel, addStatusService,
deleteStatusService, addStatsItem, deleteStatsItem, saveEvent, deleteEvent,
saveReactionRole, setTicketTab, setAutomationDraft, setKbDraft, setEventDraft,
setReactionDraft, setFormDraft, setEditingFormId, setRegisterTab,
setStatusServiceDraft, setStatsItemDraft, setTicketDetail, setKbEditDraft,
setAutomationEditDraft,
}}>
{children}
</AppContext.Provider>
);
}
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppProvider');
return ctx;
}