@deck-ui/skills 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +19 -0
- package/src/community-skill-row.tsx +67 -0
- package/src/community-skills-browser.tsx +134 -0
- package/src/index.ts +22 -0
- package/src/learning-row.tsx +51 -0
- package/src/skill-detail-page.tsx +117 -0
- package/src/skill-row.tsx +27 -0
- package/src/skills-grid.tsx +70 -0
- package/src/styles.css +2 -0
- package/src/types.ts +41 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deck-ui/skills",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": ["src"],
|
|
8
|
+
"peerDependencies": {
|
|
9
|
+
"react": "^19.0.0",
|
|
10
|
+
"react-dom": "^19.0.0",
|
|
11
|
+
"@deck-ui/core": "^0.1.0"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"lucide-react": "^0.577.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CommunitySkill } from "./types"
|
|
2
|
+
import { cn } from "@deck-ui/core"
|
|
3
|
+
import { Plus, Loader2, Check } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
export interface CommunitySkillRowProps {
|
|
6
|
+
skill: CommunitySkill
|
|
7
|
+
installing: boolean
|
|
8
|
+
installed: boolean
|
|
9
|
+
onInstall: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatInstalls(n: number): string {
|
|
13
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
14
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
15
|
+
return String(n)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Convert "vercel-react-best-practices" to "Vercel React Best Practices" */
|
|
19
|
+
function kebabToTitle(s: string): string {
|
|
20
|
+
return s
|
|
21
|
+
.split("-")
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
24
|
+
.join(" ")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CommunitySkillRow({
|
|
28
|
+
skill,
|
|
29
|
+
installing,
|
|
30
|
+
installed,
|
|
31
|
+
onInstall,
|
|
32
|
+
}: CommunitySkillRowProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-black/[0.08]
|
|
36
|
+
bg-white hover:border-black/[0.15] transition-all"
|
|
37
|
+
>
|
|
38
|
+
<div className="flex-1 min-w-0">
|
|
39
|
+
<p className="text-sm font-medium text-[#0d0d0d] truncate">
|
|
40
|
+
{kebabToTitle(skill.name)}
|
|
41
|
+
</p>
|
|
42
|
+
<p className="text-xs text-[#9b9b9b] truncate mt-0.5">
|
|
43
|
+
{skill.source} · {formatInstalls(skill.installs)} installs
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
<button
|
|
47
|
+
onClick={onInstall}
|
|
48
|
+
disabled={installing || installed}
|
|
49
|
+
className={cn(
|
|
50
|
+
"shrink-0 size-7 flex items-center justify-center rounded-lg transition-colors",
|
|
51
|
+
installed
|
|
52
|
+
? "text-[#9b9b9b] cursor-default"
|
|
53
|
+
: "text-[#9b9b9b] hover:bg-black/[0.06] hover:text-[#0d0d0d]",
|
|
54
|
+
installing && "opacity-50 cursor-wait",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{installing ? (
|
|
58
|
+
<Loader2 className="size-4 animate-spin" />
|
|
59
|
+
) : installed ? (
|
|
60
|
+
<Check className="size-4" />
|
|
61
|
+
) : (
|
|
62
|
+
<Plus className="size-4" />
|
|
63
|
+
)}
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommunitySkillsSection — Search and install skills from a community marketplace.
|
|
3
|
+
* Fully props-driven: host app provides search + install callbacks.
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useEffect, useState } from "react"
|
|
6
|
+
import type { CommunitySkill } from "./types"
|
|
7
|
+
import { CommunitySkillRow } from "./community-skill-row"
|
|
8
|
+
import { Search } from "lucide-react"
|
|
9
|
+
|
|
10
|
+
const PAGE_SIZE = 20
|
|
11
|
+
|
|
12
|
+
export interface CommunitySkillsSectionProps {
|
|
13
|
+
/** Called when the user types a search query (debounced internally at 350ms). */
|
|
14
|
+
onSearch: (query: string) => Promise<CommunitySkill[]>
|
|
15
|
+
/** Called when the user clicks install on a community skill. Should return the installed skill name. */
|
|
16
|
+
onInstall: (skill: CommunitySkill) => Promise<string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CommunitySkillsSection({
|
|
20
|
+
onSearch,
|
|
21
|
+
onInstall,
|
|
22
|
+
}: CommunitySkillsSectionProps) {
|
|
23
|
+
const [query, setQuery] = useState("")
|
|
24
|
+
const [results, setResults] = useState<CommunitySkill[]>([])
|
|
25
|
+
const [loading, setLoading] = useState(false)
|
|
26
|
+
const [showAll, setShowAll] = useState(false)
|
|
27
|
+
const [installingIds, setInstallingIds] = useState<Set<string>>(new Set())
|
|
28
|
+
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set())
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const q = query.trim()
|
|
32
|
+
if (!q) {
|
|
33
|
+
setResults([])
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
doSearch(q)
|
|
38
|
+
setShowAll(false)
|
|
39
|
+
}, 350)
|
|
40
|
+
return () => clearTimeout(timer)
|
|
41
|
+
}, [query])
|
|
42
|
+
|
|
43
|
+
const doSearch = async (q: string) => {
|
|
44
|
+
setLoading(true)
|
|
45
|
+
try {
|
|
46
|
+
const skills = await onSearch(q)
|
|
47
|
+
setResults(skills)
|
|
48
|
+
} catch {
|
|
49
|
+
setResults([])
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const handleInstall = useCallback(
|
|
56
|
+
async (skill: CommunitySkill) => {
|
|
57
|
+
setInstallingIds((prev) => new Set(prev).add(skill.id))
|
|
58
|
+
try {
|
|
59
|
+
await onInstall(skill)
|
|
60
|
+
setInstalledIds((prev) => new Set(prev).add(skill.id))
|
|
61
|
+
} finally {
|
|
62
|
+
setInstallingIds((prev) => {
|
|
63
|
+
const next = new Set(prev)
|
|
64
|
+
next.delete(skill.id)
|
|
65
|
+
return next
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[onInstall],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const visible = showAll ? results : results.slice(0, PAGE_SIZE)
|
|
73
|
+
const hasMore = results.length > PAGE_SIZE && !showAll
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<section className="space-y-3">
|
|
77
|
+
<div>
|
|
78
|
+
<h2 className="text-sm font-medium text-[#0d0d0d]">
|
|
79
|
+
Discover skills from the community
|
|
80
|
+
</h2>
|
|
81
|
+
<p className="text-xs text-[#9b9b9b] mt-0.5">
|
|
82
|
+
Browse thousands of ready-made procedures on skills.sh
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Search bar */}
|
|
87
|
+
<div className="relative">
|
|
88
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#9b9b9b]" />
|
|
89
|
+
<input
|
|
90
|
+
type="text"
|
|
91
|
+
value={query}
|
|
92
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
93
|
+
placeholder={'Search by what you want to achieve, like "sdr" or "writing"'}
|
|
94
|
+
className="w-full h-9 pl-9 pr-3 rounded-full border border-black/[0.10] bg-white text-sm
|
|
95
|
+
placeholder:text-black/40 focus:outline-none focus:border-black/[0.25] transition-colors"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Results grid */}
|
|
100
|
+
{loading && results.length === 0 && (
|
|
101
|
+
<p className="text-sm text-[#9b9b9b] animate-pulse">Loading...</p>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{!loading && results.length === 0 && query.trim() && (
|
|
105
|
+
<p className="text-sm text-[#9b9b9b]">
|
|
106
|
+
No skills found for "{query.trim()}"
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{visible.length > 0 && (
|
|
111
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
|
112
|
+
{visible.map((skill) => (
|
|
113
|
+
<CommunitySkillRow
|
|
114
|
+
key={skill.id}
|
|
115
|
+
skill={skill}
|
|
116
|
+
installing={installingIds.has(skill.id)}
|
|
117
|
+
installed={installedIds.has(skill.id)}
|
|
118
|
+
onInstall={() => handleInstall(skill)}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{hasMore && (
|
|
125
|
+
<button
|
|
126
|
+
onClick={() => setShowAll(true)}
|
|
127
|
+
className="text-sm text-[#5d5d5d] hover:text-[#0d0d0d] transition-colors"
|
|
128
|
+
>
|
|
129
|
+
Show {results.length - PAGE_SIZE} more
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</section>
|
|
133
|
+
)
|
|
134
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type {
|
|
3
|
+
Skill,
|
|
4
|
+
CommunitySkill,
|
|
5
|
+
LearningCategory,
|
|
6
|
+
SkillLearning,
|
|
7
|
+
} from "./types"
|
|
8
|
+
export { CATEGORY_LABELS } from "./types"
|
|
9
|
+
|
|
10
|
+
// Components
|
|
11
|
+
export { SkillsGrid } from "./skills-grid"
|
|
12
|
+
export type { SkillsGridProps } from "./skills-grid"
|
|
13
|
+
export { SkillRow } from "./skill-row"
|
|
14
|
+
export type { SkillRowProps } from "./skill-row"
|
|
15
|
+
export { SkillDetailPage } from "./skill-detail-page"
|
|
16
|
+
export type { SkillDetailPageProps } from "./skill-detail-page"
|
|
17
|
+
export { CommunitySkillsSection } from "./community-skills-browser"
|
|
18
|
+
export type { CommunitySkillsSectionProps } from "./community-skills-browser"
|
|
19
|
+
export { CommunitySkillRow } from "./community-skill-row"
|
|
20
|
+
export type { CommunitySkillRowProps } from "./community-skill-row"
|
|
21
|
+
export { LearningRow } from "./learning-row"
|
|
22
|
+
export type { LearningRowProps } from "./learning-row"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CATEGORY_LABELS } from "./types"
|
|
2
|
+
import type { LearningCategory } from "./types"
|
|
3
|
+
import { Trash2 } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
export interface LearningRowProps {
|
|
6
|
+
content: string
|
|
7
|
+
category: LearningCategory
|
|
8
|
+
sourceTitle: string | null
|
|
9
|
+
createdAt: string
|
|
10
|
+
onDelete: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function LearningRow({
|
|
14
|
+
content,
|
|
15
|
+
category,
|
|
16
|
+
sourceTitle,
|
|
17
|
+
createdAt,
|
|
18
|
+
onDelete,
|
|
19
|
+
}: LearningRowProps) {
|
|
20
|
+
const label = CATEGORY_LABELS[category] ?? category
|
|
21
|
+
const date = new Date(createdAt).toLocaleDateString("en-US", {
|
|
22
|
+
month: "short",
|
|
23
|
+
day: "numeric",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="rounded-xl border border-black/[0.06] p-4 group">
|
|
28
|
+
<div className="flex items-start gap-3">
|
|
29
|
+
<p className="text-sm text-[#0d0d0d] flex-1 leading-relaxed">
|
|
30
|
+
{content}
|
|
31
|
+
</p>
|
|
32
|
+
<button
|
|
33
|
+
onClick={onDelete}
|
|
34
|
+
className="shrink-0 size-7 flex items-center justify-center rounded-lg text-[#9b9b9b] hover:text-red-500 hover:bg-red-50 transition-colors"
|
|
35
|
+
aria-label="Delete learning"
|
|
36
|
+
>
|
|
37
|
+
<Trash2 className="size-3.5" />
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="flex items-center gap-2 mt-2 text-xs text-[#9b9b9b]">
|
|
41
|
+
<span className="px-1.5 py-0.5 rounded-md bg-black/[0.04] text-[#5d5d5d]">
|
|
42
|
+
{label}
|
|
43
|
+
</span>
|
|
44
|
+
{sourceTitle && (
|
|
45
|
+
<span className="truncate max-w-[200px]">{sourceTitle}</span>
|
|
46
|
+
)}
|
|
47
|
+
<span>{date}</span>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillDetailPage — View and edit a skill's instructions + learnings.
|
|
3
|
+
* Fully props-driven: host app provides the skill data and save callback.
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useEffect, useState } from "react"
|
|
6
|
+
import type { Skill } from "./types"
|
|
7
|
+
import { ArrowLeft, FileText } from "lucide-react"
|
|
8
|
+
|
|
9
|
+
export interface SkillDetailPageProps {
|
|
10
|
+
skill: Skill | undefined
|
|
11
|
+
onBack: () => void
|
|
12
|
+
onSave: (skillName: string, instructions: string) => Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SkillDetailPage({
|
|
16
|
+
skill,
|
|
17
|
+
onBack,
|
|
18
|
+
onSave,
|
|
19
|
+
}: SkillDetailPageProps) {
|
|
20
|
+
const [instructions, setInstructions] = useState("")
|
|
21
|
+
const [saving, setSaving] = useState(false)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (skill) setInstructions(skill.instructions)
|
|
25
|
+
}, [skill?.id])
|
|
26
|
+
|
|
27
|
+
const handleSave = useCallback(async () => {
|
|
28
|
+
if (!skill) return
|
|
29
|
+
setSaving(true)
|
|
30
|
+
try {
|
|
31
|
+
await onSave(skill.name, instructions)
|
|
32
|
+
} finally {
|
|
33
|
+
setSaving(false)
|
|
34
|
+
}
|
|
35
|
+
}, [skill, instructions, onSave])
|
|
36
|
+
|
|
37
|
+
if (!skill) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex-1 flex items-center justify-center">
|
|
40
|
+
<p className="text-sm text-[#9b9b9b]">Skill not found</p>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isDirty = instructions !== skill.instructions
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
49
|
+
{/* Header */}
|
|
50
|
+
<div className="shrink-0 px-6 py-3 border-b border-black/[0.06]">
|
|
51
|
+
<div className="max-w-2xl mx-auto flex items-center gap-3">
|
|
52
|
+
<button
|
|
53
|
+
onClick={onBack}
|
|
54
|
+
className="size-8 flex items-center justify-center rounded-lg text-[#9b9b9b] hover:text-[#0d0d0d] hover:bg-black/[0.05] transition-colors"
|
|
55
|
+
>
|
|
56
|
+
<ArrowLeft className="size-4" />
|
|
57
|
+
</button>
|
|
58
|
+
<div className="flex-1 min-w-0">
|
|
59
|
+
<h1 className="text-sm font-medium text-[#0d0d0d] truncate">
|
|
60
|
+
{skill.name}
|
|
61
|
+
</h1>
|
|
62
|
+
<p className="text-xs text-[#9b9b9b] flex items-center gap-1 truncate">
|
|
63
|
+
<FileText className="size-3" />
|
|
64
|
+
{skill.file_path}
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Body */}
|
|
71
|
+
<div className="flex-1 overflow-y-auto px-6 py-6">
|
|
72
|
+
<div className="max-w-2xl mx-auto space-y-8">
|
|
73
|
+
{/* Instructions */}
|
|
74
|
+
<section>
|
|
75
|
+
<label className="block text-xs font-medium text-[#9b9b9b] tracking-wider mb-2">
|
|
76
|
+
Instructions
|
|
77
|
+
</label>
|
|
78
|
+
<textarea
|
|
79
|
+
value={instructions}
|
|
80
|
+
onChange={(e) => setInstructions(e.target.value)}
|
|
81
|
+
rows={12}
|
|
82
|
+
className="w-full rounded-xl border border-black/[0.08] bg-white px-4 py-3 text-sm text-[#0d0d0d] placeholder:text-[#9b9b9b] focus:outline-none focus:border-black/[0.2] resize-y font-mono"
|
|
83
|
+
placeholder="Instructions for this skill..."
|
|
84
|
+
/>
|
|
85
|
+
<div className="flex items-center gap-3 mt-3">
|
|
86
|
+
<button
|
|
87
|
+
onClick={handleSave}
|
|
88
|
+
disabled={!isDirty || saving}
|
|
89
|
+
className="px-4 py-2 rounded-full text-sm font-medium bg-[#0d0d0d] text-white hover:bg-[#2d2d2d] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
90
|
+
>
|
|
91
|
+
{saving ? "Saving..." : "Save"}
|
|
92
|
+
</button>
|
|
93
|
+
{isDirty && (
|
|
94
|
+
<span className="text-xs text-[#9b9b9b]">Unsaved changes</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
{/* Learnings */}
|
|
100
|
+
{skill.learnings.trim() && (
|
|
101
|
+
<section>
|
|
102
|
+
<label className="block text-xs font-medium text-[#9b9b9b] tracking-wider mb-2">
|
|
103
|
+
Learnings
|
|
104
|
+
</label>
|
|
105
|
+
<div className="rounded-xl border border-black/[0.08] bg-[#fafafa] px-4 py-3 text-sm text-[#5d5d5d] whitespace-pre-wrap font-mono">
|
|
106
|
+
{skill.learnings}
|
|
107
|
+
</div>
|
|
108
|
+
<p className="text-xs text-[#9b9b9b] mt-2">
|
|
109
|
+
Learnings are added automatically when you give feedback on completed tasks.
|
|
110
|
+
</p>
|
|
111
|
+
</section>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Skill } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface SkillRowProps {
|
|
4
|
+
skill: Skill
|
|
5
|
+
onClick: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function SkillRow({ skill, onClick }: SkillRowProps) {
|
|
9
|
+
return (
|
|
10
|
+
<button
|
|
11
|
+
onClick={onClick}
|
|
12
|
+
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-black/[0.08]
|
|
13
|
+
bg-white hover:border-black/[0.15] transition-all text-left"
|
|
14
|
+
>
|
|
15
|
+
<div className="flex-1 min-w-0">
|
|
16
|
+
<p className="text-sm font-medium text-[#0d0d0d] truncate">
|
|
17
|
+
{skill.name}
|
|
18
|
+
</p>
|
|
19
|
+
{skill.description && (
|
|
20
|
+
<p className="text-xs text-[#9b9b9b] truncate mt-0.5">
|
|
21
|
+
{skill.description}
|
|
22
|
+
</p>
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
</button>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillsGrid — Installed skills list + optional community skills section.
|
|
3
|
+
* Fully props-driven: host app provides data and callbacks.
|
|
4
|
+
*/
|
|
5
|
+
import { useMemo } from "react"
|
|
6
|
+
import type { Skill, CommunitySkill } from "./types"
|
|
7
|
+
import { SkillRow } from "./skill-row"
|
|
8
|
+
import { CommunitySkillsSection } from "./community-skills-browser"
|
|
9
|
+
|
|
10
|
+
export interface SkillsGridProps {
|
|
11
|
+
skills: Skill[]
|
|
12
|
+
loading: boolean
|
|
13
|
+
onSkillClick: (skill: Skill) => void
|
|
14
|
+
/** Community marketplace callbacks. Omit to hide the community section. */
|
|
15
|
+
community?: {
|
|
16
|
+
onSearch: (query: string) => Promise<CommunitySkill[]>
|
|
17
|
+
onInstall: (skill: CommunitySkill) => Promise<string>
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SkillsGrid({
|
|
22
|
+
skills,
|
|
23
|
+
loading,
|
|
24
|
+
onSkillClick,
|
|
25
|
+
community,
|
|
26
|
+
}: SkillsGridProps) {
|
|
27
|
+
const sorted = useMemo(() => {
|
|
28
|
+
return [...skills].sort((a, b) => a.name.localeCompare(b.name))
|
|
29
|
+
}, [skills])
|
|
30
|
+
|
|
31
|
+
if (loading && skills.length === 0) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex-1 flex items-center justify-center">
|
|
34
|
+
<p className="text-sm text-[#9b9b9b] animate-pulse">
|
|
35
|
+
Loading skills...
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex-1 overflow-y-auto">
|
|
43
|
+
<div className="max-w-3xl mx-auto px-6 py-6 space-y-8">
|
|
44
|
+
{/* Installed section */}
|
|
45
|
+
{sorted.length > 0 && (
|
|
46
|
+
<section className="space-y-3">
|
|
47
|
+
<h2 className="text-sm font-medium text-[#0d0d0d] normal-case">Installed</h2>
|
|
48
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
|
49
|
+
{sorted.map((skill) => (
|
|
50
|
+
<SkillRow
|
|
51
|
+
key={skill.id}
|
|
52
|
+
skill={skill}
|
|
53
|
+
onClick={() => onSkillClick(skill)}
|
|
54
|
+
/>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
{/* Community marketplace */}
|
|
61
|
+
{community && (
|
|
62
|
+
<CommunitySkillsSection
|
|
63
|
+
onSearch={community.onSearch}
|
|
64
|
+
onInstall={community.onInstall}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
package/src/styles.css
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface Skill {
|
|
2
|
+
id: string
|
|
3
|
+
name: string
|
|
4
|
+
description: string
|
|
5
|
+
instructions: string
|
|
6
|
+
learnings: string
|
|
7
|
+
file_path: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CommunitySkill {
|
|
11
|
+
id: string
|
|
12
|
+
skillId: string
|
|
13
|
+
name: string
|
|
14
|
+
installs: number
|
|
15
|
+
source: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type LearningCategory =
|
|
19
|
+
| "pattern"
|
|
20
|
+
| "pitfall"
|
|
21
|
+
| "preference"
|
|
22
|
+
| "procedure"
|
|
23
|
+
|
|
24
|
+
export const CATEGORY_LABELS: Record<LearningCategory, string> = {
|
|
25
|
+
pattern: "Pattern",
|
|
26
|
+
pitfall: "Pitfall",
|
|
27
|
+
preference: "Preference",
|
|
28
|
+
procedure: "Procedure",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SkillLearning {
|
|
32
|
+
id: string
|
|
33
|
+
skill_id: string
|
|
34
|
+
project_id: string
|
|
35
|
+
content: string
|
|
36
|
+
rationale: string
|
|
37
|
+
category: LearningCategory
|
|
38
|
+
source_issue_id: string | null
|
|
39
|
+
source_issue_title: string | null
|
|
40
|
+
created_at: string
|
|
41
|
+
}
|