feat(marketplace): UI fuer Plugin-Marketplace — Modul 5/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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;
|
||||
Reference in New Issue
Block a user