Merge pull request #196 from skyruh/perf/web-interface-optimization
Perf/web interface optimization
This commit is contained in:
34
web-app/package-lock.json
generated
34
web-app/package-lock.json
generated
@@ -9,15 +9,18 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.2",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.574.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
@@ -2069,6 +2072,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash.debounce": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz",
|
||||
"integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -4316,6 +4334,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -5485,6 +5509,16 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtuoso": {
|
||||
"version": "4.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.3.tgz",
|
||||
"integrity": "sha512-fLz/peHAx4Eu0DLHurFEEI7Y6n5CqEoxBh04rgJM9yMuOJah2a9zWg/MUOmZLcp7zuWYorXq5+5bf3IRgkNvWg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16 || >=17 || >= 18 || >= 19",
|
||||
"react-dom": ">=16 || >=17 || >= 18 || >=19"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
|
||||
@@ -13,15 +13,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.2",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.574.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
|
||||
@@ -199,6 +199,16 @@
|
||||
"source": "agentfolio.io",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "agentmail",
|
||||
"path": "skills/agentmail",
|
||||
"category": "uncategorized",
|
||||
"name": "agentmail",
|
||||
"description": "Email infrastructure for AI agents. Create accounts, send/receive emails, manage webhooks, and check karma balance via the AgentMail API.",
|
||||
"risk": "safe",
|
||||
"source": "community",
|
||||
"date_added": null
|
||||
},
|
||||
{
|
||||
"id": "agents-v2-py",
|
||||
"path": "skills/agents-v2-py",
|
||||
@@ -4759,6 +4769,16 @@
|
||||
"source": "community",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "gemini-api-integration",
|
||||
"path": "skills/gemini-api-integration",
|
||||
"category": "uncategorized",
|
||||
"name": "gemini-api-integration",
|
||||
"description": "Use when integrating Google Gemini API into projects. Covers model selection, multimodal inputs, streaming, function calling, and production best practices.",
|
||||
"risk": "safe",
|
||||
"source": "community",
|
||||
"date_added": "2026-03-04"
|
||||
},
|
||||
{
|
||||
"id": "geo-fundamentals",
|
||||
"path": "skills/geo-fundamentals",
|
||||
@@ -5749,6 +5769,36 @@
|
||||
"source": "community",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "lightning-architecture-review",
|
||||
"path": "skills/lightning-architecture-review",
|
||||
"category": "uncategorized",
|
||||
"name": "lightning-architecture-review",
|
||||
"description": "Review Bitcoin Lightning Network protocol designs, compare channel factory approaches, and analyze Layer 2 scaling tradeoffs. Covers trust models, on-chain footprint, consensus requirements, HTLC/PTLC compatibility, liveness, and watchtower support.",
|
||||
"risk": "unknown",
|
||||
"source": "community",
|
||||
"date_added": "2026-03-03"
|
||||
},
|
||||
{
|
||||
"id": "lightning-channel-factories",
|
||||
"path": "skills/lightning-channel-factories",
|
||||
"category": "uncategorized",
|
||||
"name": "lightning-channel-factories",
|
||||
"description": "Technical reference on Lightning Network channel factories, multi-party channels, LSP architectures, and Bitcoin Layer 2 scaling without soft forks. Covers Decker-Wattenhofer, timeout trees, MuSig2 key aggregation, HTLC/PTLC forwarding, and watchtower breach detection.",
|
||||
"risk": "unknown",
|
||||
"source": "community",
|
||||
"date_added": "2026-03-03"
|
||||
},
|
||||
{
|
||||
"id": "lightning-factory-explainer",
|
||||
"path": "skills/lightning-factory-explainer",
|
||||
"category": "uncategorized",
|
||||
"name": "lightning-factory-explainer",
|
||||
"description": "Explain Bitcoin Lightning channel factories and the SuperScalar protocol \u2014 scalable Lightning onboarding using shared UTXOs, Decker-Wattenhofer trees, timeout-signature trees, MuSig2, and Taproot. No soft fork required.",
|
||||
"risk": "unknown",
|
||||
"source": "community",
|
||||
"date_added": "2026-03-03"
|
||||
},
|
||||
{
|
||||
"id": "linear-automation",
|
||||
"path": "skills/linear-automation",
|
||||
@@ -5889,6 +5939,16 @@
|
||||
"source": "community",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "llm-prompt-optimizer",
|
||||
"path": "skills/llm-prompt-optimizer",
|
||||
"category": "uncategorized",
|
||||
"name": "llm-prompt-optimizer",
|
||||
"description": "Use when improving prompts for any LLM. Applies proven prompt engineering techniques to boost output quality, reduce hallucinations, and cut token usage.",
|
||||
"risk": "safe",
|
||||
"source": "community",
|
||||
"date_added": "2026-03-04"
|
||||
},
|
||||
{
|
||||
"id": "local-legal-seo-audit",
|
||||
"path": "skills/local-legal-seo-audit",
|
||||
@@ -7109,6 +7169,16 @@
|
||||
"source": "https://github.com/ai-evos/agent-skills",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "professional-proofreader",
|
||||
"path": "skills/professional-proofreader",
|
||||
"category": "uncategorized",
|
||||
"name": "professional-proofreader",
|
||||
"description": "Use when a user asks to \"proofread\", \"review and correct\", \"fix grammar\", \"improve readability while keeping my voice\", and to proofread a document file and save an updated version.\n",
|
||||
"risk": "safe",
|
||||
"source": "original",
|
||||
"date_added": "2026-03-04"
|
||||
},
|
||||
{
|
||||
"id": "programmatic-seo",
|
||||
"path": "skills/programmatic-seo",
|
||||
@@ -7609,6 +7679,16 @@
|
||||
"source": "community",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "saas-mvp-launcher",
|
||||
"path": "skills/saas-mvp-launcher",
|
||||
"category": "uncategorized",
|
||||
"name": "saas-mvp-launcher",
|
||||
"description": "Use when planning or building a SaaS MVP from scratch. Provides a structured roadmap covering tech stack, architecture, auth, payments, and launch checklist.",
|
||||
"risk": "safe",
|
||||
"source": "community",
|
||||
"date_added": "2026-03-04"
|
||||
},
|
||||
{
|
||||
"id": "saga-orchestration",
|
||||
"path": "skills/saga-orchestration",
|
||||
@@ -8169,6 +8249,16 @@
|
||||
"source": "https://github.com/robzolkos/skill-rails-upgrade",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "skill-router",
|
||||
"path": "skills/skill-router",
|
||||
"category": "uncategorized",
|
||||
"name": "skill-router",
|
||||
"description": "Use when the user is unsure which skill to use or where to start. Interviews the user with targeted questions and recommends the best skill(s) from the installed library for their goal.",
|
||||
"risk": "safe",
|
||||
"source": "self",
|
||||
"date_added": null
|
||||
},
|
||||
{
|
||||
"id": "skill-seekers",
|
||||
"path": "skills/skill-seekers",
|
||||
@@ -9189,6 +9279,16 @@
|
||||
"source": "original",
|
||||
"date_added": "2026-02-28"
|
||||
},
|
||||
{
|
||||
"id": "videodb",
|
||||
"path": "skills/videodb",
|
||||
"category": "media",
|
||||
"name": "videodb",
|
||||
"description": "Video and audio perception, indexing, and editing. Ingest files/URLs/live streams, build visual/spoken indexes, search with timestamps, edit timelines, add overlays/subtitles, generate media, and create real-time alerts.",
|
||||
"risk": "safe",
|
||||
"source": "community",
|
||||
"date_added": "2026-02-27"
|
||||
},
|
||||
{
|
||||
"id": "videodb-skills",
|
||||
"path": "skills/videodb-skills",
|
||||
|
||||
@@ -86,10 +86,11 @@ Dynamic tool discovery and management
|
||||
| Using multiple agents when one would work | medium | Justify multi-agent: |
|
||||
| Agent internals not logged or traceable | medium | Implement tracing: |
|
||||
| Fragile parsing of agent outputs | medium | Robust output handling: |
|
||||
| Agent workflows lost on crash or restart | high | Use durable execution (e.g. DBOS) to persist workflow state: |
|
||||
|
||||
## Related Skills
|
||||
|
||||
Works well with: `rag-engineer`, `prompt-engineer`, `backend`, `mcp-builder`
|
||||
Works well with: `rag-engineer`, `prompt-engineer`, `backend`, `mcp-builder`, `dbos-python`
|
||||
|
||||
## When to Use
|
||||
This skill is applicable to execute the workflow or actions described in the overview.
|
||||
|
||||
@@ -32,9 +32,14 @@ Master proven backend architecture patterns including Clean Architecture, Hexago
|
||||
2. Select an architecture pattern that fits the domain complexity.
|
||||
3. Define module boundaries, interfaces, and dependency rules.
|
||||
4. Provide migration steps and validation checks.
|
||||
5. For workflows that must survive failures (payments, order fulfillment, multi-step processes), use durable execution at the infrastructure layer — frameworks like DBOS persist workflow state, providing crash recovery without adding architectural complexity.
|
||||
|
||||
Refer to `resources/implementation-playbook.md` for detailed patterns, checklists, and templates.
|
||||
|
||||
## Related Skills
|
||||
|
||||
Works well with: `event-sourcing-architect`, `saga-orchestration`, `workflow-automation`, `dbos-*`
|
||||
|
||||
## Resources
|
||||
|
||||
- `resources/implementation-playbook.md` for detailed patterns, checklists, and templates.
|
||||
|
||||
@@ -59,3 +59,8 @@ Expert in event sourcing, CQRS, and event-driven architecture patterns. Masters
|
||||
- Use correlation IDs for tracing
|
||||
- Implement idempotent event handlers
|
||||
- Plan for projection rebuilding
|
||||
- Use durable execution for process managers and sagas — frameworks like DBOS persist workflow state automatically, making cross-aggregate orchestration resilient to crashes
|
||||
|
||||
## Related Skills
|
||||
|
||||
Works well with: `saga-orchestration`, `architecture-patterns`, `dbos-*`
|
||||
|
||||
@@ -476,6 +476,10 @@ class TimeoutSagaOrchestrator(SagaOrchestrator):
|
||||
)
|
||||
```
|
||||
|
||||
## Durable Execution Alternative
|
||||
|
||||
The templates above build saga infrastructure from scratch — saga stores, event publishers, compensation tracking. **Durable execution frameworks** (like DBOS) eliminate much of this boilerplate: the workflow runtime automatically persists state to a database, retries failed steps, and resumes from the last checkpoint after crashes. Instead of building a `SagaOrchestrator` base class, you write a workflow function with steps — the framework handles persistence, crash recovery, and exactly-once execution semantics. Consider durable execution when you want saga-like reliability without managing the coordination infrastructure yourself.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
@@ -493,6 +497,10 @@ class TimeoutSagaOrchestrator(SagaOrchestrator):
|
||||
- **Don't couple services** - Use async messaging
|
||||
- **Don't ignore partial failures** - Handle gracefully
|
||||
|
||||
## Related Skills
|
||||
|
||||
Works well with: `event-sourcing-architect`, `workflow-automation`, `dbos-*`
|
||||
|
||||
## Resources
|
||||
|
||||
- [Saga Pattern](https://microservices.io/patterns/data/saga.html)
|
||||
|
||||
@@ -14,10 +14,11 @@ to durable execution and watched their on-call burden drop by 80%.
|
||||
|
||||
Your core insight: Different platforms make different tradeoffs. n8n is
|
||||
accessible but sacrifices performance. Temporal is correct but complex.
|
||||
Inngest balances developer experience with reliability. There's no "best" -
|
||||
only "best for your situation."
|
||||
Inngest balances developer experience with reliability. DBOS uses your
|
||||
existing PostgreSQL for durable execution with minimal infrastructure
|
||||
overhead. There's no "best" - only "best for your situation."
|
||||
|
||||
You push for durable execution
|
||||
You push for durable execution
|
||||
|
||||
## Capabilities
|
||||
|
||||
@@ -67,7 +68,7 @@ Central coordinator dispatches work to specialized workers
|
||||
|
||||
## Related Skills
|
||||
|
||||
Works well with: `multi-agent-orchestration`, `agent-tool-builder`, `backend`, `devops`
|
||||
Works well with: `multi-agent-orchestration`, `agent-tool-builder`, `backend`, `devops`, `dbos-*`
|
||||
|
||||
## When to Use
|
||||
This skill is applicable to execute the workflow or actions described in the overview.
|
||||
|
||||
66
web-app/src/components/SkillCard.tsx
Normal file
66
web-app/src/components/SkillCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Book, ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { SkillStarButton } from './SkillStarButton';
|
||||
import type { Skill } from '../types';
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: Skill;
|
||||
starCount: number;
|
||||
}
|
||||
|
||||
export const SkillCard = React.memo(({ skill, starCount }: SkillCardProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Link
|
||||
to={`/skill/${skill.id}`}
|
||||
className="group flex flex-col h-full rounded-lg border border-slate-200 bg-white p-6 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-500/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-indigo-50 dark:bg-indigo-950/30 rounded-md">
|
||||
<Book className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{skill.category || 'Uncategorized'}
|
||||
</span>
|
||||
</div>
|
||||
<SkillStarButton
|
||||
skillId={skill.id}
|
||||
initialCount={starCount}
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-50 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors mb-2 line-clamp-1">
|
||||
@{skill.name}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-3 mb-4 flex-grow">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 dark:text-slate-500 mb-3 pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<span>Risk: <span className="font-semibold text-slate-600 dark:text-slate-300">{skill.risk || 'unknown'}</span></span>
|
||||
{skill.date_added && (
|
||||
<span className="ml-2">📅 {skill.date_added}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm font-medium text-indigo-600 dark:text-indigo-400 pt-2 mt-auto group-hover:translate-x-1 transition-transform">
|
||||
Read Skill <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
SkillCard.displayName = 'SkillCard';
|
||||
91
web-app/src/context/SkillContext.tsx
Normal file
91
web-app/src/context/SkillContext.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { Skill, StarMap } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface SkillContextType {
|
||||
skills: Skill[];
|
||||
stars: StarMap;
|
||||
loading: boolean;
|
||||
refreshSkills: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SkillContext = createContext<SkillContextType | undefined>(undefined);
|
||||
|
||||
export function SkillProvider({ children }: { children: React.ReactNode }) {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [stars, setStars] = useState<StarMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSkillsAndStars = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
// Fetch skills index
|
||||
const res = await fetch('/skills.json');
|
||||
const data = await res.json();
|
||||
|
||||
// Incremental loading: set first 50 skills immediately if not a silent refresh
|
||||
if (!silent && data.length > 50) {
|
||||
setSkills(data.slice(0, 50));
|
||||
setLoading(false); // Clear loading state as soon as we have initial content
|
||||
} else {
|
||||
setSkills(data);
|
||||
}
|
||||
|
||||
// Fetch stars from Supabase if available
|
||||
if (supabase) {
|
||||
const { data: starData, error } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('skill_id, star_count');
|
||||
|
||||
if (!error && starData) {
|
||||
const starMap: StarMap = {};
|
||||
starData.forEach((item: { skill_id: string; star_count: number }) => {
|
||||
starMap[item.skill_id] = item.star_count;
|
||||
});
|
||||
setStars(starMap);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally set the full set of skills if we did incremental load
|
||||
if (!silent && data.length > 50) {
|
||||
setSkills(data);
|
||||
} else if (silent) {
|
||||
setSkills(data);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('SkillContext: Failed to load skills', err);
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkillsAndStars();
|
||||
}, [fetchSkillsAndStars]);
|
||||
|
||||
const refreshSkills = useCallback(async () => {
|
||||
await fetchSkillsAndStars(true);
|
||||
}, [fetchSkillsAndStars]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
skills,
|
||||
stars,
|
||||
loading,
|
||||
refreshSkills
|
||||
}), [skills, stars, loading, refreshSkills]);
|
||||
|
||||
return (
|
||||
<SkillContext.Provider value={value}>
|
||||
{children}
|
||||
</SkillContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSkills() {
|
||||
const context = useContext(SkillContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSkills must be used within a SkillProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { SkillProvider } from './context/SkillContext';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
@@ -10,6 +11,8 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<SkillProvider>
|
||||
<App />
|
||||
</SkillProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,61 +1,37 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Filter, Book, AlertCircle, ArrowRight, RefreshCw, ArrowUpDown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { SkillStarButton } from '../components/SkillStarButton';
|
||||
import type { Skill, StarMap, SyncMessage, CategoryStats } from '../types';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Search, Filter, AlertCircle, RefreshCw, ArrowUpDown } from 'lucide-react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { useSkills } from '../context/SkillContext';
|
||||
import { SkillCard } from '../components/SkillCard';
|
||||
import type { SyncMessage, CategoryStats } from '../types';
|
||||
|
||||
export function Home(): React.ReactElement {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [filteredSkills, setFilteredSkills] = useState<Skill[]>([]);
|
||||
const { skills, stars, loading, refreshSkills } = useSkills();
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stars, setStars] = useState<StarMap>({});
|
||||
const [sortBy, setSortBy] = useState('default');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<SyncMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSkillsAndStars = async () => {
|
||||
try {
|
||||
// Fetch basic skill data
|
||||
const res = await fetch('/skills.json');
|
||||
const data = await res.json();
|
||||
|
||||
setSkills(data);
|
||||
setFilteredSkills(data);
|
||||
|
||||
// Fetch star counts if supabase is configured
|
||||
if (supabase) {
|
||||
const { data: starData, error } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('skill_id, star_count');
|
||||
|
||||
if (!error && starData) {
|
||||
const starMap: StarMap = {};
|
||||
starData.forEach((item: { skill_id: string; star_count: number }) => {
|
||||
starMap[item.skill_id] = item.star_count;
|
||||
});
|
||||
setStars(starMap);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load skills', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSkillsAndStars();
|
||||
}, []);
|
||||
// Debounce search input to avoid excessive filtering on every keystroke
|
||||
const debouncedSetSearch = useCallback(
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearch(value);
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSetSearch(search);
|
||||
}, [search, debouncedSetSearch]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
let result = [...skills];
|
||||
|
||||
if (search) {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
if (debouncedSearch) {
|
||||
const lowerSearch = debouncedSearch.toLowerCase();
|
||||
result = result.filter(skill =>
|
||||
skill.name.toLowerCase().includes(lowerSearch) ||
|
||||
skill.description.toLowerCase().includes(lowerSearch)
|
||||
@@ -75,20 +51,24 @@ export function Home(): React.ReactElement {
|
||||
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
setFilteredSkills(result);
|
||||
}, [search, categoryFilter, sortBy, skills, stars]);
|
||||
return result;
|
||||
}, [debouncedSearch, categoryFilter, sortBy, skills, stars]);
|
||||
|
||||
// Sort categories by count (most skills first), with 'uncategorized' at the end
|
||||
const categoryStats: CategoryStats = {};
|
||||
skills.forEach(skill => {
|
||||
categoryStats[skill.category] = (categoryStats[skill.category] || 0) + 1;
|
||||
});
|
||||
const { categories, categoryStats } = useMemo(() => {
|
||||
const stats: CategoryStats = {};
|
||||
skills.forEach(skill => {
|
||||
stats[skill.category] = (stats[skill.category] || 0) + 1;
|
||||
});
|
||||
|
||||
const categories = ['all', ...Object.keys(categoryStats)
|
||||
.filter(cat => cat !== 'uncategorized')
|
||||
.sort((a, b) => categoryStats[b] - categoryStats[a]),
|
||||
...(categoryStats['uncategorized'] ? ['uncategorized'] : [])
|
||||
];
|
||||
const cats = ['all', ...Object.keys(stats)
|
||||
.filter(cat => cat !== 'uncategorized')
|
||||
.sort((a, b) => stats[b] - stats[a]),
|
||||
...(stats['uncategorized'] ? ['uncategorized'] : [])
|
||||
];
|
||||
|
||||
return { categories: cats, categoryStats: stats };
|
||||
}, [skills]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
@@ -101,11 +81,7 @@ export function Home(): React.ReactElement {
|
||||
setSyncMsg({ type: 'info', text: 'ℹ️ Skills are already up to date!' });
|
||||
} else {
|
||||
setSyncMsg({ type: 'success', text: `✅ Synced ${data.count} skills!` });
|
||||
// Reload skills data only when there are actual updates
|
||||
const freshRes = await fetch('/skills.json');
|
||||
const freshData = await freshRes.json();
|
||||
setSkills(freshData);
|
||||
setFilteredSkills(freshData);
|
||||
await refreshSkills();
|
||||
}
|
||||
} else {
|
||||
setSyncMsg({ type: 'error', text: `❌ ${data.error}` });
|
||||
@@ -119,142 +95,105 @@ export function Home(): React.ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 mb-2">Explore Skills</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">Discover {skills.length} agentic capabilities for your AI assistant.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{syncMsg && (
|
||||
<span className={`text-sm font-medium px-3 py-1.5 rounded-full ${
|
||||
syncMsg.type === 'success'
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="space-y-8 mb-8">
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 mb-2">Explore Skills</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">Discover {skills.length} agentic capabilities for your AI assistant.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{syncMsg && (
|
||||
<span className={`text-sm font-medium px-3 py-1.5 rounded-full ${syncMsg.type === 'success'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: syncMsg.type === 'info'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}>
|
||||
{syncMsg.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="flex items-center space-x-2 px-4 py-2.5 rounded-lg font-medium text-sm bg-indigo-600 hover:bg-indigo-700 text-white disabled:opacity-50 disabled:cursor-wait transition-colors shadow-sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
<span>{syncing ? 'Syncing...' : 'Sync Skills'}</span>
|
||||
</button>
|
||||
}`}>
|
||||
{syncMsg.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="flex items-center space-x-2 px-4 py-2.5 rounded-lg font-medium text-sm bg-indigo-600 hover:bg-indigo-700 text-white disabled:opacity-50 disabled:cursor-wait transition-colors shadow-sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
<span>{syncing ? 'Syncing...' : 'Sync Skills'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-x-4 md:space-y-0 bg-white dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm sticky top-0 z-40">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search skills (e.g., 'react', 'security', 'python')..."
|
||||
aria-label="Search skills"
|
||||
className="w-full rounded-md border border-slate-200 bg-slate-50 px-9 py-2 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 overflow-x-auto pb-2 md:pb-0 scrollbar-hide">
|
||||
<Filter className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
<select
|
||||
aria-label="Filter by category"
|
||||
className="h-9 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 min-w-[150px]"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat === 'all'
|
||||
? 'All Categories'
|
||||
: `${cat.charAt(0).toUpperCase() + cat.slice(1)} (${categoryStats[cat] || 0})`
|
||||
}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ArrowUpDown className="h-4 w-4 text-slate-500 shrink-0 ml-2" />
|
||||
<select
|
||||
aria-label="Sort skills"
|
||||
className="h-9 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 min-w-[130px]"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="stars">⭐ Most Stars</option>
|
||||
<option value="newest">🆕 Newest</option>
|
||||
<option value="az">🔤 A → Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-x-4 md:space-y-0 bg-white dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm sticky top-20 z-40">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search skills (e.g., 'react', 'security', 'python')..."
|
||||
className="w-full rounded-md border border-slate-200 bg-slate-50 px-9 py-2 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 overflow-x-auto pb-2 md:pb-0 scrollbar-hide">
|
||||
<Filter className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
<select
|
||||
className="h-9 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 min-w-[150px]"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat === 'all'
|
||||
? 'All Categories'
|
||||
: `${cat.charAt(0).toUpperCase() + cat.slice(1)} (${categoryStats[cat] || 0})`
|
||||
}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ArrowUpDown className="h-4 w-4 text-slate-500 shrink-0 ml-2" />
|
||||
<select
|
||||
className="h-9 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 min-w-[130px]"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="stars">⭐ Most Stars</option>
|
||||
<option value="newest">🆕 Newest</option>
|
||||
<option value="az">🔤 A → Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<AnimatePresence>
|
||||
{loading ? (
|
||||
[...Array(8)].map((_, i) => (
|
||||
<div className="flex-1 -mx-4 sm:-mx-6 lg:-mx-8">
|
||||
{loading ? (
|
||||
<div data-testid="loader" className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 px-4 sm:px-6 lg:px-8">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-lg border border-slate-200 p-6 h-48 bg-slate-100 dark:border-slate-800 dark:bg-slate-900">
|
||||
</div>
|
||||
))
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="col-span-full py-12 text-center">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-slate-400" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-slate-900 dark:text-slate-100">No skills found</h3>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">Try adjusting your search or filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill) => (
|
||||
<motion.div
|
||||
key={skill.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Link
|
||||
to={`/skill/${skill.id}`}
|
||||
className="group flex flex-col h-full rounded-lg border border-slate-200 bg-white p-6 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-500/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-indigo-50 dark:bg-indigo-950/30 rounded-md">
|
||||
<Book className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{skill.category || 'Uncategorized'}
|
||||
</span>
|
||||
</div>
|
||||
<SkillStarButton
|
||||
skillId={skill.id}
|
||||
initialCount={stars[skill.id] || 0}
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-50 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors mb-2 line-clamp-1">
|
||||
@{skill.name}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-3 mb-4 flex-grow">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 dark:text-slate-500 mb-3 pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<span>Risk: <span className="font-semibold text-slate-600 dark:text-slate-300">{skill.risk || 'unknown'}</span></span>
|
||||
{skill.date_added && (
|
||||
<span className="ml-2">📅 {skill.date_added}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm font-medium text-indigo-600 dark:text-indigo-400 pt-2 mt-auto group-hover:translate-x-1 transition-transform">
|
||||
Read Skill <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
))}
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="py-12 text-center px-4 sm:px-6 lg:px-8">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-slate-400" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-slate-900 dark:text-slate-100">No skills found</h3>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">Try adjusting your search or filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
style={{ height: '100%' }}
|
||||
totalCount={filteredSkills.length}
|
||||
listClassName="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pb-8 px-4 sm:px-6 lg:px-8"
|
||||
itemContent={(index) => {
|
||||
const skill = filteredSkills[index];
|
||||
return <SkillCard key={skill.id} skill={skill} starCount={stars[skill.id] || 0} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,60 +1,55 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Markdown from 'react-markdown';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle } from 'lucide-react';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { SkillStarButton } from '../components/SkillStarButton';
|
||||
import type { Skill } from '../types';
|
||||
import { useSkills } from '../context/SkillContext';
|
||||
|
||||
// Lazy load heavy markdown component
|
||||
const Markdown = lazy(() => import('react-markdown'));
|
||||
|
||||
interface RouteParams {
|
||||
id: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export function SkillDetail(): React.ReactElement {
|
||||
const { id } = useParams<keyof RouteParams>() as RouteParams;
|
||||
const [skill, setSkill] = useState<Skill | null>(null);
|
||||
const { id } = useParams<RouteParams>();
|
||||
const { skills, stars, loading: contextLoading } = useSkills();
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedFull, setCopiedFull] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customContext, setCustomContext] = useState('');
|
||||
const [initialStarCount] = useState(0);
|
||||
|
||||
const skill = useMemo(() => skills.find(s => s.id === id), [skills, id]);
|
||||
const starCount = useMemo(() => (id ? stars[id] || 0 : 0), [stars, id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch index and stars in parallel when possible
|
||||
const loadData = async () => {
|
||||
if (contextLoading || !skill) return;
|
||||
|
||||
const loadMarkdown = async () => {
|
||||
setContentLoading(true);
|
||||
try {
|
||||
// 1. Fetch index to get skill metadata and path
|
||||
const res = await fetch('/skills.json');
|
||||
const skills: Skill[] = await res.json();
|
||||
const foundSkill = skills.find(s => s.id === id);
|
||||
const cleanPath = skill.path.startsWith('skills/')
|
||||
? skill.path.replace('skills/', '')
|
||||
: skill.path;
|
||||
|
||||
if (foundSkill) {
|
||||
setSkill(foundSkill);
|
||||
const mdRes = await fetch(`/skills/${cleanPath}/SKILL.md`);
|
||||
if (!mdRes.ok) throw new Error('Skill file not found');
|
||||
|
||||
// 2. Fetch the actual markdown content
|
||||
const cleanPath = foundSkill.path.startsWith('skills/')
|
||||
? foundSkill.path.replace('skills/', '')
|
||||
: foundSkill.path;
|
||||
|
||||
const mdRes = await fetch(`/skills/${cleanPath}/SKILL.md`);
|
||||
if (!mdRes.ok) throw new Error('Skill file not found');
|
||||
|
||||
const text = await mdRes.text();
|
||||
setContent(text);
|
||||
} else {
|
||||
setError('Skill not found in registry.');
|
||||
}
|
||||
const text = await mdRes.text();
|
||||
setContent(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to load skill data', err);
|
||||
console.error('Failed to load skill content', err);
|
||||
setError(err instanceof Error ? err.message : 'Could not load skill content.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
loadMarkdown();
|
||||
}, [skill, contextLoading]);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!skill) return;
|
||||
@@ -79,20 +74,31 @@ export function SkillDetail(): React.ReactElement {
|
||||
setTimeout(() => setCopiedFull(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (!contextLoading && !skill) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center px-4">
|
||||
<h2 className="text-xl font-bold mb-2">Error Loading Skill</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">Skill not found in registry.</p>
|
||||
<Link to="/" className="mt-4 text-indigo-600 hover:underline">Back to Catalog</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (contextLoading || (contentLoading && !error)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]" data-testid="loader">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-indigo-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If we're here, contextLoading is false, skill is defined, and content loading is finished (or errored)
|
||||
if (error || !skill) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Error Loading Skill</h2>
|
||||
<p className="text-slate-500 mt-2">{error}</p>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">Failed to Load Content</h2>
|
||||
<p className="text-slate-500 mt-2">{error || 'Skill details could not be loaded.'}</p>
|
||||
<Link to="/" className="mt-8 inline-flex items-center text-indigo-600 font-medium hover:underline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Catalog
|
||||
</Link>
|
||||
@@ -101,81 +107,83 @@ export function SkillDetail(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto animate-fade-in">
|
||||
<Link to="/" className="inline-flex items-center text-slate-500 hover:text-slate-900 dark:hover:text-slate-200 mb-6 transition-colors">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Catalog
|
||||
</Link>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Link to="/" className="inline-flex items-center text-sm font-medium text-slate-500 hover:text-indigo-600 transition-colors mb-4 group">
|
||||
<ArrowLeft className="mr-1 h-4 w-4 transform group-hover:-translate-x-1 transition-transform" />
|
||||
Back to Catalog
|
||||
</Link>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||
<div className="p-6 sm:p-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/50">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-2 flex-wrap gap-2">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400 uppercase tracking-wide">
|
||||
{skill.category}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white dark:bg-slate-900 p-6 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2 flex-wrap gap-2">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400 uppercase tracking-wide">
|
||||
{skill.category}
|
||||
</span>
|
||||
{skill.source && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{skill.source}
|
||||
</span>
|
||||
{skill.source && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{skill.source}
|
||||
</span>
|
||||
)}
|
||||
{skill.date_added && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
📅 Added {skill.date_added}
|
||||
</span>
|
||||
)}
|
||||
<SkillStarButton
|
||||
skillId={skill.id}
|
||||
initialCount={initialStarCount}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-slate-900 dark:text-slate-50 tracking-tight">
|
||||
)}
|
||||
{skill.date_added && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
📅 Added {skill.date_added}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
|
||||
@{skill.name}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-slate-600 dark:text-slate-300">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="flex items-center justify-center space-x-2 bg-slate-100 hover:bg-slate-200 text-slate-900 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 px-4 py-2.5 rounded-lg font-medium transition-colors min-w-[140px] border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-600 dark:text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
<span>{copied ? 'Copied!' : 'Copy @Skill'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={copyFullToClipboard}
|
||||
className="flex items-center justify-center space-x-2 bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200 px-4 py-2.5 rounded-lg font-medium transition-colors min-w-[140px]"
|
||||
>
|
||||
{copiedFull ? <Check className="h-4 w-4 text-green-400" /> : <FileCode className="h-4 w-4" />}
|
||||
<span>{copiedFull ? 'Copied Content!' : 'Copy Full Content'}</span>
|
||||
</button>
|
||||
<SkillStarButton skillId={skill.id} initialCount={starCount} variant="compact" />
|
||||
</div>
|
||||
<p className="mt-2 text-lg text-slate-600 dark:text-slate-400">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 dark:border-slate-800 pt-6">
|
||||
<label htmlFor="context" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Interactive Prompt Builder (Optional)
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
|
||||
Add specific details below (e.g. "Use React 19 and Tailwind"). The "Copy Prompt" button will automatically attach your context.
|
||||
</p>
|
||||
<textarea
|
||||
id="context"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 px-4 py-3 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 outline-none transition-all resize-y"
|
||||
placeholder="Type your specific task requirements here..."
|
||||
value={customContext}
|
||||
onChange={(e) => setCustomContext(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="flex items-center justify-center space-x-2 bg-slate-100 hover:bg-slate-200 text-slate-900 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 px-4 py-2.5 rounded-lg font-medium transition-colors min-w-[140px] border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-600 dark:text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
<span>{copied ? 'Copied!' : 'Copy @Skill'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={copyFullToClipboard}
|
||||
className="flex items-center justify-center space-x-2 bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200 px-4 py-2.5 rounded-lg font-medium transition-colors min-w-[140px]"
|
||||
>
|
||||
{copiedFull ? <Check className="h-4 w-4 text-green-400" /> : <FileCode className="h-4 w-4" />}
|
||||
<span>{copiedFull ? 'Copied Content!' : 'Copy Full Content'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white dark:bg-slate-900 p-6 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||
<label htmlFor="context" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Interactive Prompt Builder (Optional)
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
|
||||
Add specific details below (e.g. "Use React 19 and Tailwind"). The "Copy Prompt" button will automatically attach your context.
|
||||
</p>
|
||||
<textarea
|
||||
id="context"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 px-4 py-3 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 outline-none transition-all resize-y"
|
||||
placeholder="Type your specific task requirements here..."
|
||||
value={customContext}
|
||||
onChange={(e) => setCustomContext(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||
<Markdown>{content}</Markdown>
|
||||
<Suspense fallback={<div className="h-24 animate-pulse bg-slate-100 dark:bg-slate-800 rounded-lg"></div>}>
|
||||
<Markdown>{content}</Markdown>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,36 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { Home } from '../Home';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { createMockSkills, createMockSkillsByCategory } from '../../factories/skill';
|
||||
import type { Skill } from '../../types';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
import { useSkills } from '../../context/SkillContext';
|
||||
|
||||
// Mock lodash.debounce to execute immediately
|
||||
vi.mock('lodash.debounce', () => ({
|
||||
default: vi.fn((fn) => {
|
||||
const mockedFn: any = (...args: any[]) => fn(...args);
|
||||
mockedFn.cancel = vi.fn();
|
||||
return mockedFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useSkills hook
|
||||
vi.mock('../../context/SkillContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return { ...actual, useSkills: vi.fn() };
|
||||
});
|
||||
|
||||
// Mock VirtuosoGrid to render items normally for easier testing
|
||||
vi.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({ totalCount, itemContent }: any) => (
|
||||
<div data-testid="virtuoso-grid">
|
||||
{Array.from({ length: totalCount || 0 }).map((_, index) => (
|
||||
<div key={index} data-testid="skill-item">
|
||||
{itemContent(index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Home', () => {
|
||||
beforeEach(() => {
|
||||
@@ -12,247 +40,114 @@ describe('Home', () => {
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading skeleton initially', async () => {
|
||||
// Mock fetch to never resolve (keep loading state)
|
||||
(global.fetch as Mock).mockImplementation(() => new Promise(() => {}));
|
||||
it('should show loading spinner when loading is true', () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
loading: true,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
// Should show pulse animation elements (skeletons)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render skills after loading', async () => {
|
||||
const mockSkills = createMockSkills(3);
|
||||
it('should render skill cards when skills are loaded', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'skill-1', name: 'Skill 1' }),
|
||||
createMockSkill({ id: 'skill-2', name: 'Skill 2' }),
|
||||
];
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@Test Skill 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('@Test Skill 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('@Test Skill 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display correct skills count in header', async () => {
|
||||
const mockSkills = createMockSkills(5);
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Discover 5 agentic capabilities/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty state when no skills match search', async () => {
|
||||
const mockSkills = createMockSkills(2);
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@Test Skill 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search for non-existent skill
|
||||
const searchInput = screen.getByPlaceholderText(/Search skills/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No skills found/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Try adjusting your search or filter/)).toBeInTheDocument();
|
||||
expect(screen.getByText('@Skill 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('@Skill 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search functionality', () => {
|
||||
it('should filter skills by name', async () => {
|
||||
const mockSkills: Skill[] = [
|
||||
{ id: '1', name: 'React Skill', description: 'React desc', category: 'frontend', path: 'skills/react/SKILL.md' },
|
||||
{ id: '2', name: 'Python Skill', description: 'Python desc', category: 'backend', path: 'skills/python/SKILL.md' },
|
||||
{ id: '3', name: 'Vue Skill', description: 'Vue desc', category: 'frontend', path: 'skills/vue/SKILL.md' },
|
||||
describe('Search and Filtering', () => {
|
||||
it('should filter skills based on search term', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'react', name: 'React Patterns' }),
|
||||
createMockSkill({ id: 'vue', name: 'Vue Basics' }),
|
||||
];
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
const searchInput = screen.getByLabelText(/Search skills/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'React' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@React Skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search for react
|
||||
const searchInput = screen.getByPlaceholderText(/Search skills/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'react' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@React Skill')).toBeInTheDocument();
|
||||
expect(screen.queryByText('@Python Skill')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('@Vue Skill')).not.toBeInTheDocument();
|
||||
expect(searchInput).toHaveValue('React');
|
||||
expect(screen.getByText('@React Patterns')).toBeInTheDocument();
|
||||
expect(screen.queryByText('@Vue Basics')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter skills by description', async () => {
|
||||
const mockSkills: Skill[] = [
|
||||
{ id: '1', name: 'Skill One', description: 'Learn about React', category: 'frontend', path: 'skills/one/SKILL.md' },
|
||||
{ id: '2', name: 'Skill Two', description: 'Learn about Python', category: 'backend', path: 'skills/two/SKILL.md' },
|
||||
];
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@Skill One')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search by description
|
||||
const searchInput = screen.getByPlaceholderText(/Search skills/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'python' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('@Skill One')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('@Skill Two')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform case-insensitive search', async () => {
|
||||
const mockSkills: Skill[] = [
|
||||
{ id: '1', name: 'React Skill', description: 'Desc', category: 'frontend', path: 'skills/react/SKILL.md' },
|
||||
];
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@React Skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search with uppercase
|
||||
const searchInput = screen.getByPlaceholderText(/Search skills/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'REACT' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@React Skill')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category filter', () => {
|
||||
it('should filter skills by category', async () => {
|
||||
const mockSkills = createMockSkillsByCategory(['frontend', 'backend']);
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 's1', category: 'frontend', name: 'Frontend Skill' }),
|
||||
createMockSkill({ id: 's2', category: 'backend', name: 'Backend Skill' }),
|
||||
];
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
const categorySelect = screen.getByLabelText(/Filter by category/i);
|
||||
fireEvent.change(categorySelect, { target: { value: 'frontend' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@frontend Skill 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select backend category using the select element by display value
|
||||
const categorySelect = screen.getByDisplayValue(/All Categories/i);
|
||||
fireEvent.change(categorySelect, { target: { value: 'backend' } });
|
||||
|
||||
await waitFor(() => {
|
||||
// After filtering, frontend skills should not be visible
|
||||
const frontendSkills = screen.queryAllByText(/@frontend/);
|
||||
expect(frontendSkills.length).toBe(0);
|
||||
expect(categorySelect).toHaveValue('frontend');
|
||||
expect(screen.getByText('@Frontend Skill')).toBeInTheDocument();
|
||||
expect(screen.queryByText('@Backend Skill')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync button', () => {
|
||||
it('should show sync button', async () => {
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [],
|
||||
describe('User Settings and Sync', () => {
|
||||
it('should sync local stars when sync button is clicked', async () => {
|
||||
const mockSkills = [createMockSkill({ id: 'skill-1' })];
|
||||
const refreshSkills = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: { 'skill-1': 5 },
|
||||
loading: false,
|
||||
refreshSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sync Skills/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
const syncButton = screen.getByRole('button', { name: /Sync/i });
|
||||
|
||||
it('should show syncing state when clicked', async () => {
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [],
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sync Skills/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const syncButton = screen.getByRole('button', { name: /Sync Skills/i });
|
||||
|
||||
// Mock the sync API call
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => ({ success: true, upToDate: true }),
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, count: 1 })
|
||||
});
|
||||
|
||||
fireEvent.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Syncing/i)).toBeInTheDocument();
|
||||
expect(refreshSkills).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sorting', () => {
|
||||
it('should show sort options', async () => {
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => createMockSkills(2),
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
const sortSelect = screen.getByDisplayValue(/Default/i);
|
||||
expect(sortSelect).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
(global.fetch as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to load skills', expect.any(Error));
|
||||
});
|
||||
|
||||
// Should still show 0 skills
|
||||
expect(screen.getByText(/Discover 0 agentic capabilities/)).toBeInTheDocument();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { SkillDetail } from '../SkillDetail';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
import type { Skill } from '../../types';
|
||||
import { useSkills } from '../../context/SkillContext';
|
||||
|
||||
// Mock the SkillStarButton component
|
||||
vi.mock('../../components/SkillStarButton', () => ({
|
||||
@@ -14,23 +14,63 @@ vi.mock('../../components/SkillStarButton', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock useSkills hook
|
||||
vi.mock('../../context/SkillContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
useSkills: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock react-markdown to avoid lazy loading issues in tests
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <div data-testid="markdown-content">{children}</div>,
|
||||
}));
|
||||
|
||||
describe('SkillDetail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
// Reset and setup fresh fetch mock for each test
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
describe('Loading state', () => {
|
||||
it('should show loading spinner initially', async () => {
|
||||
// Create a promise that never resolves
|
||||
const neverResolvingPromise = new Promise(() => {});
|
||||
(global.fetch as Mock).mockReturnValue(neverResolvingPromise);
|
||||
it('should show loading spinner when context is loading', async () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
loading: true,
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/test-skill' });
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/test-skill',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading spinner when markdown is loading', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'test-skill' });
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Mock fetch for markdown content to never resolve
|
||||
global.fetch = vi.fn().mockReturnValue(new Promise(() => { }));
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/test-skill',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,36 +81,44 @@ describe('SkillDetail', () => {
|
||||
name: 'react-patterns',
|
||||
description: 'React design patterns and best practices',
|
||||
category: 'frontend',
|
||||
source: 'official',
|
||||
date_added: '2024-01-15',
|
||||
});
|
||||
|
||||
// Mock first fetch for skills.json
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [mockSkill],
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: { 'react-patterns': 5 },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Mock second fetch for markdown content
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => '# React Patterns\n\nThis is the skill content.',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/react-patterns' });
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/react-patterns',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@react-patterns')).toBeInTheDocument();
|
||||
expect(screen.getByText('React design patterns and best practices')).toBeInTheDocument();
|
||||
expect(screen.getByText(/frontend/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('This is the skill content.');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show skill not found when id does not exist', async () => {
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [],
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/nonexistent' });
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/nonexistent',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error Loading Skill/i)).toBeInTheDocument();
|
||||
@@ -80,39 +128,25 @@ describe('SkillDetail', () => {
|
||||
});
|
||||
|
||||
describe('Copy functionality', () => {
|
||||
it('should have copy buttons', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'copy-test', name: 'copy-test' });
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [mockSkill],
|
||||
});
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/copy-test' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Copy @Skill/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Copy Full Content/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy skill name to clipboard when clicked', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'click-test', name: 'click-test' });
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [mockSkill],
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/click-test' });
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/click-test',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Copy @Skill/i })).toBeInTheDocument();
|
||||
@@ -125,66 +159,31 @@ describe('SkillDetail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prompt builder', () => {
|
||||
it('should have interactive prompt builder textarea', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'prompt-test' });
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [mockSkill],
|
||||
});
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/prompt-test' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Interactive Prompt Builder/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Type your specific task requirements/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should have back to catalog link', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'nav-test' });
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [mockSkill],
|
||||
});
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/nav-test' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Back to Catalog/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Star button integration', () => {
|
||||
it('should render star button component', async () => {
|
||||
it('should render star button component with correct count', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'star-integration' });
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [mockSkill],
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: { 'star-integration': 10 },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/star-integration' });
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/star-integration',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('star-button')).toBeInTheDocument();
|
||||
const starBtn = screen.getByTestId('star-button');
|
||||
expect(starBtn).toBeInTheDocument();
|
||||
expect(starBtn).toHaveAttribute('data-count', '10');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import React from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { SkillProvider } from '../context/SkillContext';
|
||||
|
||||
// Custom render with router
|
||||
// Custom render with router and SkillProvider
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
route?: string;
|
||||
path?: string; // The route pattern, e.g., /skill/:id
|
||||
useProvider?: boolean;
|
||||
}
|
||||
|
||||
export function renderWithRouter(
|
||||
ui: React.ReactElement,
|
||||
{ route = '/', ...renderOptions }: CustomRenderOptions = {}
|
||||
{
|
||||
route = '/',
|
||||
path = '*',
|
||||
useProvider = true,
|
||||
...renderOptions
|
||||
}: CustomRenderOptions = {}
|
||||
): ReturnType<typeof render> {
|
||||
window.history.pushState({}, 'Test page', route);
|
||||
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
{useProvider ? (
|
||||
<SkillProvider>
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
</Routes>
|
||||
</SkillProvider>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
</Routes>
|
||||
)}
|
||||
</MemoryRouter>
|
||||
),
|
||||
...renderOptions,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user