Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface BlueprintField {
|
||||
name: string;
|
||||
type: string;
|
||||
label: string;
|
||||
default: string | null;
|
||||
options: string[];
|
||||
optional: boolean;
|
||||
help: string;
|
||||
}
|
||||
|
||||
interface Blueprint {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
fields: BlueprintField[];
|
||||
scheduleHuman: string;
|
||||
command: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
const INDEX_URL = "/docs/api/automation-blueprints-index.json";
|
||||
|
||||
function CopyButton({ text }: { text: string }): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyBtn}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}}
|
||||
aria-label="Copy command"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BlueprintCard({ blueprint }: { blueprint: Blueprint }): JSX.Element {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.title}>{blueprint.title}</h3>
|
||||
<span className={styles.schedule}>{blueprint.scheduleHuman}</span>
|
||||
</div>
|
||||
<p className={styles.desc}>{blueprint.description}</p>
|
||||
|
||||
<div className={styles.tags}>
|
||||
{blueprint.tags.map((t) => (
|
||||
<span key={t} className={styles.tag}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.cmdRow}>
|
||||
<code className={styles.cmd}>{blueprint.command}</code>
|
||||
<CopyButton text={blueprint.command} />
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<a className={styles.appBtn} href={blueprint.appUrl}>
|
||||
Send to App ↗
|
||||
</a>
|
||||
<span className={styles.hint}>
|
||||
or paste the command into the CLI, TUI, or any messenger
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutomationBlueprintsCatalog(): JSX.Element {
|
||||
const [blueprints, setBlueprints] = useState<Blueprint[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(INDEX_URL)
|
||||
.then((r) => r.json())
|
||||
.then((data: Blueprint[]) => {
|
||||
if (!cancelled) setBlueprints(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <p>Couldn't load the blueprint catalog: {error}</p>;
|
||||
}
|
||||
if (blueprints === null) {
|
||||
return <p>Loading blueprints…</p>;
|
||||
}
|
||||
if (blueprints.length === 0) {
|
||||
return <p>No automation blueprints are available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{blueprints.map((r) => (
|
||||
<BlueprintCard key={r.key} blueprint={r} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
padding: 1.1rem 1.2rem;
|
||||
background: var(--ifm-card-background-color, var(--ifm-background-surface-color));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
}
|
||||
|
||||
.cmdRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cmd {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 6px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.copyBtn {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: transparent;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyBtn:hover {
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.appBtn {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
background: var(--ifm-color-primary);
|
||||
color: var(--ifm-color-primary-contrast-background, #fff);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.appBtn:hover {
|
||||
background: var(--ifm-color-primary-dark);
|
||||
text-decoration: none;
|
||||
color: var(--ifm-color-primary-contrast-background, #fff);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import stories from '@site/src/data/userStories.json';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
interface Story {
|
||||
id: string;
|
||||
source: string;
|
||||
author: string;
|
||||
url: string;
|
||||
date: string;
|
||||
category: string;
|
||||
headline: string;
|
||||
quote: string;
|
||||
size: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const allStories = stories as Story[];
|
||||
|
||||
// Category → pretty label + accent colors (solid + soft fill + gradient top-strip)
|
||||
const CATEGORIES: Record<
|
||||
string,
|
||||
{ label: string; solid: string; soft: string; strip: string }
|
||||
> = {
|
||||
'dev-workflow': {
|
||||
label: 'Dev Workflow',
|
||||
solid: '#60a5fa',
|
||||
soft: 'rgba(96, 165, 250, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #3b82f6, #60a5fa, #a78bfa)',
|
||||
},
|
||||
'personal-assistant': {
|
||||
label: 'Personal Assistant',
|
||||
solid: '#34d399',
|
||||
soft: 'rgba(52, 211, 153, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #10b981, #34d399, #a7f3d0)',
|
||||
},
|
||||
'content-creation': {
|
||||
label: 'Content Creation',
|
||||
solid: '#f472b6',
|
||||
soft: 'rgba(244, 114, 182, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #ec4899, #f472b6, #fda4af)',
|
||||
},
|
||||
'business-ops': {
|
||||
label: 'Business Ops',
|
||||
solid: '#fb923c',
|
||||
soft: 'rgba(251, 146, 60, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #f97316, #fb923c, #fcd34d)',
|
||||
},
|
||||
trading: {
|
||||
label: 'Trading & Markets',
|
||||
solid: '#facc15',
|
||||
soft: 'rgba(250, 204, 21, 0.16)',
|
||||
strip: 'linear-gradient(90deg, #eab308, #facc15, #fde047)',
|
||||
},
|
||||
research: {
|
||||
label: 'Research',
|
||||
solid: '#a78bfa',
|
||||
soft: 'rgba(167, 139, 250, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #8b5cf6, #a78bfa, #c4b5fd)',
|
||||
},
|
||||
creative: {
|
||||
label: 'Creative',
|
||||
solid: '#f87171',
|
||||
soft: 'rgba(248, 113, 113, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #ef4444, #f87171, #fca5a5)',
|
||||
},
|
||||
marketing: {
|
||||
label: 'Marketing',
|
||||
solid: '#e879f9',
|
||||
soft: 'rgba(232, 121, 249, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #d946ef, #e879f9, #f0abfc)',
|
||||
},
|
||||
integrations: {
|
||||
label: 'Integrations',
|
||||
solid: '#38bdf8',
|
||||
soft: 'rgba(56, 189, 248, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #0ea5e9, #38bdf8, #7dd3fc)',
|
||||
},
|
||||
enterprise: {
|
||||
label: 'Enterprise',
|
||||
solid: '#94a3b8',
|
||||
soft: 'rgba(148, 163, 184, 0.16)',
|
||||
strip: 'linear-gradient(90deg, #64748b, #94a3b8, #cbd5e1)',
|
||||
},
|
||||
messaging: {
|
||||
label: 'Messaging',
|
||||
solid: '#22d3ee',
|
||||
soft: 'rgba(34, 211, 238, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #06b6d4, #22d3ee, #67e8f9)',
|
||||
},
|
||||
privacy: {
|
||||
label: 'Privacy & Self-Hosted',
|
||||
solid: '#4ade80',
|
||||
soft: 'rgba(74, 222, 128, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #16a34a, #4ade80, #86efac)',
|
||||
},
|
||||
'cost-optimization': {
|
||||
label: 'Cost Optimization',
|
||||
solid: '#fbbf24',
|
||||
soft: 'rgba(251, 191, 36, 0.16)',
|
||||
strip: 'linear-gradient(90deg, #f59e0b, #fbbf24, #fde68a)',
|
||||
},
|
||||
meta: {
|
||||
label: 'Meta & Ecosystem',
|
||||
solid: '#c084fc',
|
||||
soft: 'rgba(192, 132, 252, 0.14)',
|
||||
strip: 'linear-gradient(90deg, #a855f7, #c084fc, #d8b4fe)',
|
||||
},
|
||||
general: {
|
||||
label: 'General',
|
||||
solid: '#9ca3af',
|
||||
soft: 'rgba(156, 163, 175, 0.16)',
|
||||
strip: 'linear-gradient(90deg, #6b7280, #9ca3af, #d1d5db)',
|
||||
},
|
||||
};
|
||||
|
||||
// Source → compact label shown in the badge row
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
x: 'X · Twitter',
|
||||
hn: 'Hacker News',
|
||||
reddit: 'Reddit',
|
||||
github: 'GitHub',
|
||||
youtube: 'YouTube',
|
||||
blog: 'Blog',
|
||||
podcast: 'Podcast',
|
||||
linkedin: 'LinkedIn',
|
||||
gist: 'GitHub Gist',
|
||||
producthunt: 'Product Hunt',
|
||||
discord: 'Discord',
|
||||
};
|
||||
|
||||
function sourceColor(source: string): string {
|
||||
switch (source) {
|
||||
case 'x': return '#1d9bf0';
|
||||
case 'hn': return '#ff6600';
|
||||
case 'reddit': return '#ff4500';
|
||||
case 'github': return '#8b949e';
|
||||
case 'youtube': return '#ff0033';
|
||||
case 'blog': return '#a78bfa';
|
||||
case 'podcast': return '#8b5cf6';
|
||||
case 'linkedin': return '#0a66c2';
|
||||
case 'gist': return '#8b949e';
|
||||
case 'producthunt': return '#da552f';
|
||||
case 'discord': return '#5865f2';
|
||||
default: return '#64748b';
|
||||
}
|
||||
}
|
||||
|
||||
export default function UserStoriesCollage(): JSX.Element {
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [activeSource, setActiveSource] = useState<string>('all');
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of allStories) counts[s.category] = (counts[s.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}, []);
|
||||
|
||||
const sourceCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of allStories) counts[s.source] = (counts[s.source] ?? 0) + 1;
|
||||
return counts;
|
||||
}, []);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
return allStories.filter((s) => {
|
||||
if (activeCategory !== 'all' && s.category !== activeCategory) return false;
|
||||
if (activeSource !== 'all' && s.source !== activeSource) return false;
|
||||
return true;
|
||||
});
|
||||
}, [activeCategory, activeSource]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.hero}>
|
||||
<h1>User Stories & Use Cases</h1>
|
||||
<p>
|
||||
What the Hermes Agent community is actually building. Every tile
|
||||
below links to a real post, issue, video, or gist where someone
|
||||
describes how they use Hermes — scraped from X, GitHub, Reddit,
|
||||
Hacker News, YouTube, blogs, and podcasts.
|
||||
</p>
|
||||
<div className={styles.meta}>
|
||||
<span><strong>{allStories.length}</strong> stories</span>
|
||||
<span><strong>{Object.keys(categoryCounts).length}</strong> categories</span>
|
||||
<span><strong>{Object.keys(sourceCounts).length}</strong> sources</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
<div className={styles.filters}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.filterBtn} ${activeCategory === 'all' ? styles.filterActive : ''}`}
|
||||
onClick={() => setActiveCategory('all')}
|
||||
>
|
||||
All<span className={styles.filterCount}>{allStories.length}</span>
|
||||
</button>
|
||||
{Object.entries(CATEGORIES)
|
||||
.filter(([key]) => categoryCounts[key])
|
||||
.sort((a, b) => (categoryCounts[b[0]] ?? 0) - (categoryCounts[a[0]] ?? 0))
|
||||
.map(([key, meta]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`${styles.filterBtn} ${activeCategory === key ? styles.filterActive : ''}`}
|
||||
onClick={() => setActiveCategory(key)}
|
||||
style={
|
||||
activeCategory === key
|
||||
? { background: meta.solid, borderColor: meta.solid, color: '#0f172a' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{meta.label}
|
||||
<span className={styles.filterCount}>{categoryCounts[key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Source filters — smaller, secondary row */}
|
||||
<div className={styles.filters} style={{ marginTop: '-0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.filterBtn} ${activeSource === 'all' ? styles.filterActive : ''}`}
|
||||
onClick={() => setActiveSource('all')}
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
>
|
||||
All sources
|
||||
</button>
|
||||
{Object.entries(SOURCE_LABELS)
|
||||
.filter(([key]) => sourceCounts[key])
|
||||
.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`${styles.filterBtn} ${activeSource === key ? styles.filterActive : ''}`}
|
||||
onClick={() => setActiveSource(key)}
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
...(activeSource === key
|
||||
? { background: sourceColor(key), borderColor: sourceColor(key), color: '#fff' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span className={styles.filterCount}>{sourceCounts[key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collage grid */}
|
||||
{visible.length === 0 ? (
|
||||
<div className={styles.empty}>No stories match that filter.</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{visible.map((s) => {
|
||||
const cat = CATEGORIES[s.category] ?? CATEGORIES.general;
|
||||
const sizeClass =
|
||||
s.size === 'lg' ? styles.tileLg : s.size === 'sm' ? styles.tileSm : styles.tileMd;
|
||||
const srcColor = sourceColor(s.source);
|
||||
return (
|
||||
<a
|
||||
key={s.id}
|
||||
className={`${styles.tile} ${sizeClass}`}
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
{
|
||||
'--tile-accent': cat.strip,
|
||||
'--tile-accent-solid': cat.solid,
|
||||
'--tile-accent-soft': cat.soft,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.badgeRow}>
|
||||
<span className={styles.sourceBadge}>
|
||||
<span className={styles.sourceIcon} style={{ background: srcColor }} />
|
||||
{SOURCE_LABELS[s.source] ?? s.source}
|
||||
</span>
|
||||
<span className={styles.catTag}>{cat.label}</span>
|
||||
</div>
|
||||
<h3 className={styles.headline}>{s.headline}</h3>
|
||||
<p className={styles.quote}>“{s.quote}”</p>
|
||||
<span className={styles.author}>
|
||||
{s.author}
|
||||
{s.date ? <> · {s.date}</> : null}
|
||||
</span>
|
||||
<span className={styles.external} aria-hidden="true">↗</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
Built something with Hermes?{' '}
|
||||
<a
|
||||
href="https://github.com/NousResearch/hermes-agent/edit/main/website/src/data/userStories.json"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Add your story to this page
|
||||
</a>{' '}
|
||||
by editing <code>userStories.json</code>, or post it in the{' '}
|
||||
<a href="https://discord.gg/NousResearch" target="_blank" rel="noopener noreferrer">
|
||||
Nous Research Discord
|
||||
</a>{' '}
|
||||
and we'll pick it up.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/* User Stories collage — masonry grid with category-driven accents. */
|
||||
|
||||
.wrap {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 4rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 2.5rem 0 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 4vw, 3.25rem);
|
||||
margin-bottom: 0.75rem;
|
||||
background: linear-gradient(120deg, #a78bfa 0%, #60a5fa 50%, #34d399 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.hero p {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
.meta strong {
|
||||
color: var(--ifm-color-emphasis-900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Filter bar */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin: 1.75rem 0 2rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.filterBtn {
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: transparent;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filterBtn:hover {
|
||||
border-color: var(--ifm-color-emphasis-500);
|
||||
color: var(--ifm-color-emphasis-1000);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.filterActive {
|
||||
background: var(--ifm-color-emphasis-900);
|
||||
color: var(--ifm-background-color);
|
||||
border-color: var(--ifm-color-emphasis-900);
|
||||
}
|
||||
[data-theme='dark'] .filterActive {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
.filterCount {
|
||||
margin-left: 0.35rem;
|
||||
opacity: 0.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Masonry — use CSS columns for a true collage feel */
|
||||
.grid {
|
||||
column-count: 4;
|
||||
column-gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
@media (max-width: 1200px) { .grid { column-count: 3; } }
|
||||
@media (max-width: 850px) { .grid { column-count: 2; } }
|
||||
@media (max-width: 560px) { .grid { column-count: 1; } }
|
||||
|
||||
/* Tile */
|
||||
.tile {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 1.1rem 1.2rem 1.15rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
background: var(--ifm-card-background-color, var(--ifm-background-surface-color));
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
overflow: hidden;
|
||||
transition: transform 0.22s ease, box-shadow 0.22s ease, border-color 0.22s ease;
|
||||
}
|
||||
.tile::before {
|
||||
/* Color accent strip */
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: var(--tile-accent, linear-gradient(90deg, #a78bfa, #60a5fa));
|
||||
opacity: 0.9;
|
||||
}
|
||||
.tile::after {
|
||||
/* Subtle hover glow */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
pointer-events: none;
|
||||
transition: box-shadow 0.22s ease;
|
||||
}
|
||||
.tile:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--tile-accent-solid, var(--ifm-color-primary));
|
||||
box-shadow: 0 8px 24px -8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
[data-theme='dark'] .tile:hover {
|
||||
box-shadow: 0 10px 30px -12px rgba(120, 120, 200, 0.45);
|
||||
}
|
||||
|
||||
/* Size variants — big tiles get more visual weight */
|
||||
.tileSm { min-height: 130px; }
|
||||
.tileMd { min-height: 180px; }
|
||||
.tileLg {
|
||||
min-height: 240px;
|
||||
padding: 1.35rem 1.45rem 1.45rem;
|
||||
}
|
||||
.tileLg .headline {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* Tile body */
|
||||
.badgeRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sourceIcon {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
background: var(--tile-accent-solid, #a78bfa);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.catTag {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--tile-accent-soft, rgba(167, 139, 250, 0.12));
|
||||
color: var(--tile-accent-solid, #a78bfa);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--ifm-color-emphasis-1000);
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tileLg .quote { -webkit-line-clamp: 8; }
|
||||
.tileSm .quote { -webkit-line-clamp: 4; }
|
||||
|
||||
.author {
|
||||
display: block;
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.external {
|
||||
position: absolute;
|
||||
top: 0.9rem;
|
||||
right: 0.9rem;
|
||||
opacity: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--tile-accent-solid, var(--ifm-color-primary));
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.tile:hover .external {
|
||||
opacity: 1;
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
margin: 3rem auto 0;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
max-width: 720px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
font-size: 0.95rem;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.footer a {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Hermes Agent — Custom Docusaurus Theme
|
||||
* Matches the landing page branding: amber-on-dark, terminal aesthetic
|
||||
* Colors from landingpage/style.css
|
||||
*/
|
||||
|
||||
/* Import fonts to match landing page */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* Dark amber palette for light mode — readable on white (WCAG AA compliant)
|
||||
Current gold #FFD700 has only 1.4:1 contrast on white; these tones pass 4.5:1+ */
|
||||
--ifm-color-primary: #8B6508;
|
||||
--ifm-color-primary-dark: #7A5800;
|
||||
--ifm-color-primary-darker: #6E4F00;
|
||||
--ifm-color-primary-darkest: #5A4100;
|
||||
--ifm-color-primary-light: #9E7410;
|
||||
--ifm-color-primary-lighter: #B38319;
|
||||
--ifm-color-primary-lightest: #C89222;
|
||||
|
||||
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--ifm-font-family-monospace: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
--ifm-code-font-size: 90%;
|
||||
--ifm-heading-font-weight: 600;
|
||||
|
||||
--ifm-link-color: #7A5800;
|
||||
--ifm-link-hover-color: #5A4100;
|
||||
}
|
||||
|
||||
/* Dark mode — the PRIMARY mode, matches landing page */
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #FFD700;
|
||||
--ifm-color-primary-dark: #E6C200;
|
||||
--ifm-color-primary-darker: #D9B700;
|
||||
--ifm-color-primary-darkest: #B39600;
|
||||
--ifm-color-primary-light: #FFDD33;
|
||||
--ifm-color-primary-lighter: #FFE14D;
|
||||
--ifm-color-primary-lightest: #FFEB80;
|
||||
|
||||
--ifm-background-color: #07070d;
|
||||
--ifm-background-surface-color: #0f0f18;
|
||||
--ifm-navbar-background-color: #07070dEE;
|
||||
--ifm-footer-background-color: #050509;
|
||||
--ifm-color-emphasis-100: #14142a;
|
||||
--ifm-color-emphasis-200: #1a1a30;
|
||||
|
||||
--ifm-font-color-base: #e8e4dc;
|
||||
--ifm-font-color-secondary: #9a968e;
|
||||
|
||||
--ifm-link-color: #FFD700;
|
||||
--ifm-link-hover-color: #FFBF00;
|
||||
|
||||
--ifm-code-background: #0f0f18;
|
||||
|
||||
--ifm-toc-border-color: rgba(255, 215, 0, 0.08);
|
||||
--ifm-hr-border-color: rgba(255, 215, 0, 0.08);
|
||||
|
||||
--docusaurus-highlighted-code-line-bg: rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Subtle dot grid background matching landing page */
|
||||
[data-theme='dark'] .main-wrapper {
|
||||
background-image: radial-gradient(rgba(255, 215, 0, 0.02) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* Navbar styling */
|
||||
.navbar {
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
/* backdrop-filter creates a stacking context that hides
|
||||
.navbar-sidebar menu content (Docusaurus #6996). Remove it
|
||||
while the mobile sidebar is open — both classes live on the
|
||||
same <nav> element. */
|
||||
.navbar.navbar-sidebar--show {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.navbar__title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Sidebar tweaks */
|
||||
[data-theme='dark'] .menu {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .menu__link--active:not(.menu__link--sublist) {
|
||||
background-color: rgba(255, 215, 0, 0.08);
|
||||
border-left: 3px solid #FFD700;
|
||||
padding-left: calc(var(--ifm-menu-link-padding-horizontal) - 3px);
|
||||
}
|
||||
|
||||
/* Light mode sidebar active */
|
||||
[data-theme='light'] .menu__link--active:not(.menu__link--sublist) {
|
||||
background-color: rgba(139, 101, 8, 0.08);
|
||||
border-left: 3px solid #8B6508;
|
||||
padding-left: calc(var(--ifm-menu-link-padding-horizontal) - 3px);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
[data-theme='dark'] .prism-code {
|
||||
background-color: #0a0a12 !important;
|
||||
border: 1px solid rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Text diagrams: preserve spacing, disable ligatures, and prefer box-drawing-safe fonts */
|
||||
pre.prism-code.language-text,
|
||||
pre.prism-code.language-plaintext,
|
||||
pre.prism-code.language-txt,
|
||||
pre.prism-code.language-ascii {
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
line-height: 1.35;
|
||||
font-family: 'JetBrains Mono', 'Cascadia Mono', 'Cascadia Code', 'Fira Code', 'SFMono-Regular', 'DejaVu Sans Mono', 'Liberation Mono', monospace;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
pre.prism-code.language-text code,
|
||||
pre.prism-code.language-plaintext code,
|
||||
pre.prism-code.language-txt code,
|
||||
pre.prism-code.language-ascii code {
|
||||
white-space: pre;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
}
|
||||
|
||||
.theme-mermaid {
|
||||
margin: 1.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-mermaid svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.docs-terminal-figure {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 1.25rem auto 0.5rem;
|
||||
border: 1px solid rgba(255, 215, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #0a0a12;
|
||||
}
|
||||
|
||||
.docs-figure-caption {
|
||||
margin-top: 0.35rem;
|
||||
text-align: center;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Admonitions — gold-tinted */
|
||||
[data-theme='dark'] .alert--info {
|
||||
--ifm-alert-background-color: rgba(255, 215, 0, 0.05);
|
||||
--ifm-alert-border-color: rgba(255, 215, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
[data-theme='dark'] table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
[data-theme='dark'] table th {
|
||||
background-color: rgba(255, 215, 0, 0.06);
|
||||
border-color: rgba(255, 215, 0, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='dark'] table td {
|
||||
border-color: rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Light mode table styling */
|
||||
[data-theme='light'] table th {
|
||||
background-color: rgba(139, 101, 8, 0.06);
|
||||
border-color: rgba(139, 101, 8, 0.15);
|
||||
}
|
||||
|
||||
[data-theme='light'] table td {
|
||||
border-color: rgba(139, 101, 8, 0.10);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
border-top: 1px solid rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #9a968e;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .footer a:hover {
|
||||
color: #FFD700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-theme='light'] .footer a:hover {
|
||||
color: #7A5800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
[data-theme='dark'] ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::-webkit-scrollbar-track {
|
||||
background: #07070d;
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: #1a1a30;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #2a2a40;
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
[data-theme='dark'] .DocSearch-Button {
|
||||
background-color: #0f0f18;
|
||||
border: 1px solid rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ─── Mobile sidebar improvements ─────────────────────────────────────────── */
|
||||
|
||||
/* Larger touch targets on mobile */
|
||||
@media (max-width: 996px) {
|
||||
.menu__link {
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.menu__list-item-collapsible > .menu__link {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Category caret — more visible */
|
||||
.menu__caret::before {
|
||||
background-size: 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Indent subcategories clearly */
|
||||
.menu__list .menu__list {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 1px solid rgba(255, 215, 0, 0.06);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar overlay — slightly more opaque for readability */
|
||||
.navbar-sidebar__backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Sidebar width on mobile — use more of the screen */
|
||||
.navbar-sidebar {
|
||||
width: 85vw;
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero banner for docs landing if needed */
|
||||
.hero--hermes {
|
||||
background: linear-gradient(135deg, #07070d 0%, #0f0f18 100%);
|
||||
padding: 4rem 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,953 @@
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
overview?: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
source: string;
|
||||
tags: string[];
|
||||
platforms: string[];
|
||||
author: string;
|
||||
version: string;
|
||||
license?: string;
|
||||
envVars?: string[];
|
||||
commands?: string[];
|
||||
docsPath?: string;
|
||||
identifier?: string;
|
||||
installCmd?: string;
|
||||
/** Clickable URL to the skill's origin (repo / detail page). Synthesized
|
||||
* in extract-skills.py for community skills that have no generated docs
|
||||
* page, so the expanded card always has somewhere to send the user. */
|
||||
sourceUrl?: string;
|
||||
/** Lowercase pre-joined haystack used by the search filter.
|
||||
* Built once at load time so per-keystroke filtering is a single
|
||||
* `.includes()` per skill instead of array-join + toLowerCase on
|
||||
* every render. Skipped on the wire — added in the loader. */
|
||||
_search?: string;
|
||||
}
|
||||
|
||||
const allSkills: Skill[] = [];
|
||||
|
||||
interface IndexMeta {
|
||||
extractedAt?: string;
|
||||
indexGeneratedAt?: string;
|
||||
totalSkills?: number;
|
||||
externalSource?: string;
|
||||
bySource?: Record<string, number>;
|
||||
}
|
||||
const indexMeta: IndexMeta = {};
|
||||
|
||||
function formatRelativeTime(iso?: string): string | null {
|
||||
if (!iso) return null;
|
||||
const then = new Date(iso).getTime();
|
||||
if (!Number.isFinite(then)) return null;
|
||||
const now = Date.now();
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return "just now";
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months} month${months === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
apple: "\u{f179}",
|
||||
"autonomous-ai-agents": "\u{1F916}",
|
||||
blockchain: "\u{26D3}",
|
||||
communication: "\u{1F4AC}",
|
||||
creative: "\u{1F3A8}",
|
||||
"data-science": "\u{1F4CA}",
|
||||
devops: "\u{2699}",
|
||||
dogfood: "\u{1F436}",
|
||||
domain: "\u{1F310}",
|
||||
email: "\u{2709}",
|
||||
feeds: "\u{1F4E1}",
|
||||
gaming: "\u{1F3AE}",
|
||||
gifs: "\u{1F3AC}",
|
||||
github: "\u{1F4BB}",
|
||||
health: "\u{2764}",
|
||||
"inference-sh": "\u{26A1}",
|
||||
leisure: "\u{2615}",
|
||||
mcp: "\u{1F50C}",
|
||||
media: "\u{1F3B5}",
|
||||
migration: "\u{1F4E6}",
|
||||
mlops: "\u{1F9EA}",
|
||||
"note-taking": "\u{1F4DD}",
|
||||
productivity: "\u{2705}",
|
||||
"red-teaming": "\u{1F6E1}",
|
||||
research: "\u{1F50D}",
|
||||
security: "\u{1F512}",
|
||||
"smart-home": "\u{1F3E0}",
|
||||
"social-media": "\u{1F4F1}",
|
||||
"software-development": "\u{1F4BB}",
|
||||
translation: "\u{1F30D}",
|
||||
other: "\u{1F4E6}",
|
||||
};
|
||||
|
||||
const SOURCE_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; color: string; bg: string; border: string; icon: string }
|
||||
> = {
|
||||
"built-in": {
|
||||
label: "Built-in",
|
||||
color: "#4ade80",
|
||||
bg: "rgba(74, 222, 128, 0.08)",
|
||||
border: "rgba(74, 222, 128, 0.2)",
|
||||
icon: "\u{2713}",
|
||||
},
|
||||
optional: {
|
||||
label: "Optional",
|
||||
color: "#fbbf24",
|
||||
bg: "rgba(251, 191, 36, 0.08)",
|
||||
border: "rgba(251, 191, 36, 0.2)",
|
||||
icon: "\u{2B50}",
|
||||
},
|
||||
Anthropic: {
|
||||
label: "Anthropic",
|
||||
color: "#d4845a",
|
||||
bg: "rgba(212, 132, 90, 0.08)",
|
||||
border: "rgba(212, 132, 90, 0.2)",
|
||||
icon: "\u{25C6}",
|
||||
},
|
||||
LobeHub: {
|
||||
label: "LobeHub",
|
||||
color: "#60a5fa",
|
||||
bg: "rgba(96, 165, 250, 0.08)",
|
||||
border: "rgba(96, 165, 250, 0.2)",
|
||||
icon: "\u{25CB}",
|
||||
},
|
||||
"skills.sh": {
|
||||
label: "skills.sh",
|
||||
color: "#34d399",
|
||||
bg: "rgba(52, 211, 153, 0.08)",
|
||||
border: "rgba(52, 211, 153, 0.2)",
|
||||
icon: "\u{2734}",
|
||||
},
|
||||
ClawHub: {
|
||||
label: "ClawHub",
|
||||
color: "#f472b6",
|
||||
bg: "rgba(244, 114, 182, 0.08)",
|
||||
border: "rgba(244, 114, 182, 0.2)",
|
||||
icon: "\u{2726}",
|
||||
},
|
||||
"browse.sh": {
|
||||
label: "browse.sh",
|
||||
color: "#22d3ee",
|
||||
bg: "rgba(34, 211, 238, 0.08)",
|
||||
border: "rgba(34, 211, 238, 0.2)",
|
||||
icon: "\u{29BF}",
|
||||
},
|
||||
OpenAI: {
|
||||
label: "OpenAI",
|
||||
color: "#10b981",
|
||||
bg: "rgba(16, 185, 129, 0.08)",
|
||||
border: "rgba(16, 185, 129, 0.2)",
|
||||
icon: "\u{2737}",
|
||||
},
|
||||
HuggingFace: {
|
||||
label: "HuggingFace",
|
||||
color: "#fbbf24",
|
||||
bg: "rgba(251, 191, 36, 0.08)",
|
||||
border: "rgba(251, 191, 36, 0.2)",
|
||||
icon: "\u{1F917}",
|
||||
},
|
||||
NVIDIA: {
|
||||
label: "NVIDIA",
|
||||
color: "#76b900",
|
||||
bg: "rgba(118, 185, 0, 0.08)",
|
||||
border: "rgba(118, 185, 0, 0.25)",
|
||||
icon: "\u{25B6}",
|
||||
},
|
||||
VoltAgent: {
|
||||
label: "VoltAgent",
|
||||
color: "#facc15",
|
||||
bg: "rgba(250, 204, 21, 0.08)",
|
||||
border: "rgba(250, 204, 21, 0.2)",
|
||||
icon: "\u{26A1}",
|
||||
},
|
||||
GitHub: {
|
||||
label: "GitHub",
|
||||
color: "#94a3b8",
|
||||
bg: "rgba(148, 163, 184, 0.08)",
|
||||
border: "rgba(148, 163, 184, 0.2)",
|
||||
icon: "\u{2756}",
|
||||
},
|
||||
"Well-Known": {
|
||||
label: "Well-Known",
|
||||
color: "#818cf8",
|
||||
bg: "rgba(129, 140, 248, 0.08)",
|
||||
border: "rgba(129, 140, 248, 0.2)",
|
||||
icon: "\u{2756}",
|
||||
},
|
||||
gstack: {
|
||||
label: "gstack",
|
||||
color: "#fb923c",
|
||||
bg: "rgba(251, 146, 60, 0.08)",
|
||||
border: "rgba(251, 146, 60, 0.2)",
|
||||
icon: "\u{2756}",
|
||||
},
|
||||
MiniMax: {
|
||||
label: "MiniMax",
|
||||
color: "#f87171",
|
||||
bg: "rgba(248, 113, 113, 0.08)",
|
||||
border: "rgba(248, 113, 113, 0.2)",
|
||||
icon: "\u{2756}",
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_ORDER = [
|
||||
"all",
|
||||
"built-in",
|
||||
"optional",
|
||||
"Anthropic",
|
||||
"OpenAI",
|
||||
"HuggingFace",
|
||||
"NVIDIA",
|
||||
"skills.sh",
|
||||
"ClawHub",
|
||||
"browse.sh",
|
||||
"LobeHub",
|
||||
"VoltAgent",
|
||||
"Well-Known",
|
||||
"GitHub",
|
||||
"gstack",
|
||||
"MiniMax",
|
||||
];
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query || !text) return text;
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className={styles.highlight}>{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
},
|
||||
[text],
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className={styles.copyBtn}
|
||||
onClick={onCopy}
|
||||
title="Copy install command"
|
||||
aria-label="Copy install command"
|
||||
>
|
||||
{copied ? (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
|
||||
<path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.12A1.5 1.5 0 0117 6.622V12.5a1.5 1.5 0 01-1.5 1.5h-1v-3.379a3 3 0 00-.879-2.121L10.5 5.379A3 3 0 008.379 4.5H7v-1z" />
|
||||
<path d="M4.5 6A1.5 1.5 0 003 7.5v9A1.5 1.5 0 004.5 18h7a1.5 1.5 0 001.5-1.5v-5.879a1.5 1.5 0 00-.44-1.06L9.44 6.439A1.5 1.5 0 008.378 6H4.5z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={styles.copyBtnLabel}>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCard({
|
||||
skill,
|
||||
query,
|
||||
expanded,
|
||||
onToggle,
|
||||
onCategoryClick,
|
||||
onTagClick,
|
||||
style,
|
||||
onPick,
|
||||
}: {
|
||||
skill: Skill;
|
||||
query: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onCategoryClick: (cat: string) => void;
|
||||
onTagClick: (tag: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
/** Picker embed mode: render "+ Add to this Agent" and call this. */
|
||||
onPick?: (skill: Skill) => void;
|
||||
}) {
|
||||
const src = SOURCE_CONFIG[skill.source] || SOURCE_CONFIG["optional"];
|
||||
const icon = CATEGORY_ICONS[skill.category] || "\u{1F4E6}";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.card} ${expanded ? styles.cardExpanded : ""}`}
|
||||
onClick={onToggle}
|
||||
style={style}
|
||||
>
|
||||
<div className={styles.cardAccent} style={{ background: src.color }} />
|
||||
|
||||
<div className={styles.cardInner}>
|
||||
<div className={styles.cardTop}>
|
||||
<span className={styles.cardIcon}>{icon}</span>
|
||||
<div className={styles.cardTitleGroup}>
|
||||
<h3 className={styles.cardTitle}>
|
||||
{highlightMatch(skill.name, query)}
|
||||
</h3>
|
||||
<span
|
||||
className={styles.sourcePill}
|
||||
style={{
|
||||
color: src.color,
|
||||
background: src.bg,
|
||||
borderColor: src.border,
|
||||
}}
|
||||
>
|
||||
{src.icon} {src.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={`${styles.cardDesc} ${expanded ? styles.cardDescFull : ""}`}>
|
||||
{highlightMatch(skill.description || "No description available.", query)}
|
||||
</p>
|
||||
|
||||
<div className={styles.cardMeta}>
|
||||
<button
|
||||
className={styles.catButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCategoryClick(skill.category);
|
||||
}}
|
||||
title={`Filter by ${skill.categoryLabel}`}
|
||||
>
|
||||
{skill.categoryLabel || skill.category}
|
||||
</button>
|
||||
{skill.platforms?.map((p) => (
|
||||
<span key={p} className={styles.platformPill}>
|
||||
{p === "macos" ? "\u{F8FF} macOS" : p === "linux" ? "\u{1F427} Linux" : p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.cardDetail}>
|
||||
{skill.overview && (
|
||||
<div className={styles.overviewBlock}>
|
||||
<span className={styles.detailLabel}>Overview</span>
|
||||
<p className={styles.overviewText}>{skill.overview}</p>
|
||||
</div>
|
||||
)}
|
||||
{(skill.envVars?.length || skill.commands?.length) ? (
|
||||
<div className={styles.prereqBlock}>
|
||||
<span className={styles.detailLabel}>Prerequisites</span>
|
||||
{skill.envVars?.length ? (
|
||||
<div className={styles.prereqRow}>
|
||||
<span className={styles.prereqKind}>env</span>
|
||||
<span className={styles.prereqList}>
|
||||
{skill.envVars.map((v) => (
|
||||
<code key={v} className={styles.prereqItem}>{v}</code>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{skill.commands?.length ? (
|
||||
<div className={styles.prereqRow}>
|
||||
<span className={styles.prereqKind}>cmd</span>
|
||||
<span className={styles.prereqList}>
|
||||
{skill.commands.map((c) => (
|
||||
<code key={c} className={styles.prereqItem}>{c}</code>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{skill.tags?.length > 0 && (
|
||||
<div className={styles.tagRow}>
|
||||
{skill.tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={styles.tagPill}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTagClick(tag);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{skill.author && (
|
||||
<div className={styles.authorRow}>
|
||||
<span className={styles.authorLabel}>Author</span>
|
||||
<span className={styles.authorValue}>{skill.author}</span>
|
||||
</div>
|
||||
)}
|
||||
{skill.version && (
|
||||
<div className={styles.authorRow}>
|
||||
<span className={styles.authorLabel}>Version</span>
|
||||
<span className={styles.authorValue}>{skill.version}</span>
|
||||
</div>
|
||||
)}
|
||||
{skill.license && (
|
||||
<div className={styles.authorRow}>
|
||||
<span className={styles.authorLabel}>License</span>
|
||||
<span className={styles.authorValue}>{skill.license}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.installHint}>
|
||||
<code>{skill.installCmd || `hermes skills install ${skill.name}`}</code>
|
||||
<CopyButton
|
||||
text={skill.installCmd || `hermes skills install ${skill.name}`}
|
||||
/>
|
||||
</div>
|
||||
{onPick ? (
|
||||
<button
|
||||
className={styles.pickBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPick(skill);
|
||||
}}
|
||||
>
|
||||
+ Add to this Agent
|
||||
</button>
|
||||
) : null}
|
||||
<div className={styles.cardLinks}>
|
||||
{skill.docsPath ? (
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={`/docs/user-guide/skills/${skill.docsPath}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View full documentation →
|
||||
</a>
|
||||
) : skill.sourceUrl ? (
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={skill.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View source ↗
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue} style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
<span className={styles.statLabel}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
// Routes Docusaurus serves the static API JSON from. `baseUrl` is `/docs/`,
|
||||
// `static/api/` ends up at `/docs/api/`. Hardcoding here is fine because the
|
||||
// same `baseUrl` is enforced repo-wide; if it ever changes, this is the only
|
||||
// place that needs to follow.
|
||||
const SKILLS_URL = "/docs/api/skills.json";
|
||||
const META_URL = "/docs/api/skills-meta.json";
|
||||
|
||||
function buildSearchHaystack(s: Skill): string {
|
||||
// Pre-compute the lowercase blob the search filter scans. Done once at
|
||||
// load time instead of per-keystroke per-skill. With 50k+ skills the
|
||||
// per-keystroke variant was unusably slow.
|
||||
return [
|
||||
s.name,
|
||||
s.description,
|
||||
s.overview,
|
||||
s.categoryLabel,
|
||||
s.author,
|
||||
...(s.tags || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export default function SkillsDashboard() {
|
||||
// Picker embed mode (?embed=picker): the page is being iframed by a host
|
||||
// app (Hermes desktop's Bot Mode agent editor) as a skill PICKER. Site
|
||||
// chrome is hidden via a CSS class and every card gains an
|
||||
// "+ Add to this Agent" button that posts
|
||||
// { type: 'hermes-skill-pick', name, identifier, installCmd, source }
|
||||
// to the parent window. The HOST performs the actual install through its
|
||||
// own gateway (skills.manage) — the page never installs anything, so
|
||||
// there is no origin to trust in this direction; parents must validate
|
||||
// event.origin themselves before acting on the message.
|
||||
const pickerMode =
|
||||
typeof window !== "undefined" &&
|
||||
new URLSearchParams(window.location.search).get("embed") === "picker";
|
||||
|
||||
const pickSkill = useCallback(
|
||||
(skill: Skill) => {
|
||||
if (typeof window === "undefined" || window.parent === window) return;
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "hermes-skill-pick",
|
||||
name: skill.name,
|
||||
identifier: skill.identifier || skill.name,
|
||||
installCmd: skill.installCmd || `hermes skills install ${skill.name}`,
|
||||
source: skill.source,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Lazy-loaded data. Was bundled into the JS chunk (~22 MB at 50k skills,
|
||||
// which made the initial page load unusable on mobile). Now fetched on
|
||||
// mount from the same CDN that serves the docs.
|
||||
const [data, setData] = useState<{ skills: Skill[]; meta: IndexMeta } | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
// Debounced copy of `search` — used by the filter. Without the debounce,
|
||||
// typing into the search box ran .filter() over the whole catalog on
|
||||
// every keystroke, which on a 50k-item list felt like the page had
|
||||
// hung. 150ms gives a snappy feel without lagging behind the user.
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [sk, mt] = await Promise.all([
|
||||
fetch(SKILLS_URL).then((r) => {
|
||||
if (!r.ok) throw new Error(`skills.json HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
fetch(META_URL).then((r) => (r.ok ? r.json() : {})).catch(() => ({})),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const skillsArr = Array.isArray(sk) ? (sk as Skill[]) : [];
|
||||
// Stamp the precomputed search haystack onto each row.
|
||||
for (const s of skillsArr) s._search = buildSearchHaystack(s);
|
||||
setData({ skills: skillsArr, meta: mt || {} });
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce the search input — 150ms feels instant while preventing the
|
||||
// filter from running on every individual keystroke.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 150);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const allSkillsLocal: Skill[] = data?.skills ?? [];
|
||||
const indexMetaLocal: IndexMeta = data?.meta ?? indexMeta;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
searchRef.current?.blur();
|
||||
setExpandedCard(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const sources = useMemo(() => {
|
||||
const set = new Set(allSkillsLocal.map((s) => s.source));
|
||||
return SOURCE_ORDER.filter((s) => s === "all" || set.has(s));
|
||||
}, [allSkillsLocal]);
|
||||
|
||||
const categoryEntries = useMemo(() => {
|
||||
const pool =
|
||||
sourceFilter === "all"
|
||||
? allSkillsLocal
|
||||
: allSkillsLocal.filter((s) => s.source === sourceFilter);
|
||||
const map = new Map<string, { label: string; count: number }>();
|
||||
for (const s of pool) {
|
||||
const key = s.category || "uncategorized";
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
map.set(key, {
|
||||
label: s.categoryLabel || s.category || "Uncategorized",
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.map(([key, { label, count }]) => ({ key, label, count }));
|
||||
}, [sourceFilter, allSkillsLocal]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return allSkillsLocal.filter((s) => {
|
||||
if (sourceFilter !== "all" && s.source !== sourceFilter) return false;
|
||||
if (categoryFilter !== "all" && s.category !== categoryFilter) return false;
|
||||
if (q) {
|
||||
// _search is pre-built in the load effect — single .includes() per row.
|
||||
return (s._search || "").includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [debouncedSearch, sourceFilter, categoryFilter, allSkillsLocal]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
setExpandedCard(null);
|
||||
}, [debouncedSearch, sourceFilter, categoryFilter]);
|
||||
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
const handleSourceChange = useCallback(
|
||||
(src: string) => {
|
||||
setSourceFilter(src);
|
||||
setCategoryFilter("all");
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCategoryClick = useCallback((cat: string) => {
|
||||
setCategoryFilter(cat);
|
||||
gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTagClick = useCallback((tag: string) => {
|
||||
setSearch(tag);
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setSearch("");
|
||||
setSourceFilter("all");
|
||||
setCategoryFilter("all");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Skills Hub"
|
||||
description="Browse all skills and plugins available for Hermes Agent"
|
||||
>
|
||||
<div className={`${styles.page} ${pickerMode ? styles.pickerMode : ""}`}>
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.heroGlow} />
|
||||
<div className={styles.heroContent}>
|
||||
<p className={styles.heroEyebrow}>Hermes Agent</p>
|
||||
<h1 className={styles.heroTitle}>Skills Hub</h1>
|
||||
<p className={styles.heroSub}>
|
||||
Discover, search, and install from{" "}
|
||||
<strong className={styles.heroAccent}>
|
||||
{data ? allSkillsLocal.length.toLocaleString() : "…"}
|
||||
</strong>{" "}
|
||||
skills across {sources.length - 1} registries
|
||||
{loadError && (
|
||||
<span style={{ color: "#f87171", marginLeft: 8 }}>
|
||||
· failed to load catalog ({loadError})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{(indexMetaLocal?.indexGeneratedAt || indexMetaLocal?.extractedAt) && (
|
||||
<p className={styles.heroSub} style={{ fontSize: "0.85rem", opacity: 0.75 }}>
|
||||
Catalog refreshed{" "}
|
||||
<span title={indexMetaLocal.indexGeneratedAt || indexMetaLocal.extractedAt}>
|
||||
{formatRelativeTime(
|
||||
indexMetaLocal.indexGeneratedAt || indexMetaLocal.extractedAt,
|
||||
) || "recently"}
|
||||
</span>
|
||||
{" "}· auto-rebuilt twice daily
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.statsRow}>
|
||||
<StatCard
|
||||
value={allSkillsLocal.filter((s) => s.source === "built-in").length}
|
||||
label="Built-in"
|
||||
color="#4ade80"
|
||||
/>
|
||||
<StatCard
|
||||
value={allSkillsLocal.filter((s) => s.source === "optional").length}
|
||||
label="Optional"
|
||||
color="#fbbf24"
|
||||
/>
|
||||
<StatCard
|
||||
value={
|
||||
allSkillsLocal.filter(
|
||||
(s) => s.source !== "built-in" && s.source !== "optional"
|
||||
).length
|
||||
}
|
||||
label="Community"
|
||||
color="#60a5fa"
|
||||
/>
|
||||
<StatCard
|
||||
value={new Set(allSkillsLocal.map((s) => s.category)).size}
|
||||
label="Categories"
|
||||
color="#a78bfa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.controlsBar}>
|
||||
<div className={styles.searchWrap}>
|
||||
<svg className={styles.searchIcon} viewBox="0 0 20 20" fill="currentColor" width="18" height="18">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder='Search skills... (press "/" to focus)'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{search && (
|
||||
<button className={styles.clearBtn} onClick={() => setSearch("")}>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.sourcePills}>
|
||||
{sources.map((src) => {
|
||||
const active = sourceFilter === src;
|
||||
const conf = SOURCE_CONFIG[src];
|
||||
const count =
|
||||
src === "all"
|
||||
? allSkillsLocal.length
|
||||
: allSkillsLocal.filter((s) => s.source === src).length;
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
className={`${styles.srcPill} ${active ? styles.srcPillActive : ""}`}
|
||||
onClick={() => handleSourceChange(src)}
|
||||
style={
|
||||
active && conf
|
||||
? ({
|
||||
"--pill-color": conf.color,
|
||||
"--pill-bg": conf.bg,
|
||||
"--pill-border": conf.border,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{src === "all" ? "All" : conf?.label || src}
|
||||
<span className={styles.srcCount}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.layout}>
|
||||
<button
|
||||
className={styles.sidebarToggle}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h6a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Categories
|
||||
{categoryFilter !== "all" && (
|
||||
<span className={styles.activeCatBadge}>
|
||||
{categoryEntries.find((c) => c.key === categoryFilter)?.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.sidebarOpen : ""}`}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h2 className={styles.sidebarTitle}>Categories</h2>
|
||||
{categoryFilter !== "all" && (
|
||||
<button className={styles.sidebarClear} onClick={() => setCategoryFilter("all")}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className={styles.catList}>
|
||||
<button
|
||||
className={`${styles.catItem} ${categoryFilter === "all" ? styles.catItemActive : ""}`}
|
||||
onClick={() => {
|
||||
setCategoryFilter("all");
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className={styles.catItemIcon}>{"\u{1F4CB}"}</span>
|
||||
<span className={styles.catItemLabel}>All Skills</span>
|
||||
<span className={styles.catItemCount}>{filtered.length}</span>
|
||||
</button>
|
||||
{categoryEntries.map((cat) => (
|
||||
<button
|
||||
key={cat.key}
|
||||
className={`${styles.catItem} ${categoryFilter === cat.key ? styles.catItemActive : ""}`}
|
||||
onClick={() => handleCategoryClick(cat.key)}
|
||||
>
|
||||
<span className={styles.catItemIcon}>
|
||||
{CATEGORY_ICONS[cat.key] || "\u{1F4E6}"}
|
||||
</span>
|
||||
<span className={styles.catItemLabel}>{cat.label}</span>
|
||||
<span className={styles.catItemCount}>{cat.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className={styles.main} ref={gridRef}>
|
||||
{(search || sourceFilter !== "all" || categoryFilter !== "all") && (
|
||||
<div className={styles.filterSummary}>
|
||||
<span className={styles.filterCount}>
|
||||
{filtered.length} result{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{search && (
|
||||
<span className={styles.filterChip}>
|
||||
“{search}”
|
||||
<button onClick={() => setSearch("")}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{sourceFilter !== "all" && (
|
||||
<span className={styles.filterChip}>
|
||||
{SOURCE_CONFIG[sourceFilter]?.label || sourceFilter}
|
||||
<button onClick={() => setSourceFilter("all")}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{categoryFilter !== "all" && (
|
||||
<span className={styles.filterChip}>
|
||||
{categoryEntries.find((c) => c.key === categoryFilter)?.label ||
|
||||
categoryFilter}
|
||||
<button onClick={() => setCategoryFilter("all")}>×</button>
|
||||
</span>
|
||||
)}
|
||||
<button className={styles.clearAllBtn} onClick={clearAll}>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data && !loadError ? (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.loadingSpinner} />
|
||||
<h3 className={styles.emptyTitle}>Loading the catalog…</h3>
|
||||
<p className={styles.emptyDesc}>
|
||||
Fetching 88k+ skills across every registry. One moment.
|
||||
</p>
|
||||
</div>
|
||||
) : visible.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{visible.map((skill, i) => {
|
||||
const key = `${skill.source}-${skill.name}-${i}`;
|
||||
return (
|
||||
<SkillCard
|
||||
key={key}
|
||||
skill={skill}
|
||||
query={search}
|
||||
expanded={expandedCard === key}
|
||||
onToggle={() =>
|
||||
setExpandedCard(expandedCard === key ? null : key)
|
||||
}
|
||||
onCategoryClick={handleCategoryClick}
|
||||
onTagClick={handleTagClick}
|
||||
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
|
||||
onPick={pickerMode ? pickSkill : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className={styles.loadMoreWrap}>
|
||||
<button
|
||||
className={styles.loadMoreBtn}
|
||||
onClick={() => setVisibleCount((v) => v + PAGE_SIZE)}
|
||||
>
|
||||
Show more ({filtered.length - visibleCount} remaining)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>{"\u{1F50D}"}</div>
|
||||
<h3 className={styles.emptyTitle}>No skills found</h3>
|
||||
<p className={styles.emptyDesc}>
|
||||
Try a different search term or clear your filters.
|
||||
</p>
|
||||
<button className={styles.emptyReset} onClick={clearAll}>
|
||||
Reset all filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className={styles.backdrop} onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user