mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
585 lines
22 KiB
TypeScript
585 lines
22 KiB
TypeScript
import { useNavigate } from '@tanstack/react-router'
|
|
import type { ClawdisSkillMetadata, SkillInstallSpec } from 'clawdhub-schema'
|
|
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import { api } from '../../convex/_generated/api'
|
|
import type { Doc, Id } from '../../convex/_generated/dataModel'
|
|
import { SkillDiffCard } from './SkillDiffCard'
|
|
|
|
type SkillDetailPageProps = {
|
|
slug: string
|
|
canonicalOwner?: string
|
|
redirectToCanonical?: boolean
|
|
}
|
|
|
|
export function SkillDetailPage({
|
|
slug,
|
|
canonicalOwner,
|
|
redirectToCanonical,
|
|
}: SkillDetailPageProps) {
|
|
const navigate = useNavigate()
|
|
const { isAuthenticated } = useConvexAuth()
|
|
const me = useQuery(api.users.me)
|
|
const result = useQuery(api.skills.getBySlug, { slug })
|
|
const toggleStar = useMutation(api.stars.toggle)
|
|
const addComment = useMutation(api.comments.add)
|
|
const removeComment = useMutation(api.comments.remove)
|
|
const updateTags = useMutation(api.skills.updateTags)
|
|
const setBatch = useMutation(api.skills.setBatch)
|
|
const getReadme = useAction(api.skills.getReadme)
|
|
const [readme, setReadme] = useState<string | null>(null)
|
|
const [comment, setComment] = useState('')
|
|
const [tagName, setTagName] = useState('latest')
|
|
const [tagVersionId, setTagVersionId] = useState<Id<'skillVersions'> | ''>('')
|
|
const [activeTab, setActiveTab] = useState<'files' | 'compare' | 'versions'>('files')
|
|
|
|
const isLoadingSkill = result === undefined
|
|
const skill = result?.skill
|
|
const owner = result?.owner
|
|
const latestVersion = result?.latestVersion
|
|
const versions = useQuery(
|
|
api.skills.listVersions,
|
|
skill ? { skillId: skill._id, limit: 50 } : 'skip',
|
|
) as Doc<'skillVersions'>[] | undefined
|
|
const diffVersions = useQuery(
|
|
api.skills.listVersions,
|
|
skill ? { skillId: skill._id, limit: 200 } : 'skip',
|
|
) as Doc<'skillVersions'>[] | undefined
|
|
|
|
const isStarred = useQuery(
|
|
api.stars.isStarred,
|
|
isAuthenticated && skill ? { skillId: skill._id } : 'skip',
|
|
)
|
|
const comments = useQuery(
|
|
api.comments.listBySkill,
|
|
skill ? { skillId: skill._id, limit: 50 } : 'skip',
|
|
) as Array<{ comment: Doc<'comments'>; user: Doc<'users'> | null }> | undefined
|
|
|
|
const canManage = Boolean(
|
|
me && skill && (me._id === skill.ownerUserId || ['admin', 'moderator'].includes(me.role ?? '')),
|
|
)
|
|
const canHighlight = Boolean(me && ['admin', 'moderator'].includes(me.role ?? ''))
|
|
|
|
const ownerHandle = owner?.handle ?? owner?.name ?? null
|
|
const wantsCanonicalRedirect = Boolean(
|
|
ownerHandle &&
|
|
(redirectToCanonical ||
|
|
(typeof canonicalOwner === 'string' && canonicalOwner && canonicalOwner !== ownerHandle)),
|
|
)
|
|
|
|
const forkOf = result?.forkOf ?? null
|
|
const canonical = result?.canonical ?? null
|
|
const forkOfLabel = forkOf?.kind === 'duplicate' ? 'duplicate of' : 'fork of'
|
|
const forkOfOwnerHandle = forkOf?.owner?.handle ?? null
|
|
const canonicalOwnerHandle = canonical?.owner?.handle ?? null
|
|
const forkOfHref = forkOf?.skill?.slug
|
|
? buildSkillHref(forkOfOwnerHandle, forkOf.skill.slug)
|
|
: null
|
|
const canonicalHref =
|
|
canonical?.skill?.slug && canonical.skill.slug !== forkOf?.skill?.slug
|
|
? buildSkillHref(canonicalOwnerHandle, canonical.skill.slug)
|
|
: null
|
|
|
|
useEffect(() => {
|
|
if (!wantsCanonicalRedirect || !ownerHandle) return
|
|
void navigate({
|
|
to: '/$owner/$slug',
|
|
params: { owner: ownerHandle, slug },
|
|
replace: true,
|
|
})
|
|
}, [navigate, ownerHandle, slug, wantsCanonicalRedirect])
|
|
|
|
const versionById = new Map<Id<'skillVersions'>, Doc<'skillVersions'>>(
|
|
(diffVersions ?? versions ?? []).map((version) => [version._id, version]),
|
|
)
|
|
const clawdis = (latestVersion?.parsed as { clawdis?: ClawdisSkillMetadata } | undefined)?.clawdis
|
|
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os])
|
|
const requirements = clawdis?.requires
|
|
const installSpecs = clawdis?.install ?? []
|
|
const readmeContent = useMemo(() => {
|
|
if (!readme) return null
|
|
return stripFrontmatter(readme)
|
|
}, [readme])
|
|
const latestFiles = latestVersion?.files ?? []
|
|
|
|
useEffect(() => {
|
|
if (!latestVersion) return
|
|
setReadme(null)
|
|
let cancelled = false
|
|
void getReadme({ versionId: latestVersion._id }).then((data) => {
|
|
if (cancelled) return
|
|
setReadme(data.text)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [latestVersion, getReadme])
|
|
|
|
useEffect(() => {
|
|
if (!tagVersionId && latestVersion) {
|
|
setTagVersionId(latestVersion._id)
|
|
}
|
|
}, [latestVersion, tagVersionId])
|
|
|
|
if (isLoadingSkill || wantsCanonicalRedirect) {
|
|
return (
|
|
<main className="section">
|
|
<div className="card">
|
|
<div className="loading-indicator">Loading skill…</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (result === null || !skill) {
|
|
return (
|
|
<main className="section">
|
|
<div className="card">Skill not found.</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<'skillVersions'>]>
|
|
|
|
return (
|
|
<main className="section">
|
|
<div className="skill-detail-stack">
|
|
<div className="card skill-hero">
|
|
<div className="skill-hero-header">
|
|
<div className="skill-hero-title">
|
|
<h1 className="section-title" style={{ margin: 0 }}>
|
|
{skill.displayName}
|
|
</h1>
|
|
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
|
|
<div className="stat">
|
|
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
|
{skill.stats.installsCurrent ?? 0} current · {skill.stats.installsAllTime ?? 0}{' '}
|
|
all-time
|
|
</div>
|
|
{owner?.handle ? (
|
|
<div className="stat">
|
|
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
|
|
</div>
|
|
) : null}
|
|
{forkOf && forkOfHref ? (
|
|
<div className="stat">
|
|
{forkOfLabel}{' '}
|
|
<a href={forkOfHref}>
|
|
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ''}
|
|
{forkOf.skill.slug}
|
|
</a>
|
|
{forkOf.version ? ` (based on ${forkOf.version})` : null}
|
|
</div>
|
|
) : null}
|
|
{canonicalHref ? (
|
|
<div className="stat">
|
|
canonical:{' '}
|
|
<a href={canonicalHref}>
|
|
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ''}
|
|
{canonical?.skill?.slug}
|
|
</a>
|
|
</div>
|
|
) : null}
|
|
{skill.batch === 'highlighted' ? <div className="tag">Highlighted</div> : null}
|
|
<div className="skill-actions">
|
|
{isAuthenticated ? (
|
|
<button
|
|
className={`star-toggle${isStarred ? ' is-active' : ''}`}
|
|
type="button"
|
|
onClick={() => void toggleStar({ skillId: skill._id })}
|
|
aria-label={isStarred ? 'Unstar skill' : 'Star skill'}
|
|
>
|
|
<span aria-hidden="true">★</span>
|
|
</button>
|
|
) : null}
|
|
{canHighlight ? (
|
|
<button
|
|
className={`highlight-toggle${skill.batch === 'highlighted' ? ' is-active' : ''}`}
|
|
type="button"
|
|
onClick={() =>
|
|
void setBatch({
|
|
skillId: skill._id,
|
|
batch: skill.batch === 'highlighted' ? undefined : 'highlighted',
|
|
})
|
|
}
|
|
aria-label={
|
|
skill.batch === 'highlighted' ? 'Unhighlight skill' : 'Highlight skill'
|
|
}
|
|
>
|
|
<span aria-hidden="true">✦</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="skill-hero-cta">
|
|
<div className="skill-version-pill">
|
|
<span className="skill-version-label">Current version</span>
|
|
<strong>v{latestVersion?.version ?? '—'}</strong>
|
|
</div>
|
|
<a
|
|
className="btn btn-primary"
|
|
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/download?slug=${skill.slug}`}
|
|
>
|
|
Download zip
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div className="skill-tag-row">
|
|
{tagEntries.length === 0 ? (
|
|
<span className="section-subtitle" style={{ margin: 0 }}>
|
|
No tags yet.
|
|
</span>
|
|
) : (
|
|
tagEntries.map(([tag, versionId]) => (
|
|
<span key={tag} className="tag">
|
|
{tag}
|
|
<span className="tag-meta">
|
|
v{versionById.get(versionId)?.version ?? versionId}
|
|
</span>
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
{canManage ? (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (!tagName.trim() || !tagVersionId) return
|
|
void updateTags({
|
|
skillId: skill._id,
|
|
tags: [{ tag: tagName.trim(), versionId: tagVersionId }],
|
|
})
|
|
}}
|
|
className="tag-form"
|
|
>
|
|
<input
|
|
className="search-input"
|
|
value={tagName}
|
|
onChange={(event) => setTagName(event.target.value)}
|
|
placeholder="latest"
|
|
/>
|
|
<select
|
|
className="search-input"
|
|
value={tagVersionId ?? ''}
|
|
onChange={(event) => setTagVersionId(event.target.value as Id<'skillVersions'>)}
|
|
>
|
|
{(diffVersions ?? []).map((version) => (
|
|
<option key={version._id} value={version._id}>
|
|
v{version.version}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button className="btn" type="submit">
|
|
Update tag
|
|
</button>
|
|
</form>
|
|
) : null}
|
|
{clawdis || installSpecs.length ? (
|
|
<div className="skill-hero-panels">
|
|
{clawdis ? (
|
|
<div className="skill-panel">
|
|
<h3 className="section-title" style={{ fontSize: '1rem', margin: 0 }}>
|
|
Requirements
|
|
</h3>
|
|
<div className="skill-panel-body">
|
|
{clawdis.emoji ? <div className="tag">{clawdis.emoji} Clawdis</div> : null}
|
|
{osLabels.length ? (
|
|
<div className="stat">
|
|
<strong>OS</strong>
|
|
<span>{osLabels.join(' · ')}</span>
|
|
</div>
|
|
) : null}
|
|
{requirements?.bins?.length ? (
|
|
<div className="stat">
|
|
<strong>Bins</strong>
|
|
<span>{requirements.bins.join(', ')}</span>
|
|
</div>
|
|
) : null}
|
|
{requirements?.anyBins?.length ? (
|
|
<div className="stat">
|
|
<strong>Any bin</strong>
|
|
<span>{requirements.anyBins.join(', ')}</span>
|
|
</div>
|
|
) : null}
|
|
{requirements?.env?.length ? (
|
|
<div className="stat">
|
|
<strong>Env</strong>
|
|
<span>{requirements.env.join(', ')}</span>
|
|
</div>
|
|
) : null}
|
|
{requirements?.config?.length ? (
|
|
<div className="stat">
|
|
<strong>Config</strong>
|
|
<span>{requirements.config.join(', ')}</span>
|
|
</div>
|
|
) : null}
|
|
{clawdis.primaryEnv ? (
|
|
<div className="stat">
|
|
<strong>Primary env</strong>
|
|
<span>{clawdis.primaryEnv}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{installSpecs.length ? (
|
|
<div className="skill-panel">
|
|
<h3 className="section-title" style={{ fontSize: '1rem', margin: 0 }}>
|
|
Install
|
|
</h3>
|
|
<div className="skill-panel-body">
|
|
{installSpecs.map((spec, index) => {
|
|
const command = formatInstallCommand(spec)
|
|
return (
|
|
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
|
|
<div>
|
|
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
|
|
{spec.bins?.length ? (
|
|
<div style={{ color: 'var(--ink-soft)', fontSize: '0.85rem' }}>
|
|
Bins: {spec.bins.join(', ')}
|
|
</div>
|
|
) : null}
|
|
{command ? <code>{command}</code> : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="card tab-card">
|
|
<div className="tab-header">
|
|
<button
|
|
className={`tab-button${activeTab === 'files' ? ' is-active' : ''}`}
|
|
type="button"
|
|
onClick={() => setActiveTab('files')}
|
|
>
|
|
Files
|
|
</button>
|
|
<button
|
|
className={`tab-button${activeTab === 'compare' ? ' is-active' : ''}`}
|
|
type="button"
|
|
onClick={() => setActiveTab('compare')}
|
|
>
|
|
Compare
|
|
</button>
|
|
<button
|
|
className={`tab-button${activeTab === 'versions' ? ' is-active' : ''}`}
|
|
type="button"
|
|
onClick={() => setActiveTab('versions')}
|
|
>
|
|
Versions
|
|
</button>
|
|
</div>
|
|
{activeTab === 'files' ? (
|
|
<div className="tab-body">
|
|
<div>
|
|
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
SKILL.md
|
|
</h2>
|
|
<div className="markdown">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{readmeContent ?? 'Loading…'}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
<div className="file-list">
|
|
<div className="file-list-header">
|
|
<h3 className="section-title" style={{ fontSize: '1.05rem', margin: 0 }}>
|
|
Files
|
|
</h3>
|
|
<span className="section-subtitle" style={{ margin: 0 }}>
|
|
{latestFiles.length} total
|
|
</span>
|
|
</div>
|
|
<div className="file-list-body">
|
|
{latestFiles.length === 0 ? (
|
|
<div className="stat">No files available.</div>
|
|
) : (
|
|
latestFiles.map((file) => (
|
|
<div key={file.path} className="file-row">
|
|
<span className="file-path">{file.path}</span>
|
|
<span className="file-meta">{formatBytes(file.size)}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{activeTab === 'compare' && skill ? (
|
|
<div className="tab-body">
|
|
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
|
|
</div>
|
|
) : null}
|
|
{activeTab === 'versions' ? (
|
|
<div className="tab-body">
|
|
<div>
|
|
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
Versions
|
|
</h2>
|
|
<p className="section-subtitle" style={{ margin: 0 }}>
|
|
Download older releases or scan the changelog.
|
|
</p>
|
|
</div>
|
|
<div className="version-scroll">
|
|
<div className="version-list">
|
|
{(versions ?? []).map((version) => (
|
|
<div key={version._id} className="version-row">
|
|
<div className="version-info">
|
|
<div>
|
|
v{version.version} · {new Date(version.createdAt).toLocaleDateString()}
|
|
{version.changelogSource === 'auto' ? (
|
|
<span style={{ color: 'var(--ink-soft)' }}> · auto</span>
|
|
) : null}
|
|
</div>
|
|
<div style={{ color: '#5c554e', whiteSpace: 'pre-wrap' }}>
|
|
{version.changelog}
|
|
</div>
|
|
</div>
|
|
<div className="version-actions">
|
|
<a
|
|
className="btn version-zip"
|
|
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/download?slug=${skill.slug}&version=${version.version}`}
|
|
>
|
|
Zip
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="card">
|
|
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
Comments
|
|
</h2>
|
|
{isAuthenticated ? (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (!comment.trim()) return
|
|
void addComment({ skillId: skill._id, body: comment.trim() }).then(() =>
|
|
setComment(''),
|
|
)
|
|
}}
|
|
className="comment-form"
|
|
>
|
|
<textarea
|
|
className="comment-input"
|
|
rows={4}
|
|
value={comment}
|
|
onChange={(event) => setComment(event.target.value)}
|
|
placeholder="Leave a note…"
|
|
/>
|
|
<button className="btn comment-submit" type="submit">
|
|
Post comment
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<p className="section-subtitle">Sign in to comment.</p>
|
|
)}
|
|
<div style={{ display: 'grid', gap: 12, marginTop: 16 }}>
|
|
{(comments ?? []).length === 0 ? (
|
|
<div className="stat">No comments yet.</div>
|
|
) : (
|
|
(comments ?? []).map((entry) => (
|
|
<div key={entry.comment._id} className="stat" style={{ alignItems: 'flex-start' }}>
|
|
<div>
|
|
<strong>@{entry.user?.handle ?? entry.user?.name ?? 'user'}</strong>
|
|
<div style={{ color: '#5c554e' }}>{entry.comment.body}</div>
|
|
</div>
|
|
{isAuthenticated &&
|
|
me &&
|
|
(me._id === entry.comment.userId ||
|
|
me.role === 'admin' ||
|
|
me.role === 'moderator') ? (
|
|
<button
|
|
className="btn"
|
|
type="button"
|
|
onClick={() => void removeComment({ commentId: entry.comment._id })}
|
|
>
|
|
Delete
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function buildSkillHref(ownerHandle: string | null, slug: string) {
|
|
if (ownerHandle) return `/${ownerHandle}/${slug}`
|
|
return `/skills/${slug}`
|
|
}
|
|
|
|
function stripFrontmatter(content: string) {
|
|
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
if (!normalized.startsWith('---')) return content
|
|
const endIndex = normalized.indexOf('\n---', 3)
|
|
if (endIndex === -1) return content
|
|
return normalized.slice(endIndex + 4).replace(/^\n+/, '')
|
|
}
|
|
|
|
function formatOsList(os?: string[]) {
|
|
if (!os?.length) return []
|
|
return os.map((entry) => {
|
|
const key = entry.trim().toLowerCase()
|
|
if (key === 'darwin' || key === 'macos' || key === 'mac') return 'macOS'
|
|
if (key === 'linux') return 'Linux'
|
|
if (key === 'windows' || key === 'win32') return 'Windows'
|
|
return entry
|
|
})
|
|
}
|
|
|
|
function formatInstallLabel(spec: SkillInstallSpec) {
|
|
if (spec.kind === 'brew') return 'Homebrew'
|
|
if (spec.kind === 'node') return 'Node'
|
|
if (spec.kind === 'go') return 'Go'
|
|
if (spec.kind === 'uv') return 'uv'
|
|
return 'Install'
|
|
}
|
|
|
|
function formatInstallCommand(spec: SkillInstallSpec) {
|
|
if (spec.kind === 'brew' && spec.formula) {
|
|
if (spec.tap && !spec.formula.includes('/')) {
|
|
return `brew install ${spec.tap}/${spec.formula}`
|
|
}
|
|
return `brew install ${spec.formula}`
|
|
}
|
|
if (spec.kind === 'node' && spec.package) {
|
|
return `npm i -g ${spec.package}`
|
|
}
|
|
if (spec.kind === 'go' && spec.module) {
|
|
return `go install ${spec.module}`
|
|
}
|
|
if (spec.kind === 'uv' && spec.package) {
|
|
return `uv tool install ${spec.package}`
|
|
}
|
|
return null
|
|
}
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (!Number.isFinite(bytes)) return '—'
|
|
if (bytes < 1024) return `${bytes} B`
|
|
const units = ['KB', 'MB', 'GB']
|
|
let value = bytes / 1024
|
|
let unitIndex = 0
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024
|
|
unitIndex += 1
|
|
}
|
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
|
|
}
|