refactor(web-app): migrate to TypeScript, add security fix and test suite

This commit is contained in:
sck_0
2026-03-03 17:03:58 +01:00
parent 489875c4b8
commit 4ab3377fee
28 changed files with 3997 additions and 613 deletions

View File

@@ -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
View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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>

View 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;

View 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,
})
)
);
}

View 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');
});
});
});

View 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;

View File

@@ -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

View 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
}

View File

@@ -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
View 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>,
);

View File

@@ -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
View 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;

View File

@@ -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>
);
}

View 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. &quot;Use React 19 and Tailwind&quot;). The &quot;Copy Prompt&quot; 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;

View 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();
});
});
});

View 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
View 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();

View 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;
}

View 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
View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -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
View 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
View 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,
},
})
);