import { useEffect, useMemo, useRef, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AgentDesiredSkillEntry, Agent, CatalogSkill, CatalogSkillFileDetail, CatalogSkillSource, CompanySkillCompatibility, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, CompanySkillFileInventoryEntry, CompanySkillListItem, CompanySkillProjectScanResult, CompanySkillSharingScope, CompanySkillSourceBadge, CompanySkillTrustLevel, CompanySkillUpdateStatus, CompanySkillVersion, } from "@paperclipai/shared"; import { companySkillsApi } from "../api/companySkills"; import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { MarkdownBody } from "../components/MarkdownBody"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { PageSkeleton } from "../components/PageSkeleton"; import { CopyText } from "../components/CopyText"; import { Identity } from "../components/Identity"; import { AgentIcon } from "../components/AgentIconPicker"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { buildLineDiff, type DiffRow } from "../lib/line-diff"; import { cn, relativeTime } from "../lib/utils"; import { resolveSkillSummaryText } from "../lib/company-skill-summary"; import { parseSkillRoute, skillRoute, withRouteSkill, resolveSkillRouteToken, type CompanySkillRouteSubject, } from "../lib/company-skill-routes"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { AlertTriangle, ArrowUpCircle, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, Code2, Download, Eye, Filter, FileCode2, FileText, Folder, FolderOpen, GitFork, Github, Globe, HelpCircle, LayoutGrid, Link2, Lock, ExternalLink, Paperclip, Pause, Pencil, Pin, Plus, Copy, RefreshCw, Save, Search, Settings, ShieldCheck, Star, Trash2, Users, History, XOctagon, } from "lucide-react"; type SkillTreeNode = { name: string; path: string | null; kind: "dir" | "file"; fileKind?: CompanySkillFileInventoryEntry["kind"]; children: SkillTreeNode[]; }; const SKILL_TREE_BASE_INDENT = 16; const SKILL_TREE_STEP_INDENT = 24; const SKILL_TREE_ROW_HEIGHT_CLASS = "min-h-9"; function VercelMark(props: SVGProps) { return ( ); } function stripFrontmatter(markdown: string) { const normalized = markdown.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) return normalized.trim(); const closing = normalized.indexOf("\n---\n", 4); if (closing < 0) return normalized.trim(); return normalized.slice(closing + 5).trim(); } function splitFrontmatter(markdown: string): { frontmatter: string | null; body: string } { const normalized = markdown.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) { return { frontmatter: null, body: normalized }; } const closing = normalized.indexOf("\n---\n", 4); if (closing < 0) { return { frontmatter: null, body: normalized }; } return { frontmatter: normalized.slice(4, closing).trim(), body: normalized.slice(closing + 5).trimStart(), }; } function mergeFrontmatter(markdown: string, body: string) { const parsed = splitFrontmatter(markdown); if (!parsed.frontmatter) return body; return ["---", parsed.frontmatter, "---", "", body].join("\n"); } function buildTree(entries: CompanySkillFileInventoryEntry[]) { const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] }; for (const entry of entries) { const segments = entry.path.split("/").filter(Boolean); let current = root; let currentPath = ""; for (const [index, segment] of segments.entries()) { currentPath = currentPath ? `${currentPath}/${segment}` : segment; const isLeaf = index === segments.length - 1; let next = current.children.find((child) => child.name === segment); if (!next) { next = { name: segment, path: isLeaf ? entry.path : currentPath, kind: isLeaf ? "file" : "dir", fileKind: isLeaf ? entry.kind : undefined, children: [], }; current.children.push(next); } current = next; } } function sortNode(node: SkillTreeNode) { node.children.sort((left, right) => { if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1; if (left.name === "SKILL.md") return -1; if (right.name === "SKILL.md") return 1; return left.name.localeCompare(right.name); }); node.children.forEach(sortNode); } sortNode(root); return root.children; } function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string | null) { const normalizedLabel = sourceLabel?.toLowerCase() ?? ""; const isSkillsShManaged = normalizedLabel.includes("skills.sh") || normalizedLabel.includes("vercel-labs/skills"); switch (sourceBadge) { case "skills_sh": return { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" }; case "github": return isSkillsShManaged ? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" } : { icon: Github, label: sourceLabel ?? "GitHub", managedLabel: "GitHub managed" }; case "url": return { icon: Link2, label: sourceLabel ?? "URL", managedLabel: "URL managed" }; case "local": return { icon: Folder, label: sourceLabel ?? "Folder", managedLabel: "Folder managed" }; case "paperclip": return { icon: Paperclip, label: sourceLabel ?? "Paperclip", managedLabel: "Paperclip managed" }; default: return { icon: Boxes, label: sourceLabel ?? "Catalog", managedLabel: "Catalog managed" }; } } function shortRef(ref: string | null | undefined) { if (!ref) return null; return ref.slice(0, 7); } function middleTruncate(value: string, maxLength = 72) { if (value.length <= maxLength) return value; const edgeLength = Math.floor((maxLength - 3) / 2); return `${value.slice(0, edgeLength)}...${value.slice(value.length - edgeLength)}`; } function formatProjectScanSummary(result: CompanySkillProjectScanResult) { const parts = [ `${result.discovered} found`, `${result.imported.length} imported`, `${result.updated.length} updated`, ]; if (result.conflicts.length > 0) parts.push(`${result.conflicts.length} conflicts`); if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`); return `${parts.join(", ")} across ${result.scannedWorkspaces} workspace${result.scannedWorkspaces === 1 ? "" : "s"}.`; } function fileIcon(kind: CompanySkillFileInventoryEntry["kind"]) { if (kind === "script" || kind === "reference") return FileCode2; return FileText; } function catalogSkillRoute(catalogRef: string) { return `/skills?view=catalog&catalog=${encodeURIComponent(catalogRef)}`; } function parentDirectoryPaths(filePath: string) { const segments = filePath.split("/").filter(Boolean); const parents: string[] = []; for (let index = 0; index < segments.length - 1; index += 1) { parents.push(segments.slice(0, index + 1).join("/")); } return parents; } type SourceFilter = "all" | "company" | "bundled" | "optional" | "external"; const SOURCE_FILTER_LABELS: Record = { all: "All", company: "Company", bundled: "Bundled", optional: "Optional", external: "External", }; function readonlyMetadataValue(metadata: Record | null | undefined, key: string): string | null { if (!metadata || typeof metadata !== "object") return null; const raw = (metadata as Record)[key]; if (typeof raw !== "string") return null; const trimmed = raw.trim(); return trimmed.length > 0 ? trimmed : null; } function readonlyMetadataKind(metadata: Record | null | undefined): "bundled" | "optional" | null { const value = readonlyMetadataValue(metadata, "sourceKind") ?? readonlyMetadataValue(metadata, "catalogKind"); if (value === "bundled") return "bundled"; if (value === "optional") return "optional"; return null; } function classifySource(skill: { sourceBadge: CompanySkillSourceBadge; sourceType: string; catalogKind?: "bundled" | "optional" | null; metadata?: Record | null; }): SourceFilter { if (skill.sourceBadge === "paperclip") return "company"; if (skill.sourceType === "local_path" && !skill.sourceBadge.toString().includes("github")) { return "company"; } if (skill.sourceType === "catalog" || skill.sourceBadge === "catalog") { const kind = skill.catalogKind ?? readonlyMetadataKind(skill.metadata); if (kind === "bundled") return "bundled"; if (kind === "optional") return "optional"; return "company"; } if (skill.sourceBadge === "github" || skill.sourceBadge === "skills_sh" || skill.sourceBadge === "url" || skill.sourceBadge === "local") { return "external"; } return "company"; } function SourceFilterMenu({ counts, value, onChange, }: { counts: Record; value: SourceFilter; onChange: (next: SourceFilter) => void; }) { const filters: SourceFilter[] = ["all", "company", "bundled", "optional", "external"]; const activeFilterCount = value === "all" ? 0 : 1; return ( Source onChange(next as SourceFilter)}> {filters.map((filter) => ( {SOURCE_FILTER_LABELS[filter]} {counts[filter] ?? 0} ))} ); } function CatalogFilterMenu({ kindFilter, categoryFilter, categories, onKindChange, onCategoryChange, }: { kindFilter: "all" | "bundled" | "optional"; categoryFilter: string; categories: string[]; onKindChange: (next: "all" | "bundled" | "optional") => void; onCategoryChange: (next: string) => void; }) { const activeFilterCount = (kindFilter === "all" ? 0 : 1) + (categoryFilter ? 1 : 0); return ( Type onKindChange(next as "all" | "bundled" | "optional")}> All Bundled Optional Category onCategoryChange(next === "__all__" ? "" : next)}> All categories {categories.map((category) => ( {category} ))} ); } function TrustChip({ level }: { level: CompanySkillTrustLevel }) { const map = { markdown_only: { icon: ShieldCheck, label: "Markdown only", tooltip: "Text only — no scripts, no binaries, no assets.", className: "border-border bg-muted/40 text-muted-foreground", }, assets: { icon: Folder, label: "Includes assets", tooltip: "Ships images, fonts, or other non-script files.", className: "border-cyan-500/30 bg-cyan-500/10 text-cyan-200", }, scripts_executables: { icon: AlertTriangle, label: "Includes scripts", tooltip: "Ships executable scripts. Review before installing.", className: "border-amber-500/40 bg-amber-500/10 text-amber-200", }, } as const; const config = map[level] ?? map.markdown_only; const Icon = config.icon; return ( {config.tooltip} ); } function CompatChip({ compatibility }: { compatibility: CompanySkillCompatibility }) { if (compatibility === "compatible") return null; const map = { unknown: { icon: HelpCircle, label: "Unknown format", tooltip: "Paperclip could not validate this skill as Agent Skills markdown. Install at your own risk.", className: "border-yellow-500/40 bg-yellow-500/10 text-yellow-200", }, invalid: { icon: XOctagon, label: "Invalid", tooltip: "This skill cannot be installed — content is not valid Agent Skills markdown.", className: "border-destructive/40 bg-destructive/10 text-destructive", }, } as const; const config = map[compatibility]; const Icon = config.icon; return ( {config.tooltip} ); } function ProvenanceBadge({ packageName, packageVersion }: { packageName: string | null; packageVersion: string | null }) { if (!packageName) return null; return ( Installed from the app-shipped skills catalog. Provenance is signed by package version and content hash. ); } function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } // --------------------------------------------------------------------------- // Skills Store discovery grid (PAP-10879) // --------------------------------------------------------------------------- type DiscoveryTab = "all" | "installed" | "catalog" | "bundled"; const DISCOVERY_TABS: DiscoveryTab[] = ["all", "installed", "catalog", "bundled"]; type DiscoverySort = "agents" | "stars" | "forks" | "recent" | "alphabetical"; const DISCOVERY_SORT_LABELS: Record = { agents: "Most agents", stars: "Most stars", forks: "Most forks", recent: "Recently updated", alphabetical: "Alphabetical", }; const DISCOVERY_SORTS: DiscoverySort[] = ["agents", "stars", "forks", "recent", "alphabetical"]; export type DiscoveryCard = { key: string; skillId: string | null; catalogRef: string | null; name: string; slug: string; author: string; version: string | null; tagline: string | null; description: string | null; categories: string[]; iconUrl: string | null; color: string | null; starCount: number; agentCount: number; forkCount: number; installed: boolean; required: boolean; forkedFrom: boolean; updatedAt: number; sourceBadge?: CompanySkillSourceBadge | null; sourceLabel?: string | null; }; // Stable palette used to auto-assign an accent colour to a skill when the // backend has not stored an explicit one. Colour is derived from the skill key // so the same skill always lands on the same hue. const DISCOVERY_ACCENTS = [ "#6366f1", "#0ea5e9", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316", "#22c55e", "#3b82f6", "#a855f7", ]; function skillAccentColor(key: string, explicit: string | null | undefined): string { const trimmed = explicit?.trim(); if (trimmed) return trimmed; let hash = 0; for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) >>> 0; return DISCOVERY_ACCENTS[hash % DISCOVERY_ACCENTS.length]; } function SkillCardIcon({ card, size = 36 }: { card: DiscoveryCard; size?: number }) { if (card.iconUrl) { return ( ); } const accent = skillAccentColor(card.key, card.color); const letter = (card.slug || card.name || "?").trim().charAt(0).toUpperCase(); return ( ); } function discoveryVersionLabel(skill: { packageVersion: string | null; sourceRef: string | null; }, required: boolean): string | null { if (skill.packageVersion) return `v${skill.packageVersion}`; if (required) return "core"; if (skill.sourceRef) return shortRef(skill.sourceRef); return null; } function uniqueCategories(values: (string | null | undefined)[]): string[] { const out: string[] = []; const seen = new Set(); for (const value of values) { const slug = value?.trim(); if (!slug || seen.has(slug)) continue; seen.add(slug); out.push(slug); } return out; } function normalizeSkillDraftSlug(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80); } function splitCategoryDraft(value: string) { return value .split(",") .map((entry) => normalizeSkillDraftSlug(entry)) .filter(Boolean); } function defaultSkillMarkdown(name: string, tagline: string) { const title = name.trim() || "New Skill"; const summary = tagline.trim() || "Describe when agents should use this skill."; return [ "---", `name: ${title}`, `description: ${summary}`, "---", "", `# ${title}`, "", summary, "", "## When To Use", "", "- Use this skill when the task needs its specialized workflow.", "", "## Workflow", "", "1. Inspect the task context.", "2. Apply the workflow carefully.", "3. Report what changed and how it was verified.", "", ].join("\n"); } // Merge installed company skills and the install catalog into one card model. // Installed skills win on dedup (they carry the richer social-proof metadata); // catalog-only skills fill in the rest of the discoverable surface. function buildDiscoveryCards( installed: CompanySkillListItem[], catalog: CatalogSkill[], ): DiscoveryCard[] { const catalogByKey = new Map(catalog.map((entry) => [entry.key, entry])); const cards: DiscoveryCard[] = []; const installedKeys = new Set(); for (const skill of installed) { installedKeys.add(skill.key); const catalogMatch = catalogByKey.get(skill.key) ?? null; const required = skill.catalogKind === "bundled" || catalogMatch?.kind === "bundled"; cards.push({ key: skill.key, skillId: skill.id, catalogRef: catalogMatch ? catalogMatch.id : null, name: skill.name, slug: skill.slug, author: skill.authorName ?? skill.sourceLabel ?? "you", version: discoveryVersionLabel(skill, required), tagline: skill.tagline ?? null, description: skill.description ?? null, categories: uniqueCategories([...(skill.categories ?? []), catalogMatch?.category]), iconUrl: skill.iconUrl, color: skill.color, starCount: skill.starCount ?? 0, agentCount: skill.attachedAgentCount ?? 0, forkCount: skill.forkCount ?? 0, installed: true, required, forkedFrom: Boolean(skill.forkedFromSkillId), updatedAt: new Date(skill.updatedAt).getTime() || 0, sourceBadge: skill.sourceBadge, sourceLabel: skill.sourceLabel, }); } for (const entry of catalog) { if (installedKeys.has(entry.key)) continue; const required = entry.kind === "bundled"; cards.push({ key: entry.key, skillId: null, catalogRef: entry.id, name: entry.name, slug: entry.slug, author: entry.packageName ?? "Paperclip", version: discoveryVersionLabel({ packageVersion: entry.packageVersion ?? null, sourceRef: null }, required), tagline: null, description: entry.description, categories: uniqueCategories([entry.category, ...(entry.tags ?? [])]), iconUrl: null, color: null, starCount: 0, agentCount: 0, forkCount: 0, installed: false, required, forkedFrom: false, updatedAt: 0, sourceBadge: "catalog", sourceLabel: entry.packageName ?? "Catalog", }); } return cards; } function cardsForTab(cards: DiscoveryCard[], tab: DiscoveryTab): DiscoveryCard[] { switch (tab) { case "installed": return cards.filter((card) => card.installed); case "catalog": return cards.filter((card) => card.catalogRef != null); case "bundled": return cards.filter((card) => card.required); case "all": default: return cards; } } function sortDiscoveryCards(cards: DiscoveryCard[], sort: DiscoverySort, demoteRequired: boolean): DiscoveryCard[] { const byName = (a: DiscoveryCard, b: DiscoveryCard) => a.name.localeCompare(b.name); const sorted = [...cards].sort((a, b) => { // Bundled/required skills are demoted out of discovery rankings (except on // the Bundled tab, where they are the whole point). if (demoteRequired && a.required !== b.required) return a.required ? 1 : -1; switch (sort) { case "stars": return b.starCount - a.starCount || byName(a, b); case "forks": return b.forkCount - a.forkCount || byName(a, b); case "recent": return b.updatedAt - a.updatedAt || byName(a, b); case "alphabetical": return byName(a, b); case "agents": default: return b.agentCount - a.agentCount || byName(a, b); } }); return sorted; } function discoveryMatchesSearch(card: DiscoveryCard, query: string): boolean { if (!query) return true; const haystack = [ card.name, card.slug, card.author, card.tagline ?? "", card.description ?? "", card.categories.join(" "), ].join(" ").toLowerCase(); return haystack.includes(query.toLowerCase()); } function SkillStat({ icon: Icon, value }: { icon: typeof Star; value: string }) { return ( ); } function SkillCategoryChip({ label }: { label: string }) { return ( {label} ); } function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: DiscoveryCard) => void }) { return ( ); } export type DiscoveryCategory = { slug: string; count: number }; function CategoryNav({ categories, total, active, onSelect, }: { categories: DiscoveryCategory[]; total: number; active: string | null; onSelect: (slug: string | null) => void; }) { return ( ); } export function DiscoveryGrid({ tab, tabCounts, onTabChange, categories, categoryTotal, activeCategory, onCategoryChange, search, onSearchChange, sort, onSortChange, cards, onOpenCard, loading, error, totalCount, onCreate, onImport, onBrowseCatalog, onScan, scanPending, scanStatus, }: { tab: DiscoveryTab; tabCounts: Record; onTabChange: (tab: DiscoveryTab) => void; categories: DiscoveryCategory[]; categoryTotal: number; activeCategory: string | null; onCategoryChange: (slug: string | null) => void; search: string; onSearchChange: (value: string) => void; sort: DiscoverySort; onSortChange: (sort: DiscoverySort) => void; cards: DiscoveryCard[]; onOpenCard: (card: DiscoveryCard) => void; loading: boolean; error: string | null; totalCount: number; onCreate: () => void; onImport: () => void; onBrowseCatalog: () => void; onScan: () => void; scanPending: boolean; scanStatus: string | null; }) { // Source filter (github / skills.sh / local / …) lives in the grid so it // narrows whatever the parent already filtered by tab/category/search (PAP-10907 E). const [sourceBadgeFilter, setSourceBadgeFilter] = useState("all"); const availableSources = useMemo(() => { const set = new Set(); for (const card of cards) if (card.sourceBadge) set.add(card.sourceBadge); return Array.from(set).sort(); }, [cards]); useEffect(() => { if (sourceBadgeFilter !== "all" && !availableSources.includes(sourceBadgeFilter)) { setSourceBadgeFilter("all"); } }, [availableSources, sourceBadgeFilter]); const sourceFilteredCards = useMemo( () => (sourceBadgeFilter === "all" ? cards : cards.filter((card) => card.sourceBadge === sourceBadgeFilter)), [cards, sourceBadgeFilter], ); const sourceFilterActive = sourceBadgeFilter !== "all"; return ( // On desktop the store is bounded to the viewport so the category sidebar // and the results pane each scroll independently (PAP-10907). Mobile keeps // the natural page flow.
{/* Secondary category sidebar — the main app nav collapses to a rail while this is present (handled in Layout). */}
{/* Search + sort + actions */}
onSortChange(value as DiscoverySort)}> {DISCOVERY_SORTS.map((option) => ( {DISCOVERY_SORT_LABELS[option]} ))} {availableSources.length > 1 ? ( All sources {availableSources.map((badge) => ( {sourceMeta(badge as CompanySkillSourceBadge, null).label} ))} ) : null} Create new skill Browse catalog Import from path or URL
{/* Mobile category selector (sidebar is hidden below md) */} {categories.length > 0 ? (
onCategoryChange(value === "__all__" ? null : value)} > All ({categoryTotal}) {categories.map((category) => ( {category.slug} ({category.count}) ))}
) : null} {/* Tab strip — Bundled/required lives at the end */}
onTabChange(value as DiscoveryTab)}> All {tabCounts.all} Installed {tabCounts.installed} Catalog {tabCounts.catalog} Bundled {tabCounts.bundled}
{/* Grid body */}
{scanStatus ?

{scanStatus}

: null} {loading ? ( ) : error ? (
{error}
) : sourceFilteredCards.length === 0 ? (
{totalCount === 0 ? (
) : (search || activeCategory || sourceFilterActive) ? (
) : null}
) : ( <>

{sourceFilteredCards.length} {sourceFilteredCards.length === 1 ? "skill" : "skills"} {activeCategory ? · {activeCategory} : null}

{sourceFilteredCards.map((card) => ( ))}
)}
); } type SkillCreateDraft = { name: string; slug: string; tagline: string; description: string; color: string; categories: string[]; markdown: string; sharingScope: Exclude; forkedFromSkillId: string | null; forkedFromName: string | null; }; function buildBlankSkillDraft(): SkillCreateDraft { return { name: "", slug: "", tagline: "", description: "", color: DISCOVERY_ACCENTS[0]!, categories: [], markdown: defaultSkillMarkdown("", ""), sharingScope: "company", forkedFromSkillId: null, forkedFromName: null, }; } function buildForkSkillDraft(skill: CompanySkillDetail): SkillCreateDraft { const name = `${skill.name} Fork`; const slug = normalizeSkillDraftSlug(`${skill.slug}-fork`); return { name, slug, tagline: skill.tagline ?? "", description: skill.description ?? "", color: skill.color ?? skillAccentColor(skill.key, null), categories: skill.categories, markdown: skill.markdown.replace(/^name:\s*.*$/m, `name: ${name}`), sharingScope: "company", forkedFromSkillId: skill.id, forkedFromName: skill.name, }; } function NewSkillWizard({ initialDraft, onCreate, isPending, error, onCancel, }: { initialDraft: SkillCreateDraft; onCreate: (payload: CompanySkillCreateRequest) => void; isPending: boolean; error: string | null; onCancel: () => void; }) { const [step, setStep] = useState(0); const [draft, setDraft] = useState(initialDraft); const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0); const categoryDraft = draft.categories.join(", "); const steps = ["Basics", "Design", "Content", "Review"]; useEffect(() => { setStep(0); setDraft(initialDraft); setSlugDirty(initialDraft.slug.trim().length > 0); }, [initialDraft]); function patchDraft(patch: Partial) { setDraft((current) => ({ ...current, ...patch })); } const nameValid = draft.name.trim().length > 0; const effectiveSlug = draft.slug.trim() || normalizeSkillDraftSlug(draft.name); const effectiveMarkdown = draft.markdown.trim().length > 0 ? draft.markdown : defaultSkillMarkdown(draft.name, draft.tagline); function submit() { onCreate({ name: draft.name.trim(), slug: effectiveSlug || null, description: draft.description.trim() || draft.tagline.trim() || null, markdown: effectiveMarkdown, color: draft.color, tagline: draft.tagline.trim() || null, categories: draft.categories, sharingScope: draft.sharingScope, forkedFromSkillId: draft.forkedFromSkillId, }); } return (
{steps.map((label, index) => ( ))}
{draft.forkedFromName ? (
Forking {draft.forkedFromName}
) : null} {step === 0 ? (
{ const nextName = event.target.value; patchDraft({ name: nextName, slug: slugDirty ? draft.slug : normalizeSkillDraftSlug(nextName), markdown: draft.markdown === defaultSkillMarkdown(draft.name, draft.tagline) ? defaultSkillMarkdown(nextName, draft.tagline) : draft.markdown, }); }} placeholder="Skill name" className="h-9" /> { const nextSlug = normalizeSkillDraftSlug(event.target.value); setSlugDirty(nextSlug.length > 0); patchDraft({ slug: nextSlug }); }} placeholder="skill-shortname" className="h-9 font-mono" />