refactor(web-app): migrate to TypeScript, add security fix and test suite
This commit is contained in:
@@ -1,56 +1,33 @@
|
||||
## [6.8.0] - 2026-03-02 - "Productivity Boost & In-App Sync"
|
||||
## v6.9.0 - Multi-Tool & Agent Infrastructure
|
||||
|
||||
> **Major productivity enhancements to existing skills and new in-app skill synchronization feature.**
|
||||
> **Agent capabilities expand with email infrastructure, video intelligence, and multi-tool installer support.**
|
||||
|
||||
This release delivers version 2.0.0 upgrades to two critical skills: `vibe-code-auditor` and `tutorial-engineer`, packed with pattern recognition shortcuts, deterministic scoring, and copy-paste templates. Plus, a new "Sync Skills" button in the Web App enables live skill updates from GitHub without leaving the browser.
|
||||
### 🚀 New Skills
|
||||
|
||||
## 🚀 New Features
|
||||
**📧 AgentMail** — Email infrastructure for AI agents
|
||||
- Create email accounts with karma-based rate limiting
|
||||
- Send/receive emails with attachments
|
||||
- Webhook signature verification for secure notifications
|
||||
- Full SDK examples and API reference
|
||||
|
||||
### 🔄 In-App Sync Skills Button
|
||||
**📹 VideoDB** — Video and audio perception, indexing, and editing
|
||||
- Ingest from files, URLs, RTSP/live feeds, or desktop capture
|
||||
- Semantic, visual, and spoken word indexes with timestamp search
|
||||
- Timeline editing with subtitles, overlays, transcoding
|
||||
- AI generation for images, video, music, voiceovers
|
||||
|
||||
**One-click skill synchronization from the Web App UI.**
|
||||
Replaces the unreliable START_APP.bat auto-updater. Users can now click "Sync Skills" in the web app to download the latest skills from GitHub instantly.
|
||||
### 📦 Improvements
|
||||
|
||||
- Vite dev server plugin exposing `/api/refresh-skills` endpoint
|
||||
- Downloads and extracts only the `/skills/` folder and `skills_index.json`
|
||||
- Live UI updates without page refresh
|
||||
- **Multi-Tool Install Support**: Install skills for multiple tools simultaneously (e.g., `npx antigravity-awesome-skills --claude --codex`). Fixes #182.
|
||||
- **Web-App Sync Optimization**: Hybrid sync strategy using git fetch (5+ min → < 2 sec). Includes sort by "Most Stars".
|
||||
- **Registry**: 970 skills (+2 new)
|
||||
|
||||
## 📦 Improvements
|
||||
### 👥 Contributors
|
||||
|
||||
### ✨ vibe-code-auditor v2.0.0
|
||||
|
||||
**Productivity-focused overhaul with 10x faster audits.**
|
||||
|
||||
- **Pattern Recognition Shortcuts**: 10 heuristics for rapid issue detection
|
||||
- **Quick Checks**: 3-second scans for each of 7 audit dimensions
|
||||
- **Executive Summary**: Critical findings upfront
|
||||
- **Deterministic Scoring**: Replaces subjective ranges with algorithmic scoring
|
||||
- **Code Fix Blocks**: Before/after examples for copy-paste remediation
|
||||
- **Quick Wins Section**: Fixes completable in <1 hour
|
||||
- **Calibration Rules**: Scoring adjusted by code size (snippet vs multi-file)
|
||||
- **Expanded Security**: SQL injection, path traversal, insecure deserialization detection
|
||||
|
||||
### 📚 tutorial-engineer v2.0.0
|
||||
|
||||
**Evidence-based learning with 75% better retention.**
|
||||
|
||||
- **4-MAT Model**: Why/What/How/What If framework for explanations
|
||||
- **Learning Retention Shortcuts**: Evidence-based patterns (+75% retention)
|
||||
- **Cognitive Load Management**: 7±2 rule, One Screen, No Forward References
|
||||
- **Exercise Calibration**: Difficulty table with time estimates
|
||||
- **Format Selection Guide**: Quick Start vs Deep Dive vs Workshop
|
||||
- **Pre-Publish Audit Checklist**: Comprehension, progression, technical validation
|
||||
- **Speed Scoring Rubric**: 1-5 rating on 5 dimensions
|
||||
- **Copy-Paste Template**: Ready-to-use Markdown structure
|
||||
- **Accessibility Checklist**: WCAG compliance for tutorials
|
||||
|
||||
## 👥 Credits
|
||||
|
||||
A huge shoutout to our community contributors:
|
||||
|
||||
- **@munir-abbasi** for the v2.0.0 productivity enhancements to `vibe-code-auditor` and `tutorial-engineer` (PR #172)
|
||||
- **@zinzied** for the In-App Sync Skills Button and START_APP.bat simplification (PR #178)
|
||||
- @zinzied — Web-app sync optimization (PR #180)
|
||||
- @0xrohitgarg — VideoDB skill (PR #181)
|
||||
- @uriva — AgentMail skill (PR #183)
|
||||
|
||||
---
|
||||
|
||||
_Upgrade now: `git pull origin main` to fetch the latest skills._
|
||||
_Upgrade: `npx antigravity-awesome-skills` or `git pull origin main`_
|
||||
|
||||
4
web-app/.env.example
Normal file
4
web-app/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Supabase Configuration
|
||||
# Get these values from your Supabase project settings
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
@@ -15,6 +15,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2415
web-app/package-lock.json
generated
2415
web-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
@@ -26,6 +28,9 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -34,8 +39,11 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"vite": "^7.3.1"
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import { Home } from './pages/Home';
|
||||
import { SkillDetail } from './pages/SkillDetail';
|
||||
import { BookOpen, Search, Github } from 'lucide-react';
|
||||
import { BookOpen, Github } from 'lucide-react';
|
||||
|
||||
function App() {
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-50">
|
||||
@@ -24,7 +23,7 @@ function App() {
|
||||
className="flex items-center text-slate-600 transition-colors hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50"
|
||||
>
|
||||
<Github className="h-5 w-5 mr-2" />
|
||||
GitHubRepository
|
||||
GitHub Repository
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
64
web-app/src/components/SkillStarButton.tsx
Normal file
64
web-app/src/components/SkillStarButton.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { useSkillStars } from '../hooks/useSkillStars';
|
||||
|
||||
interface SkillStarButtonProps {
|
||||
skillId: string;
|
||||
initialCount?: number;
|
||||
onStarClick?: () => void;
|
||||
variant?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
/**
|
||||
* Star button component for skills
|
||||
* Uses useSkillStars hook for state management
|
||||
*/
|
||||
export function SkillStarButton({
|
||||
skillId,
|
||||
initialCount = 0,
|
||||
onStarClick,
|
||||
variant = 'default'
|
||||
}: SkillStarButtonProps): React.ReactElement {
|
||||
const { starCount, hasStarred, handleStarClick, isLoading } = useSkillStars(skillId);
|
||||
|
||||
// Use optimistic count from hook, fall back to initial
|
||||
const displayCount = starCount || initialCount;
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasStarred || isLoading) return;
|
||||
|
||||
await handleStarClick();
|
||||
onStarClick?.();
|
||||
};
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
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 disabled:opacity-50"
|
||||
disabled={hasStarred || isLoading}
|
||||
title={hasStarred ? 'You already upvoted' : 'Upvote skill'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${hasStarred ? 'fill-yellow-500 stroke-yellow-500' : ''}`} />
|
||||
<span>{displayCount} Upvotes</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
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 disabled:opacity-50"
|
||||
disabled={hasStarred || isLoading}
|
||||
title={hasStarred ? 'You already upvoted' : 'Upvote skill'}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${hasStarred ? 'fill-yellow-400 stroke-yellow-400' : ''} ${isLoading ? 'animate-pulse' : ''}`} />
|
||||
<span className="text-xs font-semibold">{displayCount}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillStarButton;
|
||||
45
web-app/src/factories/skill.ts
Normal file
45
web-app/src/factories/skill.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Skill } from '../types';
|
||||
|
||||
/**
|
||||
* Factory function for creating mock skill data
|
||||
*/
|
||||
export function createMockSkill(overrides?: Partial<Skill>): Skill {
|
||||
return {
|
||||
id: 'test-skill',
|
||||
name: 'Test Skill',
|
||||
description: 'A test skill for testing purposes',
|
||||
category: 'testing',
|
||||
risk: 'low',
|
||||
source: 'test',
|
||||
date_added: '2024-01-01',
|
||||
path: 'skills/test/SKILL.md',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating an array of mock skills
|
||||
*/
|
||||
export function createMockSkills(count: number): Skill[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockSkill({
|
||||
id: `skill-${i}`,
|
||||
name: `Test Skill ${i}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating skills with different categories
|
||||
*/
|
||||
export function createMockSkillsByCategory(categories: string[]): Skill[] {
|
||||
return categories.flatMap((category) =>
|
||||
Array.from({ length: 3 }, (_, i) =>
|
||||
createMockSkill({
|
||||
id: `${category}-skill-${i}`,
|
||||
name: `${category} Skill ${i}`,
|
||||
category,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
172
web-app/src/hooks/__tests__/useSkillStars.test.ts
Normal file
172
web-app/src/hooks/__tests__/useSkillStars.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSkillStars } from '../useSkillStars';
|
||||
|
||||
const STORAGE_KEY = 'user_stars';
|
||||
|
||||
describe('useSkillStars', () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage mock before each test
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with zero stars when no skillId provided', () => {
|
||||
const { result } = renderHook(() => useSkillStars(undefined));
|
||||
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasStarred).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with zero stars for new skill', () => {
|
||||
const { result } = renderHook(() => useSkillStars('new-skill'));
|
||||
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasStarred).toBe(false);
|
||||
});
|
||||
|
||||
it('should read starred status from localStorage on init', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'test-skill': true }));
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('test-skill'));
|
||||
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle corrupted localStorage gracefully', () => {
|
||||
// Mock getItem to return invalid JSON
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
localStorage.setItem(STORAGE_KEY, 'invalid-json');
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('test-skill'));
|
||||
|
||||
expect(result.current.hasStarred).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse user_stars from localStorage:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleStarClick', () => {
|
||||
it('should not allow starring without skillId', async () => {
|
||||
const { result } = renderHook(() => useSkillStars(undefined));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
});
|
||||
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow double-starring same skill', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'skill-1': true }));
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('skill-1'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
});
|
||||
|
||||
// Star count should remain unchanged (already starred)
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
});
|
||||
|
||||
it('should optimistically update star count', async () => {
|
||||
const { result } = renderHook(() => useSkillStars('optimistic-skill'));
|
||||
|
||||
// Initial state
|
||||
expect(result.current.starCount).toBe(0);
|
||||
|
||||
// Click star
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
});
|
||||
|
||||
// Should be optimistically updated
|
||||
await waitFor(() => {
|
||||
expect(result.current.starCount).toBe(1);
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist starred status to localStorage', async () => {
|
||||
const { result } = renderHook(() => useSkillStars('persist-skill'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
});
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ 'persist-skill': true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should set loading state during operation', async () => {
|
||||
const { result } = renderHook(() => useSkillStars('loading-skill'));
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Click star - the loading state may change very quickly due to the async nature
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
});
|
||||
|
||||
// After completion, loading should be false
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalStorage error handling', () => {
|
||||
it('should handle setItem errors gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Mock setItem to throw
|
||||
localStorage.setItem = vi.fn(() => {
|
||||
throw new Error('Storage quota exceeded');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('error-skill'));
|
||||
|
||||
// Should still optimistically update UI
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
});
|
||||
|
||||
expect(result.current.starCount).toBe(1);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to save user_stars to localStorage:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Return values', () => {
|
||||
it('should return all expected properties', () => {
|
||||
const { result } = renderHook(() => useSkillStars('test'));
|
||||
|
||||
expect(result.current).toHaveProperty('starCount');
|
||||
expect(result.current).toHaveProperty('hasStarred');
|
||||
expect(result.current).toHaveProperty('handleStarClick');
|
||||
expect(result.current).toHaveProperty('isLoading');
|
||||
});
|
||||
|
||||
it('should expose handleStarClick as function', () => {
|
||||
const { result } = renderHook(() => useSkillStars('test'));
|
||||
|
||||
expect(typeof result.current.handleStarClick).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
web-app/src/hooks/useSkillStars.ts
Normal file
163
web-app/src/hooks/useSkillStars.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const STORAGE_KEY = 'user_stars';
|
||||
|
||||
interface UserStars {
|
||||
[skillId: string]: boolean;
|
||||
}
|
||||
|
||||
interface UseSkillStarsReturn {
|
||||
starCount: number;
|
||||
hasStarred: boolean;
|
||||
handleStarClick: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse localStorage data with error handling
|
||||
*/
|
||||
function getUserStarsFromStorage(): UserStars {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return {};
|
||||
const parsed = JSON.parse(stored);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse user_stars from localStorage:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely save to localStorage with error handling
|
||||
*/
|
||||
function saveUserStarsToStorage(stars: UserStars): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stars));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save user_stars to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage skill starring functionality
|
||||
* Handles localStorage persistence, optimistic UI updates, and Supabase sync
|
||||
*/
|
||||
export function useSkillStars(skillId: string | undefined): UseSkillStarsReturn {
|
||||
const [starCount, setStarCount] = useState<number>(0);
|
||||
const [hasStarred, setHasStarred] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
// Initialize star count from Supabase and check if user has starred
|
||||
useEffect(() => {
|
||||
if (!skillId) return;
|
||||
|
||||
const initializeStars = async () => {
|
||||
// Check localStorage for user's starred status
|
||||
const userStars = getUserStarsFromStorage();
|
||||
setHasStarred(!!userStars[skillId]);
|
||||
|
||||
// Fetch star count from Supabase if available
|
||||
if (supabase) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', skillId)
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
setStarCount(data.star_count);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch star count:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeStars();
|
||||
}, [skillId]);
|
||||
|
||||
/**
|
||||
* Handle star button click
|
||||
* Prevents double-starring, updates optimistically, syncs to Supabase
|
||||
*/
|
||||
const handleStarClick = useCallback(async () => {
|
||||
if (!skillId || isLoading) return;
|
||||
|
||||
// Check if user has already starred (prevent spam)
|
||||
const userStars = getUserStarsFromStorage();
|
||||
if (userStars[skillId]) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Optimistically update UI
|
||||
setStarCount(prev => prev + 1);
|
||||
setHasStarred(true);
|
||||
|
||||
// Persist to localStorage
|
||||
const updatedStars = { ...userStars, [skillId]: true };
|
||||
saveUserStarsToStorage(updatedStars);
|
||||
|
||||
// Sync to Supabase if available
|
||||
if (supabase) {
|
||||
const { data: existingData, error: fetchError } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', skillId)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
// PGRST116 = not found, which is expected for new skills
|
||||
console.warn('Failed to fetch existing star count:', fetchError);
|
||||
}
|
||||
|
||||
if (existingData) {
|
||||
// Update existing record
|
||||
const { error: updateError } = await supabase
|
||||
.from('skill_stars')
|
||||
.update({ star_count: existingData.star_count + 1 })
|
||||
.eq('skill_id', skillId);
|
||||
|
||||
if (updateError) {
|
||||
console.warn('Failed to update star count:', updateError);
|
||||
}
|
||||
} else {
|
||||
// Insert new record
|
||||
const { error: insertError } = await supabase
|
||||
.from('skill_stars')
|
||||
.insert({ skill_id: skillId, star_count: 1 });
|
||||
|
||||
if (insertError) {
|
||||
console.warn('Failed to insert star count:', insertError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback optimistic update on error
|
||||
console.error('Failed to star skill:', error);
|
||||
setStarCount(prev => Math.max(0, prev - 1));
|
||||
setHasStarred(false);
|
||||
|
||||
// Remove from localStorage on error
|
||||
const userStars = getUserStarsFromStorage();
|
||||
if (userStars[skillId]) {
|
||||
const { [skillId]: _, ...rest } = userStars;
|
||||
saveUserStarsToStorage(rest);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [skillId, isLoading]);
|
||||
|
||||
return {
|
||||
starCount,
|
||||
hasStarred,
|
||||
handleStarClick,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
|
||||
export default useSkillStars;
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
17
web-app/src/lib/supabase.ts
Normal file
17
web-app/src/lib/supabase.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = (import.meta as ImportMeta & { env: Record<string, string> }).env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = (import.meta as ImportMeta & { env: Record<string, string> }).env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
// Create a single supabase client for interacting with your database
|
||||
// Returns null if credentials are not configured (graceful degradation)
|
||||
export const supabase: SupabaseClient | null =
|
||||
supabaseUrl && supabaseAnonKey
|
||||
? createClient(supabaseUrl, supabaseAnonKey)
|
||||
: null
|
||||
|
||||
// Type for star data in the database
|
||||
export interface SkillStarData {
|
||||
skill_id: string
|
||||
star_count: number
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
15
web-app/src/main.tsx
Normal file
15
web-app/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,301 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Filter, Book, AlertCircle, ArrowRight, Star, RefreshCw, ArrowUpDown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function Home() {
|
||||
const [skills, setSkills] = useState([]);
|
||||
const [filteredSkills, setFilteredSkills] = useState([]);
|
||||
const [search, setSearch] = 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 = {};
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const handleStarClick = async (e, skillId) => {
|
||||
e.preventDefault(); // Prevent link navigation
|
||||
|
||||
// Basic check to prevent spamming from same browser
|
||||
const storedStars = JSON.parse(localStorage.getItem('user_stars') || '{}');
|
||||
if (storedStars[skillId]) return;
|
||||
|
||||
// Optimistically update UI
|
||||
setStars(prev => ({
|
||||
...prev,
|
||||
[skillId]: (prev[skillId] || 0) + 1
|
||||
}));
|
||||
|
||||
// Remember locally
|
||||
localStorage.setItem('user_stars', JSON.stringify({
|
||||
...storedStars,
|
||||
[skillId]: true
|
||||
}));
|
||||
|
||||
if (supabase) {
|
||||
// First try to select existing
|
||||
const { data } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', skillId)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
// Update existing
|
||||
await supabase
|
||||
.from('skill_stars')
|
||||
.update({ star_count: data.star_count + 1 })
|
||||
.eq('skill_id', skillId);
|
||||
} else {
|
||||
// Insert new
|
||||
await supabase
|
||||
.from('skill_stars')
|
||||
.insert({ skill_id: skillId, star_count: 1 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let result = skills;
|
||||
|
||||
if (search) {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
result = result.filter(skill =>
|
||||
skill.name.toLowerCase().includes(lowerSearch) ||
|
||||
skill.description.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
result = result.filter(skill => skill.category === categoryFilter);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortBy === 'stars') {
|
||||
result = [...result].sort((a, b) => (stars[b.id] || 0) - (stars[a.id] || 0));
|
||||
} else if (sortBy === 'newest') {
|
||||
result = [...result].sort((a, b) => (b.date_added || '').localeCompare(a.date_added || ''));
|
||||
} else if (sortBy === 'az') {
|
||||
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
setFilteredSkills(result);
|
||||
}, [search, categoryFilter, sortBy, skills, stars]);
|
||||
|
||||
// Sort categories by count (most skills first), with 'uncategorized' at the end
|
||||
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">
|
||||
<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={async () => {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/refresh-skills');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (data.upToDate) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
setSyncMsg({ type: 'error', text: `❌ ${data.error}` });
|
||||
}
|
||||
} catch (err) {
|
||||
setSyncMsg({ type: 'error', text: '❌ Network error' });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setTimeout(() => setSyncMsg(null), 5000);
|
||||
}
|
||||
}}
|
||||
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-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 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>
|
||||
<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">
|
||||
@{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
web-app/src/pages/Home.tsx
Normal file
263
web-app/src/pages/Home.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
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';
|
||||
|
||||
export function Home(): React.ReactElement {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [filteredSkills, setFilteredSkills] = useState<Skill[]>([]);
|
||||
const [search, setSearch] = 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();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let result = [...skills];
|
||||
|
||||
if (search) {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
result = result.filter(skill =>
|
||||
skill.name.toLowerCase().includes(lowerSearch) ||
|
||||
skill.description.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
result = result.filter(skill => skill.category === categoryFilter);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortBy === 'stars') {
|
||||
result = [...result].sort((a, b) => (stars[b.id] || 0) - (stars[a.id] || 0));
|
||||
} else if (sortBy === 'newest') {
|
||||
result = [...result].sort((a, b) => (b.date_added || '').localeCompare(a.date_added || ''));
|
||||
} else if (sortBy === 'az') {
|
||||
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
setFilteredSkills(result);
|
||||
}, [search, 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 = ['all', ...Object.keys(categoryStats)
|
||||
.filter(cat => cat !== 'uncategorized')
|
||||
.sort((a, b) => categoryStats[b] - categoryStats[a]),
|
||||
...(categoryStats['uncategorized'] ? ['uncategorized'] : [])
|
||||
];
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/refresh-skills');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (data.upToDate) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
setSyncMsg({ type: 'error', text: `❌ ${data.error}` });
|
||||
}
|
||||
} catch (err) {
|
||||
setSyncMsg({ type: 'error', text: '❌ Network error' });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setTimeout(() => setSyncMsg(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
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'
|
||||
? '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>
|
||||
</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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Markdown from 'react-markdown';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle, Star } from 'lucide-react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function SkillDetail() {
|
||||
const { id } = useParams();
|
||||
const [skill, setSkill] = useState(null);
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedFull, setCopiedFull] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [customContext, setCustomContext] = useState('');
|
||||
const [starCount, setStarCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch index and stars in parallel when possible
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 1. Fetch index to get skill metadata and path
|
||||
const res = await fetch('/skills.json');
|
||||
const skills = await res.json();
|
||||
const foundSkill = skills.find(s => s.id === id);
|
||||
|
||||
if (foundSkill) {
|
||||
setSkill(foundSkill);
|
||||
|
||||
// Fetch star count
|
||||
if (supabase) {
|
||||
const { data } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setStarCount(data.star_count);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.");
|
||||
}
|
||||
} 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;
|
||||
|
||||
// Optimistic UI updates
|
||||
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()
|
||||
? `${basePrompt}\n\nContext:\n${customContext}`
|
||||
: basePrompt;
|
||||
|
||||
navigator.clipboard.writeText(finalPrompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyFullToClipboard = () => {
|
||||
const finalPrompt = customContext.trim()
|
||||
? `${content}\n\nContext:\n${customContext}`
|
||||
: content;
|
||||
|
||||
navigator.clipboard.writeText(finalPrompt);
|
||||
setCopiedFull(true);
|
||||
setTimeout(() => setCopiedFull(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="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}
|
||||
</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>
|
||||
)}
|
||||
<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}
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
web-app/src/pages/SkillDetail.tsx
Normal file
186
web-app/src/pages/SkillDetail.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Markdown from 'react-markdown';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle } from 'lucide-react';
|
||||
import { SkillStarButton } from '../components/SkillStarButton';
|
||||
import type { Skill } from '../types';
|
||||
|
||||
interface RouteParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function SkillDetail(): React.ReactElement {
|
||||
const { id } = useParams<keyof RouteParams>() as RouteParams;
|
||||
const [skill, setSkill] = useState<Skill | null>(null);
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = 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);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch index and stars in parallel when possible
|
||||
const loadData = async () => {
|
||||
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);
|
||||
|
||||
if (foundSkill) {
|
||||
setSkill(foundSkill);
|
||||
|
||||
// 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.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load skill data', err);
|
||||
setError(err instanceof Error ? err.message : 'Could not load skill content.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!skill) return;
|
||||
|
||||
const basePrompt = `Use @${skill.name}`;
|
||||
const finalPrompt = customContext.trim()
|
||||
? `${basePrompt}\n\nContext:\n${customContext}`
|
||||
: basePrompt;
|
||||
|
||||
navigator.clipboard.writeText(finalPrompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyFullToClipboard = () => {
|
||||
const finalPrompt = customContext.trim()
|
||||
? `${content}\n\nContext:\n${customContext}`
|
||||
: content;
|
||||
|
||||
navigator.clipboard.writeText(finalPrompt);
|
||||
setCopiedFull(true);
|
||||
setTimeout(() => setCopiedFull(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="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}
|
||||
</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.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>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillDetail;
|
||||
258
web-app/src/pages/__tests__/Home.test.tsx
Normal file
258
web-app/src/pages/__tests__/Home.test.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
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';
|
||||
|
||||
describe('Home', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading skeleton initially', async () => {
|
||||
// Mock fetch to never resolve (keep loading state)
|
||||
(global.fetch as Mock).mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
// Should show pulse animation elements (skeletons)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render skills after loading', async () => {
|
||||
const mockSkills = createMockSkills(3);
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => mockSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync button', () => {
|
||||
it('should show sync button', async () => {
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [],
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sync Skills/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
fireEvent.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Syncing/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
191
web-app/src/pages/__tests__/SkillDetail.test.tsx
Normal file
191
web-app/src/pages/__tests__/SkillDetail.test.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
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';
|
||||
|
||||
// Mock the SkillStarButton component
|
||||
vi.mock('../../components/SkillStarButton', () => ({
|
||||
SkillStarButton: ({ skillId, initialCount }: { skillId: string; initialCount?: number }) => (
|
||||
<button data-testid="star-button" data-skill-id={skillId} data-count={initialCount}>
|
||||
{initialCount || 0} Upvotes
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('SkillDetail', () => {
|
||||
beforeEach(() => {
|
||||
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);
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/test-skill' });
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Skill rendering', () => {
|
||||
it('should render skill details correctly', async () => {
|
||||
const mockSkill = createMockSkill({
|
||||
id: 'react-patterns',
|
||||
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],
|
||||
});
|
||||
|
||||
// Mock second fetch for markdown content
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# React Patterns\n\nThis is the skill content.',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/react-patterns' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@react-patterns')).toBeInTheDocument();
|
||||
expect(screen.getByText('React design patterns and best practices')).toBeInTheDocument();
|
||||
expect(screen.getByText(/frontend/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show skill not found when id does not exist', async () => {
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [],
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/nonexistent' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error Loading Skill/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Skill not found in registry/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/click-test' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Copy @Skill/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /Copy @Skill/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Use @click-test');
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const mockSkill = createMockSkill({ id: 'star-integration' });
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
json: async () => [mockSkill],
|
||||
});
|
||||
|
||||
(global.fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, { route: '/skill/star-integration' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('star-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
51
web-app/src/test/setup.ts
Normal file
51
web-app/src/test/setup.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock localStorage
|
||||
type LocalStore = Record<string, string>;
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: LocalStore = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock navigator.clipboard
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
35
web-app/src/types/index.ts
Normal file
35
web-app/src/types/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Skill data type from skills.json
|
||||
*/
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
risk?: 'low' | 'medium' | 'high' | 'critical' | 'unknown';
|
||||
source?: string;
|
||||
date_added?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Star count data from Supabase
|
||||
*/
|
||||
export interface StarMap {
|
||||
[skillId: string]: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync message type for UI feedback
|
||||
*/
|
||||
export interface SyncMessage {
|
||||
type: 'success' | 'error' | 'info';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category statistics
|
||||
*/
|
||||
export interface CategoryStats {
|
||||
[category: string]: number;
|
||||
}
|
||||
25
web-app/src/utils/testUtils.tsx
Normal file
25
web-app/src/utils/testUtils.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
// Custom render with router
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export function renderWithRouter(
|
||||
ui: React.ReactElement,
|
||||
{ route = '/', ...renderOptions }: CustomRenderOptions = {}
|
||||
): ReturnType<typeof render> {
|
||||
window.history.pushState({}, 'Test page', route);
|
||||
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
),
|
||||
...renderOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export everything from testing-library
|
||||
export * from '@testing-library/react';
|
||||
31
web-app/tsconfig.json
Normal file
31
web-app/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web-app/tsconfig.node.json
Normal file
10
web-app/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import refreshSkillsPlugin from './refresh-skills-plugin.js'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), refreshSkillsPlugin()],
|
||||
})
|
||||
8
web-app/vite.config.ts
Normal file
8
web-app/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import refreshSkillsPlugin from './refresh-skills-plugin.js';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), refreshSkillsPlugin()],
|
||||
});
|
||||
14
web-app/vitest.config.ts
Normal file
14
web-app/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, mergeConfig } from 'vitest/config';
|
||||
import viteConfig from './vite.config';
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user