diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 99186ab9..c6f2f956 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -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", diff --git a/web-app/package.json b/web-app/package.json index aaf0982e..2543dadd 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -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" }, diff --git a/web-app/public/skills.json b/web-app/public/skills.json index 9d71203a..a025d2ba 100644 --- a/web-app/public/skills.json +++ b/web-app/public/skills.json @@ -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", diff --git a/web-app/public/skills/ai-agents-architect/SKILL.md b/web-app/public/skills/ai-agents-architect/SKILL.md index ee7dbfba..939c7ecb 100644 --- a/web-app/public/skills/ai-agents-architect/SKILL.md +++ b/web-app/public/skills/ai-agents-architect/SKILL.md @@ -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. diff --git a/web-app/public/skills/architecture-patterns/SKILL.md b/web-app/public/skills/architecture-patterns/SKILL.md index 8841965b..e6c189df 100644 --- a/web-app/public/skills/architecture-patterns/SKILL.md +++ b/web-app/public/skills/architecture-patterns/SKILL.md @@ -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. diff --git a/web-app/public/skills/event-sourcing-architect/SKILL.md b/web-app/public/skills/event-sourcing-architect/SKILL.md index e6d60e10..ed8e81ab 100644 --- a/web-app/public/skills/event-sourcing-architect/SKILL.md +++ b/web-app/public/skills/event-sourcing-architect/SKILL.md @@ -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-*` diff --git a/web-app/public/skills/saga-orchestration/SKILL.md b/web-app/public/skills/saga-orchestration/SKILL.md index c37470d0..59dfd9e0 100644 --- a/web-app/public/skills/saga-orchestration/SKILL.md +++ b/web-app/public/skills/saga-orchestration/SKILL.md @@ -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) diff --git a/web-app/public/skills/workflow-automation/SKILL.md b/web-app/public/skills/workflow-automation/SKILL.md index 8232459d..5a9e0bed 100644 --- a/web-app/public/skills/workflow-automation/SKILL.md +++ b/web-app/public/skills/workflow-automation/SKILL.md @@ -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. diff --git a/web-app/src/components/SkillCard.tsx b/web-app/src/components/SkillCard.tsx new file mode 100644 index 00000000..fe7ea3cd --- /dev/null +++ b/web-app/src/components/SkillCard.tsx @@ -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 ( + + +
+
+
+ +
+ + {skill.category || 'Uncategorized'} + +
+ +
+ +

+ @{skill.name} +

+ +

+ {skill.description} +

+ +
+ Risk: {skill.risk || 'unknown'} + {skill.date_added && ( + 📅 {skill.date_added} + )} +
+ +
+ Read Skill +
+ +
+ ); +}); + +SkillCard.displayName = 'SkillCard'; diff --git a/web-app/src/context/SkillContext.tsx b/web-app/src/context/SkillContext.tsx new file mode 100644 index 00000000..bbde1fe3 --- /dev/null +++ b/web-app/src/context/SkillContext.tsx @@ -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; +} + +const SkillContext = createContext(undefined); + +export function SkillProvider({ children }: { children: React.ReactNode }) { + const [skills, setSkills] = useState([]); + const [stars, setStars] = useState({}); + 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 ( + + {children} + + ); +} + +export function useSkills() { + const context = useContext(SkillContext); + if (context === undefined) { + throw new Error('useSkills must be used within a SkillProvider'); + } + return context; +} diff --git a/web-app/src/main.tsx b/web-app/src/main.tsx index 6545cc9a..533d286c 100644 --- a/web-app/src/main.tsx +++ b/web-app/src/main.tsx @@ -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( - + + + , ); diff --git a/web-app/src/pages/Home.tsx b/web-app/src/pages/Home.tsx index b5263a50..13b712b7 100644 --- a/web-app/src/pages/Home.tsx +++ b/web-app/src/pages/Home.tsx @@ -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([]); - const [filteredSkills, setFilteredSkills] = useState([]); + 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({}); const [sortBy, setSortBy] = useState('default'); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(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 ( -
-
-
-

Explore Skills

-

Discover {skills.length} agentic capabilities for your AI assistant.

-
-
- {syncMsg && ( - +
+
+
+

Explore Skills

+

Discover {skills.length} agentic capabilities for your AI assistant.

+
+
+ {syncMsg && ( + - {syncMsg.text} - - )} - + }`}> + {syncMsg.text} + + )} + +
+
+ +
+
+ + setSearch(e.target.value)} + /> +
+
+ + + + +
-
-
- - setSearch(e.target.value)} - /> -
-
- - - - -
-
- -
- - {loading ? ( - [...Array(8)].map((_, i) => ( +
+ {loading ? ( +
+ {[...Array(8)].map((_, i) => (
- )) - ) : filteredSkills.length === 0 ? ( -
- -

No skills found

-

Try adjusting your search or filter.

-
- ) : ( - filteredSkills.map((skill) => ( - - -
-
-
- -
- - {skill.category || 'Uncategorized'} - -
- -
- -

- @{skill.name} -

- -

- {skill.description} -

- -
- Risk: {skill.risk || 'unknown'} - {skill.date_added && ( - 📅 {skill.date_added} - )} -
- -
- Read Skill -
- -
- )) - )} - + ))} +
+ ) : filteredSkills.length === 0 ? ( +
+ +

No skills found

+

Try adjusting your search or filter.

+
+ ) : ( + { + const skill = filteredSkills[index]; + return ; + }} + /> + )}
); diff --git a/web-app/src/pages/SkillDetail.tsx b/web-app/src/pages/SkillDetail.tsx index ce73c5dd..bb73ceb0 100644 --- a/web-app/src/pages/SkillDetail.tsx +++ b/web-app/src/pages/SkillDetail.tsx @@ -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() as RouteParams; - const [skill, setSkill] = useState(null); + const { id } = useParams(); + 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(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 ( -
-
+
+

Error Loading Skill

+

Skill not found in registry.

+ Back to Catalog
); } + if (contextLoading || (contentLoading && !error)) { + return ( +
+ +
+ ); + } + + // If we're here, contextLoading is false, skill is defined, and content loading is finished (or errored) if (error || !skill) { return (
-

Error Loading Skill

-

{error}

+

Failed to Load Content

+

{error || 'Skill details could not be loaded.'}

Back to Catalog @@ -101,81 +107,83 @@ export function SkillDetail(): React.ReactElement { } return ( -
- - Back to Catalog - +
+
+ + + Back to Catalog + -
-
-
-
-
- - {skill.category} +
+
+
+ + {skill.category} + + {skill.source && ( + + {skill.source} - {skill.source && ( - - {skill.source} - - )} - {skill.date_added && ( - - 📅 Added {skill.date_added} - - )} - -
-

+ )} + {skill.date_added && ( + + 📅 Added {skill.date_added} + + )} +

+
+

@{skill.name}

-

- {skill.description} -

-
-
- - +
+

+ {skill.description} +

-
- -

- Add specific details below (e.g. "Use React 19 and Tailwind"). The "Copy Prompt" button will automatically attach your context. -

-