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:
@@ -1,6 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlugins, useInstallPlugin, useActivatePlugin, useDeactivatePlugin, useUninstallPlugin } from '@/api/hooks';
|
||||
import {
|
||||
usePlugins,
|
||||
useInstallPlugin,
|
||||
useActivatePlugin,
|
||||
useDeactivatePlugin,
|
||||
useUninstallPlugin,
|
||||
useUploadPlugin,
|
||||
useInstallPluginFromUrl,
|
||||
} from '@/api/hooks';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -44,6 +52,122 @@ function statusLabel(status: string, t: (k: string) => string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function InstallPluginSection() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const uploadMutation = useUploadPlugin();
|
||||
const installUrlMutation = useInstallPluginFromUrl();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [url, setUrl] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
toast.error(t('settings.pluginSelectFile'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await uploadMutation.mutateAsync(selectedFile);
|
||||
toast.success(t('settings.pluginUploadedSuccess'));
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallUrl = async () => {
|
||||
if (!url.trim()) {
|
||||
toast.error(t('settings.pluginEnterUrl'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await installUrlMutation.mutateAsync(url.trim());
|
||||
toast.success(t('settings.pluginUrlInstalledSuccess'));
|
||||
setUrl('');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const isBusy = uploadMutation.isPending || installUrlMutation.isPending;
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-4">
|
||||
{t('settings.installPlugin')}
|
||||
</h2>
|
||||
|
||||
{/* ZIP Upload */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
{t('settings.uploadZip')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileSelect}
|
||||
className="block w-full text-sm text-secondary-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
||||
data-testid="plugin-zip-upload-input"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
isLoading={uploadMutation.isPending}
|
||||
disabled={!selectedFile || isBusy}
|
||||
data-testid="plugin-upload-btn"
|
||||
>
|
||||
{t('settings.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedFile && (
|
||||
<p className="text-xs text-secondary-400 mt-1">
|
||||
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL Install */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
{t('settings.installFromUrl')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/plugin.zip"
|
||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
data-testid="plugin-url-input"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleInstallUrl}
|
||||
isLoading={installUrlMutation.isPending}
|
||||
disabled={!url.trim() || isBusy}
|
||||
data-testid="plugin-install-url-btn"
|
||||
>
|
||||
{t('settings.install')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPluginsPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -133,6 +257,9 @@ export function SettingsPluginsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Install Plugin Section */}
|
||||
<InstallPluginSection />
|
||||
|
||||
{plugins.length === 0 ? (
|
||||
<EmptyState title={t('settings.noPlugins')} />
|
||||
) : (
|
||||
@@ -252,4 +379,4 @@ export function SettingsPluginsPage() {
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user