Merge pull request #150 from zinzied/main - Enhanced User Experience
Features integrated: - Stars Feature: Community-driven skill discovery with upvotes - Auto-Update: Automatic skill updates via START_APP.bat (Git + PowerShell fallback) - Interactive Prompt Builder: Context-aware prompt construction - Date Tracking: Added date_added field to all skills - Auto-Categorization: Smart category assignment based on keywords - Enhanced UI: Risk level badges, date display, category stats Conflicts resolved: - START_APP.bat: Merged enhanced auto-update logic - README.md: Kept v6.4.1 with new feature documentation - Home.jsx: Combined fuzzy search + pagination + stars - SkillDetail.jsx: Merged syntax highlighting + stars + date badges All 950+ skills updated with date tracking and proper categorization. Made-with: Cursor
This commit is contained in:
9
web-app/src/lib/supabase.js
Normal file
9
web-app/src/lib/supabase.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://gczhgcbtjbvfrgfmpbmv.supabase.co';
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || 'sb_publishable_CyVwHGbtT80AuDFmXNkc9Q_YNcamTGg';
|
||||
|
||||
// Create a single supabase client for interacting with your database
|
||||
export const supabase = supabaseUrl && supabaseAnonKey
|
||||
? createClient(supabaseUrl, supabaseAnonKey)
|
||||
: null
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Filter, Book, AlertCircle, ArrowRight } from 'lucide-react';
|
||||
import { Search, Filter, Book, AlertCircle, ArrowRight, Star } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function Home() {
|
||||
const [skills, setSkills] = useState([]);
|
||||
@@ -10,23 +10,77 @@ export function Home() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [displayCount, setDisplayCount] = useState(24); // Show 24 initially (6 rows of 4)
|
||||
const [displayCount, setDisplayCount] = useState(24);
|
||||
const [stars, setStars] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/skills.json')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const fetchSkillsAndStars = async () => {
|
||||
try {
|
||||
const res = await fetch('/skills.json');
|
||||
const data = await res.json();
|
||||
|
||||
setSkills(data);
|
||||
setFilteredSkills(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
if (supabase) {
|
||||
const { data: starData, error } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('skill_id, star_count');
|
||||
|
||||
if (!error && starData) {
|
||||
const starMap = {};
|
||||
starData.forEach(item => {
|
||||
starMap[item.skill_id] = item.star_count;
|
||||
});
|
||||
setStars(starMap);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load skills", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchSkillsAndStars();
|
||||
}, []);
|
||||
|
||||
// Fuzzy search with scoring
|
||||
const handleStarClick = async (e, skillId) => {
|
||||
e.preventDefault();
|
||||
|
||||
const storedStars = JSON.parse(localStorage.getItem('user_stars') || '{}');
|
||||
if (storedStars[skillId]) return;
|
||||
|
||||
setStars(prev => ({
|
||||
...prev,
|
||||
[skillId]: (prev[skillId] || 0) + 1
|
||||
}));
|
||||
|
||||
localStorage.setItem('user_stars', JSON.stringify({
|
||||
...storedStars,
|
||||
[skillId]: true
|
||||
}));
|
||||
|
||||
if (supabase) {
|
||||
const { data } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', skillId)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
await supabase
|
||||
.from('skill_stars')
|
||||
.update({ star_count: data.star_count + 1 })
|
||||
.eq('skill_id', skillId);
|
||||
} else {
|
||||
await supabase
|
||||
.from('skill_stars')
|
||||
.insert({ skill_id: skillId, star_count: 1 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const calculateScore = (skill, terms) => {
|
||||
let score = 0;
|
||||
const nameLower = skill.name.toLowerCase();
|
||||
@@ -34,15 +88,10 @@ export function Home() {
|
||||
const catLower = (skill.category || '').toLowerCase();
|
||||
|
||||
for (const term of terms) {
|
||||
// Exact name match (highest priority)
|
||||
if (nameLower === term) score += 100;
|
||||
// Name starts with term
|
||||
else if (nameLower.startsWith(term)) score += 50;
|
||||
// Name contains term
|
||||
else if (nameLower.includes(term)) score += 30;
|
||||
// Category match
|
||||
else if (catLower.includes(term)) score += 20;
|
||||
// Description contains term
|
||||
else if (descLower.includes(term)) score += 10;
|
||||
}
|
||||
return score;
|
||||
@@ -68,12 +117,20 @@ export function Home() {
|
||||
setFilteredSkills(result);
|
||||
}, [search, categoryFilter, skills]);
|
||||
|
||||
// Reset display count when search/filter changes
|
||||
useEffect(() => {
|
||||
setDisplayCount(24);
|
||||
}, [search, categoryFilter]);
|
||||
|
||||
const categories = ['all', ...new Set(skills.map(s => s.category).filter(Boolean))];
|
||||
const categoryStats = {};
|
||||
skills.forEach(skill => {
|
||||
categoryStats[skill.category] = (categoryStats[skill.category] || 0) + 1;
|
||||
});
|
||||
|
||||
const categories = ['all', ...Object.keys(categoryStats)
|
||||
.filter(cat => cat !== 'uncategorized')
|
||||
.sort((a, b) => categoryStats[b] - categoryStats[a]),
|
||||
...(categoryStats['uncategorized'] ? ['uncategorized'] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -81,7 +138,7 @@ export function Home() {
|
||||
<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">
|
||||
{search || categoryFilter !== 'all'
|
||||
{search || categoryFilter !== 'all'
|
||||
? `Showing ${filteredSkills.length} of ${skills.length} skills`
|
||||
: `Discover ${skills.length} agentic capabilities for your AI assistant.`}
|
||||
</p>
|
||||
@@ -116,7 +173,12 @@ export function Home() {
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>
|
||||
<option key={cat} value={cat}>
|
||||
{cat === 'all'
|
||||
? 'All Categories'
|
||||
: `${cat.charAt(0).toUpperCase() + cat.slice(1)} (${categoryStats[cat] || 0})`
|
||||
}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -158,6 +220,14 @@ export function Home() {
|
||||
{skill.category || 'Uncategorized'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleStarClick(e, skill.id)}
|
||||
className="flex items-center space-x-1 px-2 py-1 rounded-md bg-slate-50 dark:bg-slate-800/50 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 text-slate-500 hover:text-yellow-600 dark:hover:text-yellow-500 transition-colors border border-slate-200 dark:border-slate-800 z-10"
|
||||
title="Upvote skill"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${JSON.parse(localStorage.getItem('user_stars') || '{}')[skill.id] ? 'fill-yellow-400 stroke-yellow-400' : ''}`} />
|
||||
<span className="text-xs font-semibold">{stars[skill.id] || 0}</span>
|
||||
</button>
|
||||
</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">
|
||||
@@ -168,7 +238,14 @@ export function Home() {
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-sm font-medium text-indigo-600 dark:text-indigo-400 pt-4 mt-auto border-t border-slate-100 dark:border-slate-800 group-hover:translate-x-1 transition-transform">
|
||||
<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>
|
||||
@@ -176,8 +253,7 @@ export function Home() {
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Load More Button */}
|
||||
|
||||
{!loading && filteredSkills.length > displayCount && (
|
||||
<div className="col-span-full flex justify-center py-8">
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Markdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle } from 'lucide-react';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle, Star } from 'lucide-react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
export function SkillDetail() {
|
||||
@@ -15,44 +15,83 @@ export function SkillDetail() {
|
||||
const [copiedFull, setCopiedFull] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [customContext, setCustomContext] = useState('');
|
||||
const [starCount, setStarCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Fetch index to get skill metadata and path
|
||||
fetch('/skills.json')
|
||||
.then(res => res.json())
|
||||
.then(skills => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const res = await fetch('/skills.json');
|
||||
const skills = await res.json();
|
||||
const foundSkill = skills.find(s => s.id === id);
|
||||
|
||||
if (foundSkill) {
|
||||
setSkill(foundSkill);
|
||||
// 2. Fetch the actual markdown content
|
||||
// The path in JSON is like "skills/category/name"
|
||||
// We mapped it to "/skills/..." in public folder
|
||||
// Remove "skills/" prefix if it exists in path to avoid double
|
||||
|
||||
if (supabase) {
|
||||
const { data } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setStarCount(data.star_count);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanPath = foundSkill.path.startsWith('skills/')
|
||||
? foundSkill.path.replace('skills/', '')
|
||||
: foundSkill.path;
|
||||
|
||||
fetch(`/skills/${cleanPath}/SKILL.md`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Skill file not found');
|
||||
return res.text();
|
||||
})
|
||||
.then(text => {
|
||||
setContent(text);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load skill content", err);
|
||||
setError("Could not load skill content. File might be missing.");
|
||||
setLoading(false);
|
||||
});
|
||||
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.");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load skill data", err);
|
||||
setError(err.message || "Could not load skill content.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
const handleStarClick = async () => {
|
||||
const storedStars = JSON.parse(localStorage.getItem('user_stars') || '{}');
|
||||
if (storedStars[id]) return;
|
||||
|
||||
setStarCount(prev => prev + 1);
|
||||
localStorage.setItem('user_stars', JSON.stringify({
|
||||
...storedStars,
|
||||
[id]: true
|
||||
}));
|
||||
|
||||
if (supabase) {
|
||||
const { data } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
await supabase
|
||||
.from('skill_stars')
|
||||
.update({ star_count: data.star_count + 1 })
|
||||
.eq('skill_id', id);
|
||||
} else {
|
||||
await supabase
|
||||
.from('skill_stars')
|
||||
.insert({ skill_id: id, star_count: 1 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
const basePrompt = `Use @${skill.name}`;
|
||||
const finalPrompt = customContext.trim()
|
||||
@@ -105,7 +144,7 @@ export function SkillDetail() {
|
||||
<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">
|
||||
<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>
|
||||
@@ -114,6 +153,18 @@ export function SkillDetail() {
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
onClick={handleStarClick}
|
||||
className="flex items-center space-x-1.5 px-3 py-1 bg-yellow-50 dark:bg-yellow-900/10 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 text-yellow-700 dark:text-yellow-500 rounded-full text-xs font-bold border border-yellow-200 dark:border-yellow-700/50 transition-colors"
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${JSON.parse(localStorage.getItem('user_stars') || '{}')[id] ? 'fill-yellow-500 stroke-yellow-500' : ''}`} />
|
||||
<span>{starCount} Upvotes</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-slate-900 dark:text-slate-50 tracking-tight">
|
||||
@{skill.name}
|
||||
|
||||
Reference in New Issue
Block a user