Product profiles page — frontend (Slice 4)
A new "Product profiles" page (library + marketplace) mirroring the agent-profile library: upload/author a PRODUCT.md (Markdown editor), view, edit, new version, fork builtins, publish/unpublish, install from marketplace, and Apply-to-product (sets the chosen product's shared identity). Reuses groupVersions + MarkdownEditor; adds the route and a sidebar entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { LoginPage } from '@/pages/LoginPage'
|
|||||||
import { MembersPage } from '@/pages/MembersPage'
|
import { MembersPage } from '@/pages/MembersPage'
|
||||||
import { OrgChartPage } from '@/pages/OrgChartPage'
|
import { OrgChartPage } from '@/pages/OrgChartPage'
|
||||||
import { PerformancePage } from '@/pages/PerformancePage'
|
import { PerformancePage } from '@/pages/PerformancePage'
|
||||||
|
import { ProductProfilesPage } from '@/pages/ProductProfilesPage'
|
||||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||||
import { SeatsPage } from '@/pages/SeatsPage'
|
import { SeatsPage } from '@/pages/SeatsPage'
|
||||||
import { SkillsPage } from '@/pages/SkillsPage'
|
import { SkillsPage } from '@/pages/SkillsPage'
|
||||||
@@ -31,6 +32,7 @@ export default function App() {
|
|||||||
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
|
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/skills" element={token ? <SkillsPage /> : <Navigate to="/login" replace />} />
|
<Route path="/skills" element={token ? <SkillsPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/agent-profiles" element={token ? <AgentProfilesPage /> : <Navigate to="/login" replace />} />
|
<Route path="/agent-profiles" element={token ? <AgentProfilesPage /> : <Navigate to="/login" replace />} />
|
||||||
|
<Route path="/product-profiles" element={token ? <ProductProfilesPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
|
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Network,
|
Network,
|
||||||
|
Package,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -46,6 +47,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||||
<NavItem icon={BookUser} label="Agent profiles" to="/agent-profiles" />
|
<NavItem icon={BookUser} label="Agent profiles" to="/agent-profiles" />
|
||||||
<NavItem icon={BookMarked} label="Skills" to="/skills" />
|
<NavItem icon={BookMarked} label="Skills" to="/skills" />
|
||||||
|
<NavItem icon={Package} label="Product profiles" to="/product-profiles" />
|
||||||
<NavItem icon={Network} label="Org chart" to="/org" />
|
<NavItem icon={Network} label="Org chart" to="/org" />
|
||||||
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
||||||
<NavItem icon={Users} label="Members" to="/members" />
|
<NavItem icon={Users} label="Members" to="/members" />
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Boxes, Download, Eye, GitFork, Pencil, Plus, Store, Upload } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { AppShell } from '@/components/AppShell'
|
||||||
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { bumpPatch } from '@/lib/semver'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { groupVersions } from '@/lib/versionedLibrary'
|
||||||
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
|
interface ProductProfileSummary {
|
||||||
|
id: string
|
||||||
|
organizationId: string | null
|
||||||
|
origin: string
|
||||||
|
profileKey: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
summary: string | null
|
||||||
|
visibility: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductProfileDetail {
|
||||||
|
profile: ProductProfileSummary
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketplaceEntry {
|
||||||
|
profile: ProductProfileSummary
|
||||||
|
alreadyInLibrary: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE = `---
|
||||||
|
product: My product
|
||||||
|
version: 1.0.0
|
||||||
|
summary: One-line description
|
||||||
|
---
|
||||||
|
|
||||||
|
# About this product
|
||||||
|
|
||||||
|
What it is, who it serves, and the conventions every agent on it should follow.
|
||||||
|
This identity is shared by every agent across the product's teams.
|
||||||
|
`
|
||||||
|
|
||||||
|
/** Reconstruct an editable PRODUCT.md (frontmatter + body) from a stored profile. */
|
||||||
|
function toMarkdown(d: ProductProfileDetail, version?: string): string {
|
||||||
|
const p = d.profile
|
||||||
|
const lines = [`product: ${p.name}`, `version: ${version ?? p.version}`]
|
||||||
|
if (p.summary) lines.push(`summary: ${p.summary}`)
|
||||||
|
return `---\n${lines.join('\n')}\n---\n\n${d.body}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The org's product-profile library (PRODUCT.md): free builtins + the company's own, versioned. */
|
||||||
|
export function ProductProfilesPage() {
|
||||||
|
const organizationId = useAuth((s) => s.organizationId)
|
||||||
|
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
|
||||||
|
const [profiles, setProfiles] = useState<ProductProfileSummary[]>([])
|
||||||
|
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [editor, setEditor] = useState<{ title: string; content: string } | null>(null)
|
||||||
|
const [preview, setPreview] = useState<{ title: string; content: string } | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!organizationId) return
|
||||||
|
try {
|
||||||
|
const [lib, market, prods] = await Promise.all([
|
||||||
|
api.get<ProductProfileSummary[]>(`/api/orgboard/product-profiles?organizationId=${organizationId}`),
|
||||||
|
api.get<MarketplaceEntry[]>(`/api/orgboard/product-profiles/marketplace?organizationId=${organizationId}`),
|
||||||
|
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
|
||||||
|
])
|
||||||
|
setProfiles(lib)
|
||||||
|
setMarketplace(market)
|
||||||
|
setProducts(prods)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
}
|
||||||
|
}, [organizationId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupVersions(profiles, (p) => p.profileKey), [profiles])
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<void>, ok: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
toast.success(ok)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchVersion = async (key: string, version: string) => {
|
||||||
|
const versions = await api.get<ProductProfileDetail[]>(`/api/orgboard/product-profiles/${key}?organizationId=${organizationId}`)
|
||||||
|
return versions.find((x) => x.profile.version === version) ?? versions[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditor = async (key: string, version: string, mode: 'edit' | 'version') => {
|
||||||
|
try {
|
||||||
|
const d = await fetchVersion(key, version)
|
||||||
|
if (!d) return
|
||||||
|
setEditor({
|
||||||
|
title: mode === 'version' ? `New version of ${key}` : `Edit ${key}`,
|
||||||
|
content: toMarkdown(d, mode === 'version' ? bumpPatch(d.profile.version) : undefined),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openView = async (key: string, version: string) => {
|
||||||
|
try {
|
||||||
|
const d = await fetchVersion(key, version)
|
||||||
|
if (!d) return
|
||||||
|
setPreview({ title: `${d.profile.name} · ${d.profile.version}`, content: toMarkdown(d) })
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = () =>
|
||||||
|
run(async () => {
|
||||||
|
if (!editor) return
|
||||||
|
await api.post('/api/orgboard/product-profiles/upload', { organizationId, content: editor.content })
|
||||||
|
setEditor(null)
|
||||||
|
}, 'Profile saved.')
|
||||||
|
|
||||||
|
const fork = (key: string, version: string) =>
|
||||||
|
run(() => api.post(`/api/orgboard/product-profiles/${key}/fork`, { organizationId, version }), `Forked ${key} into your org.`)
|
||||||
|
const setListed = (key: string, version: string, listed: boolean) =>
|
||||||
|
run(
|
||||||
|
() => api.post(`/api/orgboard/product-profiles/${key}/${listed ? 'publish' : 'unpublish'}`, { organizationId, version }),
|
||||||
|
listed ? `Published ${key}@${version}.` : `Unlisted ${key}@${version}.`,
|
||||||
|
)
|
||||||
|
const install = (sourceProfileId: string, name: string) =>
|
||||||
|
run(() => api.post('/api/orgboard/product-profiles/install', { organizationId, sourceProfileId }), `Installed ${name}.`)
|
||||||
|
const apply = (key: string, version: string, productId: string, productName: string) =>
|
||||||
|
run(
|
||||||
|
() => api.post(`/api/orgboard/product-profiles/${key}/apply`, { organizationId, productId, version }),
|
||||||
|
`Applied to ${productName} — every agent on it now shares this identity.`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
|
<header className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||||
|
<Boxes className="size-6" /> Product profiles
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Reusable product identities as PRODUCT.md. Author, version, apply to a product, and publish your own.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setEditor({ title: 'Upload PRODUCT.md', content: TEMPLATE })}>
|
||||||
|
<Upload data-icon="inline-start" /> Upload profile
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-4 inline-flex rounded-lg border p-1">
|
||||||
|
{(['library', 'marketplace'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium capitalize transition',
|
||||||
|
tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === 'library' ? <Boxes className="size-4" /> : <Store className="size-4" />} {t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'library' ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{groups.map(([key, versions]) => (
|
||||||
|
<ProfileGroupCard
|
||||||
|
key={key}
|
||||||
|
versions={versions}
|
||||||
|
products={products}
|
||||||
|
busy={busy}
|
||||||
|
onView={(v) => openView(key, v)}
|
||||||
|
onEdit={(v) => openEditor(key, v, 'edit')}
|
||||||
|
onNewVersion={(v) => openEditor(key, v, 'version')}
|
||||||
|
onFork={(v) => fork(key, v)}
|
||||||
|
onPublish={(v) => setListed(key, v, true)}
|
||||||
|
onUnpublish={(v) => setListed(key, v, false)}
|
||||||
|
onApply={(v, productId, productName) => apply(key, v, productId, productName)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{groups.length === 0 && <p className="text-sm text-muted-foreground">No product profiles yet — upload a PRODUCT.md to start.</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Product profiles other organizations have published. Install a private copy to use or customize.
|
||||||
|
</p>
|
||||||
|
{marketplace.map(({ profile: p, alreadyInLibrary }) => (
|
||||||
|
<Card key={p.id}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
{p.name} <Badge variant="outline">{p.version}</Badge>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{p.profileKey}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{p.summary}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-2">
|
||||||
|
{alreadyInLibrary ? (
|
||||||
|
<Badge variant="secondary" className="ml-auto">In your library</Badge>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" disabled={busy} className="ml-auto" onClick={() => install(p.id, p.name)}>
|
||||||
|
<Download data-icon="inline-start" /> Install
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{marketplace.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Nothing published yet. Publish one of your own to share it here.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editor && (
|
||||||
|
<Sheet open onOpenChange={(o) => !o && setEditor(null)}>
|
||||||
|
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-2xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{editor.title}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
A PRODUCT.md: YAML frontmatter (product, version, summary) + a Markdown brief. Re-uploading the
|
||||||
|
same product+version updates it; bump the version for a new one.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||||
|
<MarkdownEditor
|
||||||
|
rows={22}
|
||||||
|
mono
|
||||||
|
frontmatter
|
||||||
|
value={editor.content}
|
||||||
|
onChange={(content) => setEditor({ ...editor, content })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setEditor(null)}>Cancel</Button>
|
||||||
|
<Button disabled={busy || !editor.content.trim()} onClick={upload}>Save profile</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<Sheet open onOpenChange={(o) => !o && setPreview(null)}>
|
||||||
|
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-2xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{preview.title}</SheetTitle>
|
||||||
|
<SheetDescription>The full PRODUCT.md — read-only. Fork or make a new version to edit.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||||
|
<MarkdownEditor rows={22} mono frontmatter value={preview.content} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileGroupCard({
|
||||||
|
versions,
|
||||||
|
products,
|
||||||
|
busy,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
onNewVersion,
|
||||||
|
onFork,
|
||||||
|
onPublish,
|
||||||
|
onUnpublish,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
versions: ProductProfileSummary[]
|
||||||
|
products: Product[]
|
||||||
|
busy: boolean
|
||||||
|
onView: (version: string) => void
|
||||||
|
onEdit: (version: string) => void
|
||||||
|
onNewVersion: (version: string) => void
|
||||||
|
onFork: (version: string) => void
|
||||||
|
onPublish: (version: string) => void
|
||||||
|
onUnpublish: (version: string) => void
|
||||||
|
onApply: (version: string, productId: string, productName: string) => void
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState(versions[0].version)
|
||||||
|
const current = versions.find((v) => v.version === selected) ?? versions[0]
|
||||||
|
const isBuiltin = current.origin === 'Builtin'
|
||||||
|
const isListed = current.visibility === 'Public'
|
||||||
|
const canPublish = !isBuiltin && current.status === 'Published'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
{current.name}
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{current.profileKey}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">{current.summary}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={current.status === 'Published' ? 'default' : 'secondary'}>{current.status}</Badge>
|
||||||
|
<Badge variant="outline">{current.origin}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-2">
|
||||||
|
{versions.length > 1 ? (
|
||||||
|
<Select value={selected} onValueChange={setSelected}>
|
||||||
|
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{versions.map((v) => <SelectItem key={v.version} value={v.version}>{v.version}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{current.version}</Badge>
|
||||||
|
)}
|
||||||
|
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
||||||
|
|
||||||
|
{products.length > 0 && (
|
||||||
|
<Select value="" onValueChange={(productId) => onApply(current.version, productId, products.find((p) => p.id === productId)?.name ?? 'product')}>
|
||||||
|
<SelectTrigger className="ml-auto w-44"><SelectValue placeholder="Apply to product…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('flex items-center gap-2', products.length === 0 && 'ml-auto')}>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onView(current.version)}>
|
||||||
|
<Eye data-icon="inline-start" /> View
|
||||||
|
</Button>
|
||||||
|
{isBuiltin ? (
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
|
||||||
|
<GitFork data-icon="inline-start" /> Fork to my org
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
|
||||||
|
<Pencil data-icon="inline-start" /> Edit
|
||||||
|
</Button>
|
||||||
|
{isListed ? (
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onUnpublish(current.version)}>Unlist</Button>
|
||||||
|
) : canPublish ? (
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onPublish(current.version)}>
|
||||||
|
<Upload data-icon="inline-start" /> Publish
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
|
||||||
|
<Plus data-icon="inline-start" /> New version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user