feat(marketplace): UI fuer Plugin-Marketplace — Modul 5/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled

ERSTES Modul ueber die Phase-Q-Plugin-Architektur: Registrierung komplett
ueber das Plugin-Manifest (page_routes + menu_items) — routes/index.tsx und
Sidebar.tsx wurden NICHT angefasst. Der Komponenten-Map-Generator wired den
lazy import automatisch (38 Komponenten).

Backend existierte vollstaendig (listings mit search/tags/pagination,
listing-detail, install mit Ed25519-Signatur-Verify, verify, categories;
marketplace:read/admin + require_admin fuer Install), Frontend hatte 0%
Abdeckung.

- api/marketplace.ts: TanStack-Hooks (useMarketplaceListings mit
  search/tags/pagination, useMarketplaceListing, useMarketplaceCategories,
  useInstallFromMarketplace, useVerifyMarketplacePlugin)
- pages/Marketplace.tsx: Suche, Tag-Filter-Chips, Listing-Karten (Name,
  Version, Author, Beschreibung, Tags, Download-Counter, Verified-Badge,
  Preis/Kostenlos), Install mit Confirm (Admin-only via is_system_admin),
  Signatur-Verify, Ergebnis-Banner, Pagination — No-Access-Card ohne
  marketplace:read
- Manifest: page_route /marketplace + menu_item (Store-Icon, order 85,
  permission marketplace:read)
- i18n marketplace.* + nav.marketplace de/en

Verifikation: Vitest 10/10 (Rendering, No-Access, Empty/Error, Karten,
  Search+Tags, Admin-Gating, Install-Flow mit+ohne Confirm, Verify-Flow) ·
  tsc exit 0 · production build exit 0 · Backend-Regressionen
  (route-order, m5-miniapps) 10/10 · compileall sauber · Manifest-Check:
  page_route + menu_item korrekt.
