feat: marketplace modules and isolated container management
This commit is contained in:
@@ -1,18 +1,25 @@
|
||||
import { type ReactNode, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Icon } from '../../components/ui/icon';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { useToast } from '../../components/ui/toast';
|
||||
import { ApiError } from '../../lib/api-client';
|
||||
import {
|
||||
checkModuleHealth,
|
||||
beginMarketplaceConnection,
|
||||
disconnectMarketplaceProvider,
|
||||
fetchMarketplaceRepositories,
|
||||
fetchMarketplaceProviders,
|
||||
fetchModules,
|
||||
installModule,
|
||||
installMarketplaceRepository,
|
||||
removeModule,
|
||||
restartModule,
|
||||
setModuleEnabled,
|
||||
startModule,
|
||||
stopModule,
|
||||
type MarketplaceProvider,
|
||||
} from '../../lib/modules-api';
|
||||
import type { Module, ModuleStatus } from '../../lib/schemas';
|
||||
|
||||
@@ -43,6 +50,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
||||
mutationFn: () => removeModule(module.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||
showToast('success', `Modul "${module.name}" wurde entfernt`);
|
||||
onClose();
|
||||
},
|
||||
@@ -55,7 +63,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
||||
<Modal
|
||||
open
|
||||
title="Modul entfernen"
|
||||
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? Dateien und Registrierung werden gelöscht.`}
|
||||
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer sowie Dateien und Registrierung werden gelöscht. Datenvolumes bleiben für eine mögliche Neuinstallation erhalten.`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{formError && (
|
||||
@@ -86,6 +94,8 @@ export function ModulesPage(): ReactNode {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
|
||||
const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null);
|
||||
const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers');
|
||||
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
|
||||
|
||||
const modulesQuery = useQuery({
|
||||
@@ -93,6 +103,45 @@ export function ModulesPage(): ReactNode {
|
||||
queryFn: fetchModules,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const marketplaceQuery = useQuery({
|
||||
queryKey: ['marketplace-providers'],
|
||||
queryFn: fetchMarketplaceProviders,
|
||||
});
|
||||
const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? [];
|
||||
const repositoriesQuery = useQuery({
|
||||
queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)],
|
||||
queryFn: async () => Promise.all(connectedProviders.map(async (item) => ({
|
||||
...item,
|
||||
repositories: await fetchMarketplaceRepositories(item.provider),
|
||||
}))),
|
||||
enabled: connectedProviders.length > 0,
|
||||
});
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => beginMarketplaceConnection(provider),
|
||||
onSuccess: (authorizationUrl) => window.location.assign(authorizationUrl),
|
||||
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht gestartet werden'),
|
||||
});
|
||||
|
||||
const disconnectMutation = useMutation({
|
||||
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => disconnectMarketplaceProvider(provider),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-providers'] });
|
||||
showToast('success', 'Forge-Verbindung getrennt');
|
||||
},
|
||||
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht getrennt werden'),
|
||||
});
|
||||
|
||||
const marketplaceInstallMutation = useMutation({
|
||||
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
|
||||
installMarketplaceRepository(input.provider, input.owner, input.repository),
|
||||
onSuccess: (module) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||
showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`);
|
||||
},
|
||||
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const invalidate = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
@@ -163,190 +212,318 @@ export function ModulesPage(): ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Modulverwaltung</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
<h1 className="text-3xl font-bold text-slate-900">Modulverwaltung</h1>
|
||||
<p className="mt-1 text-base text-slate-500">
|
||||
Module installieren, starten, stoppen und entfernen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installation */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
||||
aria-label="Modul-Paket (ZIP) auswählen"
|
||||
/>
|
||||
<Button
|
||||
disabled={!selectedFile}
|
||||
loading={installMutation.isPending}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Installieren
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500">
|
||||
ZIP-Paket mit module.json (Manifest). Maximal 10 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{new URLSearchParams(window.location.search).get('marketplace') === 'connected' && (
|
||||
<p role="status" className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
Forge-Konto wurde erfolgreich verbunden.
|
||||
</p>
|
||||
)}
|
||||
{new URLSearchParams(window.location.search).get('marketplace') === 'denied' && (
|
||||
<p role="status" className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Autorisierung wurde abgebrochen.
|
||||
</p>
|
||||
)}
|
||||
{new URLSearchParams(window.location.search).get('marketplace') === 'error' && (
|
||||
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{new URLSearchParams(window.location.search).get('reason') === 'session'
|
||||
? 'MPM konnte deine Sitzung beim OAuth-Rücksprung nicht zuordnen. Verwende vor und nach der Anmeldung dieselbe Adresse (http://127.0.0.1:8081) und starte die Verbindung erneut.'
|
||||
: new URLSearchParams(window.location.search).get('reason') === 'callback'
|
||||
? 'Die OAuth-Rückgabe war unvollständig. Bitte starte die Verbindung erneut.'
|
||||
: 'Verbindung fehlgeschlagen. Prüfe OAuth-Konfiguration und Callback-URL und starte die Verbindung erneut.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Compact provider connectors */}
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2>
|
||||
<div role="tablist" aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
|
||||
<button type="button" role="tab" aria-selected={connectionTab === 'providers'} onClick={() => setConnectionTab('providers')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'providers' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||
Verbindungen
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={connectionTab === 'manual'} onClick={() => setConnectionTab('manual')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'manual' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||
Manuelle Installation
|
||||
</button>
|
||||
</div>
|
||||
{connectionTab === 'providers' ? (
|
||||
<div className="grid grid-cols-1">
|
||||
{(['github', 'gitea', 'forgejo'] as const).map((provider) => {
|
||||
const connection = marketplaceQuery.data?.find((item) => item.provider === provider);
|
||||
const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo');
|
||||
const statusText = marketplaceQuery.isLoading
|
||||
? 'Status wird geladen…'
|
||||
: connection?.connected
|
||||
? `Verbunden als ${connection.accountLogin}`
|
||||
: connection?.configured
|
||||
? 'Marketplace verbinden'
|
||||
: 'Nicht eingerichtet';
|
||||
return (
|
||||
<div key={provider} className="flex min-w-0 items-center gap-3 border-b border-slate-200 py-3 last:border-b-0">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-900">
|
||||
<ProviderMark provider={provider} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">{label}</p>
|
||||
<p className="truncate text-xs text-slate-500">{statusText}</p>
|
||||
</div>
|
||||
{connection?.connected ? (
|
||||
<Button
|
||||
className="h-9 w-9 shrink-0 p-0"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label={`${label}-Verbindung trennen`}
|
||||
title={`${label}-Verbindung trennen`}
|
||||
loading={disconnectMutation.isPending && disconnectMutation.variables === provider}
|
||||
onClick={() => disconnectMutation.mutate(provider)}
|
||||
>
|
||||
<Icon name="check" className="h-4 w-4 text-emerald-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="h-9 w-9 shrink-0 p-0"
|
||||
size="sm"
|
||||
aria-label={`Mit ${label} verbinden`}
|
||||
title={connection?.configured ? `Mit ${label} verbinden` : `OAuth-Konfiguration für ${label} fehlt`}
|
||||
disabled={!connection?.configured || marketplaceQuery.isLoading}
|
||||
loading={connectMutation.isPending && connectMutation.variables === provider}
|
||||
onClick={() => setConnectTarget(provider)}
|
||||
>
|
||||
<Icon name="plus" className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="tabpanel" className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
||||
aria-label="Modul-Paket (ZIP) auswählen"
|
||||
/>
|
||||
<Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}>
|
||||
Installieren
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{/* Modul-Liste */}
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead>
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2>
|
||||
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200">
|
||||
<table className="w-full min-w-[900px] table-fixed text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-4 py-3 font-semibold">Modul</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="px-4 py-3 font-semibold">URL</th>
|
||||
<th className="px-4 py-3 font-semibold">Aktionen</th>
|
||||
<th className="w-[34%] px-4 py-3 font-semibold">Modul</th>
|
||||
<th className="w-[13%] px-4 py-3 font-semibold">Status</th>
|
||||
<th className="w-[15%] px-4 py-3 font-semibold">URL</th>
|
||||
<th className="w-[38%] px-4 py-3 font-semibold">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modulesQuery.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-slate-500">
|
||||
Module werden geladen…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{modulesQuery.isError && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-red-600">
|
||||
Module konnten nicht geladen werden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{modulesQuery.isLoading && <tr><td colSpan={4} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</td></tr>}
|
||||
{modulesQuery.isError && <tr><td colSpan={4} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</td></tr>}
|
||||
{modulesQuery.data?.map((module) => (
|
||||
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-900">{module.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{module.moduleId} · Version {module.version}
|
||||
{module.author && ` · ${module.author}`}
|
||||
</div>
|
||||
{module.description && (
|
||||
<div className="mt-0.5 text-xs text-slate-500">{module.description}</div>
|
||||
)}
|
||||
<div className="font-semibold text-slate-900">{module.name}</div>
|
||||
<div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div>
|
||||
{module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
|
||||
${
|
||||
statusVariant(module.status) === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
|
||||
: statusVariant(module.status) === 'warning'
|
||||
? 'bg-amber-50 text-amber-700 ring-amber-600/20'
|
||||
: statusVariant(module.status) === 'danger'
|
||||
? 'bg-red-50 text-red-700 ring-red-600/20'
|
||||
: statusVariant(module.status) === 'neutral'
|
||||
? 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
||||
: 'bg-brand-50 text-brand-700 ring-brand-600/20'
|
||||
}`}
|
||||
>
|
||||
{module.status}
|
||||
</span>
|
||||
{healthResults[module.id] && (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
|
||||
/{module.slug}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{module.status !== 'RUNNING' && module.enabled && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'start'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{(module.status === 'RUNNING' || module.status === 'STARTING') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'stop'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'restart'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => healthMutation.mutate(module)}
|
||||
>
|
||||
Health
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
(lifecycleMutation.variables?.action === 'enable' ||
|
||||
lifecycleMutation.variables?.action === 'disable')
|
||||
}
|
||||
onClick={() =>
|
||||
lifecycleMutation.mutate({
|
||||
module,
|
||||
action: module.enabled ? 'disable' : 'enable',
|
||||
})
|
||||
}
|
||||
>
|
||||
{module.enabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<td className="px-4 py-3"><span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${statusVariant(module.status) === 'success' ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20' : statusVariant(module.status) === 'warning' ? 'bg-amber-50 text-amber-700 ring-amber-600/20' : statusVariant(module.status) === 'danger' ? 'bg-red-50 text-red-700 ring-red-600/20' : statusVariant(module.status) === 'neutral' ? 'bg-slate-100 text-slate-600 ring-slate-500/20' : 'bg-brand-50 text-brand-700 ring-brand-600/20'}`}>{module.status}</span>
|
||||
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3"><code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></td>
|
||||
<td className="px-4 py-3"><div className="flex flex-wrap gap-1">
|
||||
{module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>}
|
||||
{(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>}
|
||||
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => healthMutation.mutate(module)}>Health</Button>
|
||||
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && (lifecycleMutation.variables?.action === 'enable' || lifecycleMutation.variables?.action === 'disable')} onClick={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>Remove</Button>
|
||||
</div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{modulesQuery.data?.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-slate-500">
|
||||
Noch keine Module installiert.
|
||||
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Marketplace</h2>
|
||||
<p className="mt-1 text-base text-slate-500">
|
||||
Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prüft das Modulmanifest vor der Installation.
|
||||
</p>
|
||||
</div>
|
||||
{connectedProviders.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
|
||||
Verbinde zuerst GitHub, Gitea oder Forgejo.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{repositoriesQuery.isLoading && <p className="py-4 text-sm text-slate-500">Repositories werden geladen...</p>}
|
||||
{repositoriesQuery.isError && <p role="alert" className="py-4 text-sm text-red-600">Repositories konnten nicht geladen werden.</p>}
|
||||
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
|
||||
{repositoriesQuery.data?.map((provider) => (
|
||||
<div key={provider.provider}>
|
||||
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
|
||||
{provider.repositories.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Keine öffentlichen Repositories gefunden.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{provider.repositories.map((repo) => (
|
||||
<article key={repo.owner + '/' + repo.repository} className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 p-3 transition-colors hover:border-slate-400 hover:bg-slate-50">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-800"><ProviderMark provider={provider.provider} /></span>
|
||||
<a className="truncate text-sm font-medium text-brand-700 hover:underline" href={repo.htmlUrl} target="_blank" rel="noreferrer">
|
||||
{repo.owner}/{repo.repository}
|
||||
</a>
|
||||
</div>
|
||||
{repo.description && <p className="mt-1 line-clamp-2 text-xs text-slate-500">{repo.description}</p>}
|
||||
<p className="mt-1 text-xs text-slate-500">Branch: {repo.defaultBranch}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={repo.installed ? 'secondary' : 'primary'}
|
||||
disabled={repo.installed}
|
||||
className={repo.installed ? '!border-emerald-600 !bg-emerald-600 !text-white hover:!bg-emerald-600 disabled:!text-white' : ''}
|
||||
loading={marketplaceInstallMutation.isPending && marketplaceInstallMutation.variables?.repository === repo.repository}
|
||||
onClick={() => marketplaceInstallMutation.mutate({ provider: provider.provider, owner: repo.owner, repository: repo.repository })}
|
||||
>
|
||||
{repo.installed ? 'Installiert' : 'Installieren'}
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{removeTarget && (
|
||||
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
|
||||
)}
|
||||
{connectTarget && (
|
||||
<ConnectProviderModal
|
||||
provider={connectTarget}
|
||||
label={marketplaceQuery.data?.find((item) => item.provider === connectTarget)?.label ??
|
||||
(connectTarget === 'github' ? 'GitHub' : connectTarget === 'gitea' ? 'Gitea' : 'Forgejo')}
|
||||
loading={connectMutation.isPending}
|
||||
onClose={() => setConnectTarget(null)}
|
||||
onContinue={() => connectMutation.mutate(connectTarget)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ProviderMark({ provider }: { provider: 'github' | 'gitea' | 'forgejo' }): ReactNode {
|
||||
if (provider === 'github') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" fill="currentColor">
|
||||
<path d="M12 .9a11.1 11.1 0 0 0-3.51 21.63c.56.1.76-.24.76-.54v-2.08c-3.1.67-3.76-1.32-3.76-1.32-.51-1.3-1.24-1.65-1.24-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 .1.95 2.17 3.64 1.54.1-.74.39-1.25.7-1.54-2.48-.28-5.09-1.24-5.09-5.52 0-1.22.44-2.22 1.16-3-.12-.29-.5-1.43.11-2.98 0 0 .95-.3 3.05 1.15a10.6 10.6 0 0 1 5.55 0c2.1-1.45 3.04-1.15 3.04-1.15.61 1.55.23 2.69.12 2.98.72.78 1.15 1.78 1.15 3 0 4.3-2.61 5.23-5.1 5.5.4.35.75 1.02.75 2.06v3.04c0 .3.2.65.77.54A11.1 11.1 0 0 0 12 .9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (provider === 'gitea') {
|
||||
return <span className="text-xl font-bold tracking-tight text-orange-500" aria-hidden="true">G</span>;
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-emerald-500" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 18h16l-3-4h-4l-2-4H8l2 4H7l-3 4Zm5-10 3-4 3 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectProviderModal({
|
||||
provider,
|
||||
label,
|
||||
loading,
|
||||
onClose,
|
||||
onContinue,
|
||||
}: {
|
||||
provider: MarketplaceProvider['provider'];
|
||||
label: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onContinue: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`${label} verbinden`}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
panelClassName="max-w-xl overflow-hidden rounded-[28px]"
|
||||
>
|
||||
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dialog schließen"
|
||||
onClick={onClose}
|
||||
className="absolute right-1 top-0 rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 pb-5 pt-2">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||
<Icon name="modules" className="h-9 w-9" />
|
||||
</span>
|
||||
<span className="flex gap-1.5" aria-hidden="true">
|
||||
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||
</span>
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-900">
|
||||
<ProviderMark provider={provider} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-semibold text-slate-900">{label} verbinden</h2>
|
||||
<p className="mt-1 text-base text-slate-500">MPM möchte dein {label}-Konto mit dem Marketplace verbinden.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-slate-200 px-5 py-4 text-sm">
|
||||
<section>
|
||||
<h3 className="font-medium text-slate-900">Berechtigungen werden respektiert</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
MPM fordert nur den Zugriff auf deine Kontoinformationen an, um die Verbindung deinem Konto zuzuordnen. Schreibzugriff wird nicht angefordert.
|
||||
</p>
|
||||
</section>
|
||||
<section className="border-t border-slate-200 pt-4">
|
||||
<h3 className="font-medium text-slate-900">Du behältst die Kontrolle</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
Du kannst die Verbindung jederzeit in MPM trennen oder die App-Berechtigung in den Einstellungen von {label} widerrufen.
|
||||
</p>
|
||||
</section>
|
||||
<section className="border-t border-slate-200 pt-4">
|
||||
<h3 className="font-medium text-slate-900">Mit dieser App geteilte Daten</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
MPM erhält deine Forge-Konto-ID, deinen Benutzernamen und ein Zugriffstoken. Das Token wird verschlüsselt gespeichert und nur serverseitig verwendet.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Button className="mt-6 h-11 w-full rounded-full" loading={loading} onClick={onContinue}>
|
||||
Weiter zu {label} <span aria-hidden="true">↗</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user