62883ed01f
Orgs can now share skills across the tenant boundary — the next step after the per-org library.
Endpoints (all ManageSkills-gated + audited):
- POST /{key}/publish — list one of your published versions on the marketplace (Visibility→Public;
only a Published/golden-tested skill may be listed). POST /{key}/unpublish reverses it.
- POST /install — copy a publicly-listed skill (by row id) into your org as a private Installed
copy; rejects installing your own skill and duplicate (org+key+version) installs.
- GET /marketplace?organizationId= — other orgs' Authored+Public+Published skills (yours excluded),
each flagged whether that exact (key, version) is already in your library.
- SkillSummary now carries Id (install targets a specific source row). Authored skills default to
private — listing is an explicit publish step, never a side effect of authoring.
UI (Skills page): a Marketplace tab with Install / "In your library"; Publish / Unlist on your own
published skills; a "Listed" badge.
Fixes from the adversarial review (4 confirmed findings, all addressed):
- HIGH — Public⟹Published is now a domain invariant (Skill.Index forces PrivateToOrg whenever the
re-derived status isn't Published), so re-authoring a listed version without golden tests can no
longer leave it Public+Draft or decouple the marketplace gate from the eval gate.
- MEDIUM — install now uses an insert-only indexer path so the (org,key,version) unique index is the
source of truth: a race with a concurrent install/author becomes a clean 409, never an in-place
clobber of an existing row's content/ownership.
- MEDIUM/LOW — AlreadyInLibrary is computed per (key, version) to match the install conflict rule, so
a newer, not-yet-owned version of a key you already hold still shows as installable.
Verified: ArchitectureTests 8/8, IntegrationTests 47/47 (SkillMarketplaceTests: publish gate, own-org
exclusion, cross-org list→install→private copy, duplicate 409, per-version flag, Public⟹Published
invariant, Member 403), client build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
591 lines
22 KiB
TypeScript
591 lines
22 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { BookMarked, Download, GitFork, Pencil, Plus, Store, Trash2, Upload } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { AppShell } from '@/components/AppShell'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { api } from '@/lib/api'
|
|
import { useAuth } from '@/store/auth'
|
|
|
|
interface ActionDto {
|
|
name: string
|
|
risk: string
|
|
description?: string | null
|
|
}
|
|
|
|
interface GoldenTest {
|
|
input: string
|
|
expected: string
|
|
}
|
|
|
|
interface SkillSummary {
|
|
id: string
|
|
skillKey: string
|
|
name: string
|
|
version: string
|
|
summary: string | null
|
|
roles: string[]
|
|
visibility: string
|
|
minTier: string
|
|
status: string
|
|
origin: string
|
|
organizationId: string | null
|
|
goldenTestCount: number
|
|
actions: ActionDto[]
|
|
}
|
|
|
|
interface SkillDetail {
|
|
skill: SkillSummary
|
|
inputs: string | null
|
|
outputs: string | null
|
|
tools: string[]
|
|
context: string[]
|
|
goldenTests: GoldenTest[]
|
|
body: string
|
|
}
|
|
|
|
interface MarketplaceEntry {
|
|
skill: SkillSummary
|
|
alreadyInLibrary: boolean
|
|
}
|
|
|
|
type Mode = 'new' | 'version' | 'edit'
|
|
|
|
interface FormState {
|
|
mode: Mode
|
|
skillKey: string
|
|
name: string
|
|
version: string
|
|
summary: string
|
|
roles: string
|
|
inputs: string
|
|
outputs: string
|
|
tools: string
|
|
context: string
|
|
visibility: string
|
|
minTier: string
|
|
body: string
|
|
actions: ActionDto[]
|
|
goldenTests: GoldenTest[]
|
|
}
|
|
|
|
const COMMON_ROLES = 'product-owner, engineer, qa, designer, analyst'
|
|
const RISKS = ['read', 'draft', 'publish', 'destructive']
|
|
const VISIBILITIES = ['public', 'private']
|
|
const TIERS = ['free', 'team', 'scale', 'enterprise']
|
|
|
|
const emptyForm = (): FormState => ({
|
|
mode: 'new',
|
|
skillKey: '',
|
|
name: '',
|
|
version: '1.0.0',
|
|
summary: '',
|
|
roles: '',
|
|
inputs: '',
|
|
outputs: '',
|
|
tools: '',
|
|
context: '',
|
|
visibility: 'private',
|
|
minTier: 'free',
|
|
body: '',
|
|
actions: [],
|
|
goldenTests: [],
|
|
})
|
|
|
|
/** Bump the last numeric segment of a semver-ish string (1.0.0 → 1.0.1). */
|
|
function bumpPatch(version: string): string {
|
|
const parts = version.split('.')
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const n = Number(parts[i])
|
|
if (Number.isInteger(n)) {
|
|
parts[i] = String(n + 1)
|
|
return parts.join('.')
|
|
}
|
|
}
|
|
return `${version}.1`
|
|
}
|
|
|
|
const csv = (s: string): string[] =>
|
|
s.split(',').map((x) => x.trim()).filter(Boolean)
|
|
|
|
/** The org's skill library: builtin starter skills + skills the company authors and versions itself. */
|
|
export function SkillsPage() {
|
|
const organizationId = useAuth((s) => s.organizationId)
|
|
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
|
|
const [skills, setSkills] = useState<SkillSummary[]>([])
|
|
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
|
|
const [form, setForm] = useState<FormState | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
if (!organizationId) return
|
|
try {
|
|
const [lib, market] = await Promise.all([
|
|
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
|
|
api.get<MarketplaceEntry[]>(`/api/skills/marketplace?organizationId=${organizationId}`),
|
|
])
|
|
setSkills(lib)
|
|
setMarketplace(market)
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
}
|
|
}, [organizationId])
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [load])
|
|
|
|
// Group every version under its key; newest (and the org's own) first comes from the API order.
|
|
const groups = useMemo(() => {
|
|
const byKey = new Map<string, SkillSummary[]>()
|
|
for (const s of skills) {
|
|
const list = byKey.get(s.skillKey) ?? []
|
|
list.push(s)
|
|
byKey.set(s.skillKey, list)
|
|
}
|
|
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
}, [skills])
|
|
|
|
const openForm = async (key: string, version: string, mode: Mode) => {
|
|
try {
|
|
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
|
|
const d = details.find((x) => x.skill.version === version) ?? details[0]
|
|
if (!d) return
|
|
setForm({
|
|
mode,
|
|
skillKey: d.skill.skillKey,
|
|
name: d.skill.name,
|
|
version: mode === 'version' ? bumpPatch(d.skill.version) : d.skill.version,
|
|
summary: d.skill.summary ?? '',
|
|
roles: d.skill.roles.join(', '),
|
|
inputs: d.inputs ?? '',
|
|
outputs: d.outputs ?? '',
|
|
tools: d.tools.join(', '),
|
|
context: d.context.join(', '),
|
|
visibility: d.skill.visibility === 'PrivateToOrg' ? 'private' : 'public',
|
|
minTier: d.skill.minTier.toLowerCase(),
|
|
body: d.body,
|
|
actions: d.skill.actions.map((a) => ({ name: a.name, risk: a.risk.toLowerCase(), description: a.description ?? '' })),
|
|
goldenTests: d.goldenTests.map((g) => ({ ...g })),
|
|
})
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
}
|
|
}
|
|
|
|
const fork = async (key: string, version: string) => {
|
|
setBusy(true)
|
|
try {
|
|
await api.post(`/api/skills/${key}/fork`, { organizationId, version })
|
|
toast.success(`Forked ${key} into your org — edit it to customize.`)
|
|
await load()
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const setListed = async (key: string, version: string, listed: boolean) => {
|
|
setBusy(true)
|
|
try {
|
|
await api.post(`/api/skills/${key}/${listed ? 'publish' : 'unpublish'}`, { organizationId, version })
|
|
toast.success(listed ? `Published ${key}@${version} to the marketplace.` : `Unlisted ${key}@${version}.`)
|
|
await load()
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const install = async (sourceSkillId: string, name: string) => {
|
|
setBusy(true)
|
|
try {
|
|
await api.post('/api/skills/install', { organizationId, sourceSkillId })
|
|
toast.success(`Installed ${name} into your library.`)
|
|
await load()
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const save = async () => {
|
|
if (!form) return
|
|
setBusy(true)
|
|
try {
|
|
await api.post('/api/skills/authored', {
|
|
organizationId,
|
|
skillKey: form.skillKey.trim(),
|
|
name: form.name.trim(),
|
|
version: form.version.trim(),
|
|
summary: form.summary.trim() || null,
|
|
roles: csv(form.roles),
|
|
inputs: form.inputs.trim() || null,
|
|
outputs: form.outputs.trim() || null,
|
|
tools: csv(form.tools),
|
|
context: csv(form.context),
|
|
visibility: form.visibility,
|
|
minTier: form.minTier,
|
|
body: form.body,
|
|
actions: form.actions
|
|
.filter((a) => a.name.trim())
|
|
.map((a) => ({ name: a.name.trim(), risk: a.risk, description: a.description?.trim() || null })),
|
|
goldenTests: form.goldenTests.filter((g) => g.input.trim() && g.expected.trim()),
|
|
})
|
|
toast.success(`Saved ${form.skillKey}@${form.version}.`)
|
|
setForm(null)
|
|
await load()
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const willPublish = !!form && csv(form.roles).length > 0 && form.goldenTests.some((g) => g.input.trim() && g.expected.trim())
|
|
|
|
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">
|
|
<BookMarked className="size-6" /> Skills
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Your company's skill library. Builtin starter skills are shared; author and version your own.
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => setForm(emptyForm())}>
|
|
<Plus data-icon="inline-start" /> New skill
|
|
</Button>
|
|
</header>
|
|
|
|
<div className="mb-4 inline-flex rounded-lg border p-1">
|
|
<SegBtn active={tab === 'library'} onClick={() => setTab('library')} icon={BookMarked}>Library</SegBtn>
|
|
<SegBtn active={tab === 'marketplace'} onClick={() => setTab('marketplace')} icon={Store}>Marketplace</SegBtn>
|
|
</div>
|
|
|
|
{tab === 'library' ? (
|
|
<div className="flex flex-col gap-4">
|
|
{groups.map(([key, versions]) => (
|
|
<SkillGroupCard
|
|
key={key}
|
|
versions={versions}
|
|
busy={busy}
|
|
onNewVersion={(v) => openForm(key, v, 'version')}
|
|
onEdit={(v) => openForm(key, v, 'edit')}
|
|
onFork={(v) => fork(key, v)}
|
|
onPublish={(v) => setListed(key, v, true)}
|
|
onUnpublish={(v) => setListed(key, v, false)}
|
|
/>
|
|
))}
|
|
{groups.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No skills yet. Run a Git sync for builtins, or author one.</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Published skills shared by other organizations. Install a copy into your library — it lands private,
|
|
so you can edit or version it freely.
|
|
</p>
|
|
{marketplace.map(({ skill: s, alreadyInLibrary }) => (
|
|
<Card key={s.id}>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
{s.name} <Badge variant="outline">{s.version}</Badge>
|
|
<span className="font-mono text-xs text-muted-foreground">{s.skillKey}</span>
|
|
</CardTitle>
|
|
<CardDescription>{s.summary}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-wrap items-center gap-2">
|
|
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
|
<span className="text-xs text-muted-foreground">
|
|
{s.goldenTestCount} golden test{s.goldenTestCount === 1 ? '' : 's'}
|
|
</span>
|
|
{alreadyInLibrary ? (
|
|
<Badge variant="secondary" className="ml-auto">In your library</Badge>
|
|
) : (
|
|
<Button size="sm" disabled={busy} className="ml-auto" onClick={() => install(s.id, s.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 skills to share it here.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{form && (
|
|
<Sheet open onOpenChange={(o) => !o && setForm(null)}>
|
|
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
|
|
<SheetHeader>
|
|
<SheetTitle>
|
|
{form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`}
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
{willPublish
|
|
? 'Has roles + a golden test — saves as Published.'
|
|
: 'Add ≥1 role and ≥1 golden test to publish; otherwise saved as Draft.'}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="flex flex-col gap-4 px-4 pb-6">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Skill key (id)">
|
|
<Input
|
|
value={form.skillKey}
|
|
disabled={form.mode !== 'new'}
|
|
onChange={(e) => setForm({ ...form, skillKey: e.target.value })}
|
|
placeholder="api-endpoint-design"
|
|
/>
|
|
</Field>
|
|
<Field label="Version">
|
|
<Input value={form.version} disabled={form.mode === 'edit'} onChange={(e) => setForm({ ...form, version: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
<Field label="Name">
|
|
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" />
|
|
</Field>
|
|
<Field label="Summary">
|
|
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} />
|
|
</Field>
|
|
<Field label={`Roles (comma-separated — e.g. ${COMMON_ROLES})`}>
|
|
<Input value={form.roles} onChange={(e) => setForm({ ...form, roles: e.target.value })} placeholder="engineer" />
|
|
</Field>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Inputs">
|
|
<Input value={form.inputs} onChange={(e) => setForm({ ...form, inputs: e.target.value })} />
|
|
</Field>
|
|
<Field label="Outputs">
|
|
<Input value={form.outputs} onChange={(e) => setForm({ ...form, outputs: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Visibility">
|
|
<Pick value={form.visibility} options={VISIBILITIES} onChange={(v) => setForm({ ...form, visibility: v })} />
|
|
</Field>
|
|
<Field label="Min tier">
|
|
<Pick value={form.minTier} options={TIERS} onChange={(v) => setForm({ ...form, minTier: v })} />
|
|
</Field>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Tools (comma-separated)">
|
|
<Input value={form.tools} onChange={(e) => setForm({ ...form, tools: e.target.value })} />
|
|
</Field>
|
|
<Field label="Context docs (comma-separated)">
|
|
<Input value={form.context} onChange={(e) => setForm({ ...form, context: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
|
|
<Repeater
|
|
label="Actions (risk-tagged)"
|
|
onAdd={() => setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })}
|
|
>
|
|
{form.actions.map((a, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<Input
|
|
className="flex-1" placeholder="action name" value={a.name}
|
|
onChange={(e) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })}
|
|
/>
|
|
<Pick
|
|
value={a.risk} options={RISKS} className="w-32"
|
|
onChange={(v) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })}
|
|
/>
|
|
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, actions: form.actions.filter((_, j) => j !== i) })}>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</Repeater>
|
|
|
|
<Repeater
|
|
label="Golden tests (gate publishing)"
|
|
onAdd={() => setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })}
|
|
>
|
|
{form.goldenTests.map((g, i) => (
|
|
<div key={i} className="flex flex-col gap-2 rounded-md border p-2">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
className="flex-1" placeholder="input" value={g.input}
|
|
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })}
|
|
/>
|
|
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, goldenTests: form.goldenTests.filter((_, j) => j !== i) })}>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
rows={2} placeholder="expected output" value={g.expected}
|
|
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, expected: e.target.value } : x)) })}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Repeater>
|
|
|
|
<Field label="Body (the prompt the agent runs)">
|
|
<Textarea rows={8} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="You are the engineer. Turn the input into…" />
|
|
</Field>
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button variant="ghost" onClick={() => setForm(null)}>Cancel</Button>
|
|
<Button disabled={busy || !form.skillKey.trim() || !form.name.trim() || !form.body.trim()} onClick={save}>
|
|
Save {willPublish ? '& publish' : 'draft'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)}
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
function SkillGroupCard({
|
|
versions,
|
|
busy,
|
|
onNewVersion,
|
|
onEdit,
|
|
onFork,
|
|
onPublish,
|
|
onUnpublish,
|
|
}: {
|
|
versions: SkillSummary[]
|
|
busy: boolean
|
|
onNewVersion: (version: string) => void
|
|
onEdit: (version: string) => void
|
|
onFork: (version: string) => void
|
|
onPublish: (version: string) => void
|
|
onUnpublish: (version: 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.skillKey}</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 ? (
|
|
<Pick value={selected} options={versions.map((v) => v.version)} className="w-28" onChange={setSelected} />
|
|
) : (
|
|
<Badge variant="outline">{current.version}</Badge>
|
|
)}
|
|
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
|
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
|
|
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{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>
|
|
)
|
|
}
|
|
|
|
function SegBtn({ active, onClick, icon: Icon, children }: { active: boolean; onClick: () => void; icon: typeof BookMarked; children: React.ReactNode }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition ${active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
|
>
|
|
<Icon className="size-4" /> {children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label className="text-xs">{label}</Label>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Pick({ value, options, onChange, className }: { value: string; options: string[]; onChange: (v: string) => void; className?: string }) {
|
|
return (
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger className={className ?? 'w-full'}><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{options.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
}
|
|
|
|
function Repeater({ label, onAdd, children }: { label: string; onAdd: () => void; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">{label}</Label>
|
|
<Button size="sm" variant="ghost" onClick={onAdd}><Plus data-icon="inline-start" /> Add</Button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|