This commit is contained in:
Agent Zero
2026-09-13 09:44:28 +02:00
parent a3201ae221
commit 289dfc8230
7 changed files with 741 additions and 5 deletions
+349
View File
@@ -0,0 +1,349 @@
/**
* Marketplace page — browse, search, verify and install plugins
* (UI-Backlog module 5/16).
*
* Backend: /api/v1/marketplace. Install requires admin,
* browse/verify requires marketplace:read.
*
* NOTE (Phase Q): this page is registered via the marketplace PLUGIN
* MANIFEST (page_routes + menu_items) — routes/index.tsx and Sidebar.tsx
* are NOT touched. The component map generator wires the lazy import.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Store,
Search,
ShieldCheck,
Download,
Package,
BadgeCheck,
ChevronLeft,
ChevronRight,
AlertTriangle,
Inbox,
} from 'lucide-react';
import {
useMarketplaceListings,
useMarketplaceCategories,
useInstallFromMarketplace,
useVerifyMarketplacePlugin,
type MarketplaceListing,
} from '@/api/marketplace';
import { usePermission } from '@/hooks/usePermission';
import { useAuthStore } from '@/store/authStore';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
const PAGE_SIZE = 12;
function ListingCard({
listing,
isAdmin,
onInstall,
onVerify,
isMutating,
}: {
listing: MarketplaceListing;
isAdmin: boolean;
onInstall: (listing: MarketplaceListing) => void;
onVerify: (listing: MarketplaceListing) => void;
isMutating: boolean;
}) {
const { t } = useTranslation();
return (
<Card className="p-4 flex flex-col gap-3" data-testid={`marketplace-card-${listing.name}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Package className="w-5 h-5 text-primary-600 flex-shrink-0" aria-hidden="true" />
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-semibold text-secondary-900 dark:text-secondary-100">
{listing.display_name}
</span>
<span className="text-xs text-secondary-400">v{listing.version}</span>
{listing.is_verified && (
<span data-testid={`marketplace-verified-${listing.name}`}>
<BadgeCheck className="w-4 h-4 text-success-600" aria-label={t('marketplace.verified')} />
</span>
)}
</div>
<div className="text-xs text-secondary-500">{listing.author}</div>
</div>
</div>
{listing.price > 0 ? (
<Badge variant="primary">{listing.price.toFixed(2)} </Badge>
) : (
<Badge variant="success">{t('marketplace.free')}</Badge>
)}
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 line-clamp-3" data-testid={`marketplace-desc-${listing.name}`}>
{listing.description || '—'}
</p>
<div className="flex flex-wrap gap-1">
{listing.tags.map((tag) => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
<div className="flex items-center justify-between gap-2 mt-auto pt-2 border-t border-secondary-100 dark:border-secondary-800">
<span className="text-xs text-secondary-400 flex items-center gap-1">
<Download className="w-3.5 h-3.5" aria-hidden="true" />
{listing.download_count}
</span>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onVerify(listing)}
disabled={isMutating}
data-testid={`marketplace-verify-${listing.name}`}
aria-label={t('marketplace.verify')}
>
<ShieldCheck className="w-4 h-4" aria-hidden="true" />
</Button>
{isAdmin && (
<Button
size="sm"
onClick={() => onInstall(listing)}
disabled={isMutating}
data-testid={`marketplace-install-${listing.name}`}
>
{t('marketplace.install')}
</Button>
)}
</div>
</div>
</Card>
);
}
export function MarketplacePage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [activeTag, setActiveTag] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [resultMessage, setResultMessage] = useState<{ ok: boolean; text: string } | null>(null);
const { data, isLoading, isError } = useMarketplaceListings({
search: search || undefined,
tags: activeTag ? [activeTag] : undefined,
page,
pageSize: PAGE_SIZE,
});
const { data: categoriesData } = useMarketplaceCategories();
const installMut = useInstallFromMarketplace();
const verifyMut = useVerifyMarketplacePlugin();
const { hasPermission } = usePermission();
const user = useAuthStore((s) => s.user);
const canBrowse = hasPermission('marketplace:read');
const isAdmin = !!user?.is_system_admin;
const isMutating = installMut.isPending || verifyMut.isPending;
const handleInstall = (listing: MarketplaceListing) => {
if (
!window.confirm(
t('marketplace.installConfirm', { name: listing.display_name }),
)
) {
return;
}
installMut.mutate(
{ name: listing.name, activate: true },
{
onSuccess: (result) => {
setResultMessage({
ok: result.success,
text: result.success
? t('marketplace.installSuccess', { name: result.display_name })
: `${t('marketplace.installFailed')}: ${result.error ?? result.message}`,
});
},
onError: (err) => {
setResultMessage({ ok: false, text: `${t('marketplace.installFailed')}: ${err.message}` });
},
},
);
};
const handleVerify = (listing: MarketplaceListing) => {
verifyMut.mutate(
{ name: listing.name },
{
onSuccess: (result) => {
setResultMessage({
ok: result.signature_valid,
text: `${listing.display_name}: ${result.message}`,
});
},
onError: (err) => {
setResultMessage({ ok: false, text: `${t('marketplace.verifyFailed')}: ${err.message}` });
},
},
);
};
const listings = data?.listings ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (!canBrowse) {
return (
<div className="max-w-4xl mx-auto p-6" data-testid="marketplace-no-access">
<Card className="p-8 flex flex-col items-center gap-3 text-secondary-500">
<AlertTriangle className="w-10 h-10" aria-hidden="true" />
<p>{t('marketplace.noAccess')}</p>
</Card>
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-4" data-testid="marketplace-page">
<div className="flex items-center gap-3">
<Store className="w-6 h-6 text-primary-600" aria-hidden="true" />
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{t('marketplace.title')}
</h1>
</div>
{/* Search + tag filter */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400"
aria-hidden="true"
/>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('marketplace.searchPlaceholder')}
className="pl-9"
aria-label={t('marketplace.searchPlaceholder')}
data-testid="marketplace-search"
/>
</div>
</div>
{(categoriesData?.categories ?? []).length > 0 && (
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t('marketplace.categories')}>
<button
onClick={() => {
setActiveTag(null);
setPage(1);
}}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors min-h-touch ${
activeTag === null
? 'bg-primary-600 text-white'
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-800 dark:text-secondary-300'
}`}
data-testid="marketplace-tag-all"
>
{t('marketplace.allTags')}
</button>
{(categoriesData?.categories ?? []).map((tag) => (
<button
key={tag}
onClick={() => {
setActiveTag(activeTag === tag ? null : tag);
setPage(1);
}}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors min-h-touch ${
activeTag === tag
? 'bg-primary-600 text-white'
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-800 dark:text-secondary-300'
}`}
data-testid={`marketplace-tag-${tag}`}
>
{tag}
</button>
))}
</div>
)}
{resultMessage && (
<div role="status">
<Card
className={`p-3 text-sm ${resultMessage.ok ? 'text-success-700' : 'text-danger-600'}`}
data-testid="marketplace-result"
>
{resultMessage.text}
</Card>
</div>
)}
{isLoading && (
<div className="flex items-center justify-center min-h-[40vh]" role="status" data-testid="marketplace-loading">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" aria-hidden="true" />
</div>
)}
{isError && (
<Card className="p-6 flex items-center gap-3 text-danger-600" data-testid="marketplace-error">
<AlertTriangle className="w-5 h-5" aria-hidden="true" />
<span>{t('marketplace.loadError')}</span>
</Card>
)}
{!isLoading && !isError && listings.length === 0 && (
<Card className="p-8 flex flex-col items-center gap-3 text-secondary-500" data-testid="marketplace-empty">
<Inbox className="w-10 h-10" aria-hidden="true" />
<p>{t('marketplace.empty')}</p>
</Card>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{listings.map((listing) => (
<ListingCard
key={listing.id}
listing={listing}
isAdmin={isAdmin}
onInstall={handleInstall}
onVerify={handleVerify}
isMutating={isMutating}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
aria-label={t('common.previous')}
data-testid="marketplace-prev"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
</Button>
<span className="text-sm text-secondary-500" data-testid="marketplace-page-info">
{page} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
aria-label={t('common.next')}
data-testid="marketplace-next"
>
<ChevronRight className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
)}
</div>
);
}
export default MarketplacePage;