Phase 3: Plugin-UI-System (WordPress-Style)
Backend: - PluginManifest um 5 neue UI-Felder erweitert: menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets (FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage, FrontendDashboardWidget) - GET /api/v1/plugins/active-manifests Endpoint liefert UI-Manifeste aller aktiven Plugins - Registry.get_active_manifests() + PluginService.get_active_manifests() - 12 Built-in Plugins mit UI-Manifest-Daten gefuellt (menu_items, page_routes, detail_tabs, settings_pages) - Plugin-Install-System: POST /upload (ZIP), POST /install-url (URL) mit Validierung (Manifest, dangerous imports, SQL migrations) Frontend: - pluginStore.ts (Zustand) mit PluginUiManifest Typen + Selektoren - useActivePluginManifests() React Query Hook - PluginRegistry.tsx — fetcht Manifeste beim App-Start - PluginLoader.tsx — dynamisches React.lazy() mit ErrorBoundary - PluginRouteRenderer.tsx — Catch-all fuer Plugin-Routes - routes/index.tsx — Catch-all Routes fuer Plugin-Pages + Settings - Sidebar.tsx — dynamische Plugin Menu-Items mit Grouping + Icons - Settings.tsx — dynamische Plugin Settings-Pages - ContactDetail.tsx — dynamische Plugin Detail-Tabs mit Permissions - AppShell.tsx — PluginRegistry Provider eingebunden - SettingsPlugins.tsx — Install-UI (ZIP Upload + URL Install) - plugins.ts — useUploadPlugin() + useInstallPluginFromUrl() Hooks Docs & Templates: - docs/plugin-development-guide.md — komplette Entwickler-Doku - templates/plugin-template/ — Boilerplate mit allen Manifest-Feldern Tests: - 34 Vitest-Tests (PluginRegistry, PluginLoader, PluginRouteRenderer, pluginStore) — alle bestanden - TSC: keine neuen Errors (nur pre-existing Dms.tsx)
This commit is contained in:
@@ -6,6 +6,7 @@ import { PluginToolbar } from './PluginToolbar';
|
||||
import { MessageSidebar } from './MessageSidebar';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import { useAIContext } from '@/hooks/useAIContext';
|
||||
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
@@ -18,6 +19,7 @@ export function AppShell() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
|
||||
<PluginRegistry />
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { Calendar, ChevronRight, FileText, Home, Mail, Monitor, Users } from 'lucide-react';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
interface NavLeaf {
|
||||
to: string;
|
||||
@@ -26,6 +28,11 @@ const chevronIcon = (expanded: boolean) => (
|
||||
<ChevronRight className={clsx('w-4 h-4 flex-shrink-0 transition-transform duration-200', expanded ? 'rotate-90' : '')} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
function getIcon(name: string): React.ReactNode {
|
||||
const Icon = (LucideIcons as any)[name];
|
||||
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
const singleItems: NavSingleItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
|
||||
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
|
||||
@@ -66,6 +73,7 @@ export function Sidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { sidebarOpen, setSidebarOpen } = useUIStore();
|
||||
const location = useLocation();
|
||||
const pluginMenuItems = usePluginStore(s => s.getAllMenuItems());
|
||||
|
||||
const initialExpanded = treeItems
|
||||
.filter((item) => item.children.some((child) => location.pathname.startsWith(child.to)))
|
||||
@@ -196,6 +204,104 @@ export function Sidebar() {
|
||||
);
|
||||
})}
|
||||
|
||||
{pluginMenuItems.length > 0 && (
|
||||
<>
|
||||
<li className="px-3 pt-4 pb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-secondary-500">
|
||||
{t('nav.plugins', 'Plugins')}
|
||||
</span>
|
||||
</li>
|
||||
{(() => {
|
||||
const groups = new Map<string, typeof pluginMenuItems>();
|
||||
const singles: typeof pluginMenuItems = [];
|
||||
for (const item of pluginMenuItems) {
|
||||
if (item.group) {
|
||||
if (!groups.has(item.group)) groups.set(item.group, []);
|
||||
groups.get(item.group)!.push(item);
|
||||
} else {
|
||||
singles.push(item);
|
||||
}
|
||||
}
|
||||
const elements: React.ReactNode[] = [];
|
||||
for (const item of singles) {
|
||||
elements.push(
|
||||
<li key={item.path}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
|
||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isActive
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'hover:bg-secondary-800 hover:text-white'
|
||||
)
|
||||
}
|
||||
aria-label={t(item.label_key, item.label)}
|
||||
>
|
||||
{getIcon(item.icon)}
|
||||
<span className="truncate">{t(item.label_key, item.label)}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
for (const [group, items] of groups) {
|
||||
const groupKey = `plugin-group-${group}`;
|
||||
const expanded = expandedItems.has(groupKey);
|
||||
elements.push(
|
||||
<li key={groupKey}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
|
||||
'motion-safe:transition-colors cursor-pointer select-none',
|
||||
'text-secondary-300 hover:bg-secondary-800 hover:text-white'
|
||||
)}
|
||||
onClick={() => toggleExpand(groupKey)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand(groupKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getIcon(items[0].icon)}
|
||||
<span className="flex-1 truncate">{group}</span>
|
||||
{chevronIcon(expanded)}
|
||||
</div>
|
||||
{expanded && (
|
||||
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
|
||||
{items.map((child) => (
|
||||
<li key={child.path}>
|
||||
<NavLink
|
||||
to={child.path}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
|
||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isActive
|
||||
? 'bg-primary-600 text-white font-medium'
|
||||
: 'text-secondary-400 hover:bg-secondary-800 hover:text-white'
|
||||
)
|
||||
}
|
||||
aria-label={t(child.label_key, child.label)}
|
||||
>
|
||||
<span className="truncate">{t(child.label_key, child.label)}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return elements;
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{bottomItems.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
|
||||
Reference in New Issue
Block a user