refactor: reorganize repo docs and tooling layout
Consolidate the repository into clearer apps, tools, and layered docs areas so contributors can navigate and maintain it more reliably. Align validation, metadata sync, and CI around the same canonical workflow to reduce drift across local checks and GitHub Actions.
This commit is contained in:
42
apps/web-app/src/App.css
Normal file
42
apps/web-app/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
43
apps/web-app/src/App.tsx
Normal file
43
apps/web-app/src/App.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import { Home } from './pages/Home';
|
||||
import { SkillDetail } from './pages/SkillDetail';
|
||||
import { BookOpen, Github } from 'lucide-react';
|
||||
|
||||
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">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-950/80 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
||||
<div className="container flex h-14 max-w-screen-2xl items-center mx-auto px-4">
|
||||
<Link to="/" className="mr-8 flex items-center space-x-2">
|
||||
<BookOpen className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
|
||||
<span className="hidden font-bold sm:inline-block">Antigravity Skills</span>
|
||||
</Link>
|
||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
|
||||
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||
<a
|
||||
href="https://github.com/sickn33/antigravity-awesome-skills"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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" />
|
||||
GitHub Repository
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container max-w-screen-2xl mx-auto px-4 py-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/skill/:id" element={<SkillDetail />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
apps/web-app/src/assets/react.svg
Normal file
1
apps/web-app/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
66
apps/web-app/src/components/SkillCard.tsx
Normal file
66
apps/web-app/src/components/SkillCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Book, ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { SkillStarButton } from './SkillStarButton';
|
||||
import type { Skill } from '../types';
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: Skill;
|
||||
starCount: number;
|
||||
}
|
||||
|
||||
export const SkillCard = React.memo(({ skill, starCount }: SkillCardProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Link
|
||||
to={`/skill/${skill.id}`}
|
||||
className="group flex flex-col h-full rounded-lg border border-slate-200 bg-white p-6 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-500/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-indigo-50 dark:bg-indigo-950/30 rounded-md">
|
||||
<Book className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{skill.category || 'Uncategorized'}
|
||||
</span>
|
||||
</div>
|
||||
<SkillStarButton
|
||||
skillId={skill.id}
|
||||
initialCount={starCount}
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-50 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors mb-2 line-clamp-1">
|
||||
@{skill.name}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-3 mb-4 flex-grow">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 dark:text-slate-500 mb-3 pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<span>Risk: <span className="font-semibold text-slate-600 dark:text-slate-300">{skill.risk || 'unknown'}</span></span>
|
||||
{skill.date_added && (
|
||||
<span className="ml-2">📅 {skill.date_added}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm font-medium text-indigo-600 dark:text-indigo-400 pt-2 mt-auto group-hover:translate-x-1 transition-transform">
|
||||
Read Skill <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
SkillCard.displayName = 'SkillCard';
|
||||
64
apps/web-app/src/components/SkillStarButton.tsx
Normal file
64
apps/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;
|
||||
91
apps/web-app/src/context/SkillContext.tsx
Normal file
91
apps/web-app/src/context/SkillContext.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { Skill, StarMap } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface SkillContextType {
|
||||
skills: Skill[];
|
||||
stars: StarMap;
|
||||
loading: boolean;
|
||||
refreshSkills: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SkillContext = createContext<SkillContextType | undefined>(undefined);
|
||||
|
||||
export function SkillProvider({ children }: { children: React.ReactNode }) {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [stars, setStars] = useState<StarMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSkillsAndStars = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
// Fetch skills index
|
||||
const res = await fetch('/skills.json');
|
||||
const data = await res.json();
|
||||
|
||||
// Incremental loading: set first 50 skills immediately if not a silent refresh
|
||||
if (!silent && data.length > 50) {
|
||||
setSkills(data.slice(0, 50));
|
||||
setLoading(false); // Clear loading state as soon as we have initial content
|
||||
} else {
|
||||
setSkills(data);
|
||||
}
|
||||
|
||||
// Fetch stars from Supabase if available
|
||||
if (supabase) {
|
||||
const { data: starData, error } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('skill_id, star_count');
|
||||
|
||||
if (!error && starData) {
|
||||
const starMap: StarMap = {};
|
||||
starData.forEach((item: { skill_id: string; star_count: number }) => {
|
||||
starMap[item.skill_id] = item.star_count;
|
||||
});
|
||||
setStars(starMap);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally set the full set of skills if we did incremental load
|
||||
if (!silent && data.length > 50) {
|
||||
setSkills(data);
|
||||
} else if (silent) {
|
||||
setSkills(data);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('SkillContext: Failed to load skills', err);
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkillsAndStars();
|
||||
}, [fetchSkillsAndStars]);
|
||||
|
||||
const refreshSkills = useCallback(async () => {
|
||||
await fetchSkillsAndStars(true);
|
||||
}, [fetchSkillsAndStars]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
skills,
|
||||
stars,
|
||||
loading,
|
||||
refreshSkills
|
||||
}), [skills, stars, loading, refreshSkills]);
|
||||
|
||||
return (
|
||||
<SkillContext.Provider value={value}>
|
||||
{children}
|
||||
</SkillContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSkills() {
|
||||
const context = useContext(SkillContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSkills must be used within a SkillProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
45
apps/web-app/src/factories/skill.ts
Normal file
45
apps/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
apps/web-app/src/hooks/__tests__/useSkillStars.test.ts
Normal file
172
apps/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
apps/web-app/src/hooks/useSkillStars.ts
Normal file
163
apps/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;
|
||||
15
apps/web-app/src/index.css
Normal file
15
apps/web-app/src/index.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@import 'github-markdown-css/github-markdown-dark.css';
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50 overflow-x-hidden;
|
||||
}
|
||||
17
apps/web-app/src/lib/supabase.ts
Normal file
17
apps/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
|
||||
}
|
||||
18
apps/web-app/src/main.tsx
Normal file
18
apps/web-app/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { SkillProvider } from './context/SkillContext';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<SkillProvider>
|
||||
<App />
|
||||
</SkillProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
202
apps/web-app/src/pages/Home.tsx
Normal file
202
apps/web-app/src/pages/Home.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Search, Filter, AlertCircle, RefreshCw, ArrowUpDown } from 'lucide-react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { useSkills } from '../context/SkillContext';
|
||||
import { SkillCard } from '../components/SkillCard';
|
||||
import type { SyncMessage, CategoryStats } from '../types';
|
||||
|
||||
export function Home(): React.ReactElement {
|
||||
const { skills, stars, loading, refreshSkills } = useSkills();
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('default');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<SyncMessage | null>(null);
|
||||
|
||||
// Debounce search input to avoid excessive filtering on every keystroke
|
||||
const debouncedSetSearch = useCallback(
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearch(value);
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSetSearch(search);
|
||||
}, [search, debouncedSetSearch]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
let result = [...skills];
|
||||
|
||||
if (debouncedSearch) {
|
||||
const lowerSearch = debouncedSearch.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));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [debouncedSearch, categoryFilter, sortBy, skills, stars]);
|
||||
|
||||
// Sort categories by count (most skills first), with 'uncategorized' at the end
|
||||
const { categories, categoryStats } = useMemo(() => {
|
||||
const stats: CategoryStats = {};
|
||||
skills.forEach(skill => {
|
||||
stats[skill.category] = (stats[skill.category] || 0) + 1;
|
||||
});
|
||||
|
||||
const cats = ['all', ...Object.keys(stats)
|
||||
.filter(cat => cat !== 'uncategorized')
|
||||
.sort((a, b) => stats[b] - stats[a]),
|
||||
...(stats['uncategorized'] ? ['uncategorized'] : [])
|
||||
];
|
||||
|
||||
return { categories: cats, categoryStats: stats };
|
||||
}, [skills]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
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!` });
|
||||
await refreshSkills();
|
||||
}
|
||||
} 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="flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="space-y-8 mb-8">
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 mb-2">Explore Skills</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">Discover {skills.length} agentic capabilities for your AI assistant.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{syncMsg && (
|
||||
<span className={`text-sm font-medium px-3 py-1.5 rounded-full ${syncMsg.type === 'success'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: syncMsg.type === 'info'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}>
|
||||
{syncMsg.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="flex items-center space-x-2 px-4 py-2.5 rounded-lg font-medium text-sm bg-indigo-600 hover:bg-indigo-700 text-white disabled:opacity-50 disabled:cursor-wait transition-colors shadow-sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
<span>{syncing ? 'Syncing...' : 'Sync Skills'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-x-4 md:space-y-0 bg-white dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm sticky top-0 z-40">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search skills (e.g., 'react', 'security', 'python')..."
|
||||
aria-label="Search skills"
|
||||
className="w-full rounded-md border border-slate-200 bg-slate-50 px-9 py-2 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 overflow-x-auto pb-2 md:pb-0 scrollbar-hide">
|
||||
<Filter className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
<select
|
||||
aria-label="Filter by category"
|
||||
className="h-9 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 min-w-[150px]"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat === 'all'
|
||||
? 'All Categories'
|
||||
: `${cat.charAt(0).toUpperCase() + cat.slice(1)} (${categoryStats[cat] || 0})`
|
||||
}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ArrowUpDown className="h-4 w-4 text-slate-500 shrink-0 ml-2" />
|
||||
<select
|
||||
aria-label="Sort skills"
|
||||
className="h-9 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 min-w-[130px]"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="stars">⭐ Most Stars</option>
|
||||
<option value="newest">🆕 Newest</option>
|
||||
<option value="az">🔤 A → Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 -mx-4">
|
||||
{loading ? (
|
||||
<div data-testid="loader" className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 px-4">
|
||||
{[...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>
|
||||
))}
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="py-12 text-center px-4 sm:px-6 lg:px-8">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-slate-400" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-slate-900 dark:text-slate-100">No skills found</h3>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">Try adjusting your search or filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
style={{ height: '100%' }}
|
||||
totalCount={filteredSkills.length}
|
||||
listClassName="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pb-8 px-4"
|
||||
itemContent={(index) => {
|
||||
const skill = filteredSkills[index];
|
||||
return <SkillCard key={skill.id} skill={skill} starCount={stars[skill.id] || 0} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
194
apps/web-app/src/pages/SkillDetail.tsx
Normal file
194
apps/web-app/src/pages/SkillDetail.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { SkillStarButton } from '../components/SkillStarButton';
|
||||
import { useSkills } from '../context/SkillContext';
|
||||
|
||||
// Lazy load heavy markdown component
|
||||
const Markdown = lazy(() => import('react-markdown'));
|
||||
|
||||
interface RouteParams {
|
||||
id: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export function SkillDetail(): React.ReactElement {
|
||||
const { id } = useParams<RouteParams>();
|
||||
const { skills, stars, loading: contextLoading } = useSkills();
|
||||
const [content, setContent] = useState('');
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedFull, setCopiedFull] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customContext, setCustomContext] = useState('');
|
||||
|
||||
const skill = useMemo(() => skills.find(s => s.id === id), [skills, id]);
|
||||
const starCount = useMemo(() => (id ? stars[id] || 0 : 0), [stars, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contextLoading || !skill) return;
|
||||
|
||||
const loadMarkdown = async () => {
|
||||
setContentLoading(true);
|
||||
try {
|
||||
const cleanPath = skill.path.startsWith('skills/')
|
||||
? skill.path.replace('skills/', '')
|
||||
: skill.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);
|
||||
} catch (err) {
|
||||
console.error('Failed to load skill content', err);
|
||||
setError(err instanceof Error ? err.message : 'Could not load skill content.');
|
||||
} finally {
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadMarkdown();
|
||||
}, [skill, contextLoading]);
|
||||
|
||||
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 (!contextLoading && !skill) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center px-4">
|
||||
<h2 className="text-xl font-bold mb-2">Error Loading Skill</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">Skill not found in registry.</p>
|
||||
<Link to="/" className="mt-4 text-indigo-600 hover:underline">Back to Catalog</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (contextLoading || (contentLoading && !error)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]" data-testid="loader">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-indigo-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If we're here, contextLoading is false, skill is defined, and content loading is finished (or errored)
|
||||
if (error || !skill) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">Failed to Load Content</h2>
|
||||
<p className="text-slate-500 mt-2">{error || 'Skill details could not be loaded.'}</p>
|
||||
<Link to="/" className="mt-8 inline-flex items-center text-indigo-600 font-medium hover:underline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Catalog
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Link to="/" className="inline-flex items-center text-sm font-medium text-slate-500 hover:text-indigo-600 transition-colors mb-4 group">
|
||||
<ArrowLeft className="mr-1 h-4 w-4 transform group-hover:-translate-x-1 transition-transform" />
|
||||
Back to Catalog
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white dark:bg-slate-900 p-6 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2 flex-wrap gap-2">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400 uppercase tracking-wide">
|
||||
{skill.category}
|
||||
</span>
|
||||
{skill.source && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{skill.source}
|
||||
</span>
|
||||
)}
|
||||
{skill.date_added && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
📅 Added {skill.date_added}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
|
||||
@{skill.name}
|
||||
</h1>
|
||||
<SkillStarButton skillId={skill.id} initialCount={starCount} variant="compact" />
|
||||
</div>
|
||||
<p className="mt-2 text-lg text-slate-600 dark:text-slate-400">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="flex items-center justify-center space-x-2 bg-slate-100 hover:bg-slate-200 text-slate-900 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 px-4 py-2.5 rounded-lg font-medium transition-colors min-w-[140px] border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-600 dark:text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
<span>{copied ? 'Copied!' : 'Copy @Skill'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={copyFullToClipboard}
|
||||
className="flex items-center justify-center space-x-2 bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200 px-4 py-2.5 rounded-lg font-medium transition-colors min-w-[140px]"
|
||||
>
|
||||
{copiedFull ? <Check className="h-4 w-4 text-green-400" /> : <FileCode className="h-4 w-4" />}
|
||||
<span>{copiedFull ? 'Copied Content!' : 'Copy Full Content'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white dark:bg-slate-900 p-6 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||
<label htmlFor="context" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Interactive Prompt Builder (Optional)
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
|
||||
Add specific details below (e.g. "Use React 19 and Tailwind"). The "Copy Prompt" button will automatically attach your context.
|
||||
</p>
|
||||
<textarea
|
||||
id="context"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 px-4 py-3 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 outline-none transition-all resize-y"
|
||||
placeholder="Type your specific task requirements here..."
|
||||
value={customContext}
|
||||
onChange={(e) => setCustomContext(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||
<Suspense fallback={<div className="h-24 animate-pulse bg-slate-100 dark:bg-slate-800 rounded-lg"></div>}>
|
||||
<Markdown>{content}</Markdown>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillDetail;
|
||||
153
apps/web-app/src/pages/__tests__/Home.test.tsx
Normal file
153
apps/web-app/src/pages/__tests__/Home.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
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 { createMockSkill } from '../../factories/skill';
|
||||
import { useSkills } from '../../context/SkillContext';
|
||||
|
||||
// Mock lodash.debounce to execute immediately
|
||||
vi.mock('lodash.debounce', () => ({
|
||||
default: vi.fn((fn) => {
|
||||
const mockedFn: any = (...args: any[]) => fn(...args);
|
||||
mockedFn.cancel = vi.fn();
|
||||
return mockedFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useSkills hook
|
||||
vi.mock('../../context/SkillContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return { ...actual, useSkills: vi.fn() };
|
||||
});
|
||||
|
||||
// Mock VirtuosoGrid to render items normally for easier testing
|
||||
vi.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({ totalCount, itemContent }: any) => (
|
||||
<div data-testid="virtuoso-grid">
|
||||
{Array.from({ length: totalCount || 0 }).map((_, index) => (
|
||||
<div key={index} data-testid="skill-item">
|
||||
{itemContent(index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Home', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading spinner when loading is true', () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
loading: true,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render skill cards when skills are loaded', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'skill-1', name: 'Skill 1' }),
|
||||
createMockSkill({ id: 'skill-2', name: 'Skill 2' }),
|
||||
];
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@Skill 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('@Skill 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search and Filtering', () => {
|
||||
it('should filter skills based on search term', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'react', name: 'React Patterns' }),
|
||||
createMockSkill({ id: 'vue', name: 'Vue Basics' }),
|
||||
];
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
const searchInput = screen.getByLabelText(/Search skills/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'React' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchInput).toHaveValue('React');
|
||||
expect(screen.getByText('@React Patterns')).toBeInTheDocument();
|
||||
expect(screen.queryByText('@Vue Basics')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter skills by category', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 's1', category: 'frontend', name: 'Frontend Skill' }),
|
||||
createMockSkill({ id: 's2', category: 'backend', name: 'Backend Skill' }),
|
||||
];
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
const categorySelect = screen.getByLabelText(/Filter by category/i);
|
||||
fireEvent.change(categorySelect, { target: { value: 'frontend' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(categorySelect).toHaveValue('frontend');
|
||||
expect(screen.getByText('@Frontend Skill')).toBeInTheDocument();
|
||||
expect(screen.queryByText('@Backend Skill')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Settings and Sync', () => {
|
||||
it('should sync local stars when sync button is clicked', async () => {
|
||||
const mockSkills = [createMockSkill({ id: 'skill-1' })];
|
||||
const refreshSkills = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: { 'skill-1': 5 },
|
||||
loading: false,
|
||||
refreshSkills,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
const syncButton = screen.getByRole('button', { name: /Sync/i });
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, count: 1 })
|
||||
});
|
||||
|
||||
fireEvent.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshSkills).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
190
apps/web-app/src/pages/__tests__/SkillDetail.test.tsx
Normal file
190
apps/web-app/src/pages/__tests__/SkillDetail.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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 { useSkills } from '../../context/SkillContext';
|
||||
|
||||
// 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>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock useSkills hook
|
||||
vi.mock('../../context/SkillContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
useSkills: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock react-markdown to avoid lazy loading issues in tests
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <div data-testid="markdown-content">{children}</div>,
|
||||
}));
|
||||
|
||||
describe('SkillDetail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('Loading state', () => {
|
||||
it('should show loading spinner when context is loading', async () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
loading: true,
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/test-skill',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading spinner when markdown is loading', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'test-skill' });
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Mock fetch for markdown content to never resolve
|
||||
global.fetch = vi.fn().mockReturnValue(new Promise(() => { }));
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/test-skill',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: { 'react-patterns': 5 },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => '# React Patterns\n\nThis is the skill content.',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/react-patterns',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('@react-patterns')).toBeInTheDocument();
|
||||
expect(screen.getByText('React design patterns and best practices')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('This is the skill content.');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show skill not found when id does not exist', async () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/nonexistent',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
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 copy skill name to clipboard when clicked', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'click-test', name: 'click-test' });
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/click-test',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
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('Star button integration', () => {
|
||||
it('should render star button component with correct count', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'star-integration' });
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: { 'star-integration': 10 },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/star-integration',
|
||||
path: '/skill/:id',
|
||||
useProvider: false
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const starBtn = screen.getByTestId('star-button');
|
||||
expect(starBtn).toBeInTheDocument();
|
||||
expect(starBtn).toHaveAttribute('data-count', '10');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
51
apps/web-app/src/test/setup.ts
Normal file
51
apps/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
apps/web-app/src/types/index.ts
Normal file
35
apps/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;
|
||||
}
|
||||
43
apps/web-app/src/utils/testUtils.tsx
Normal file
43
apps/web-app/src/utils/testUtils.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { SkillProvider } from '../context/SkillContext';
|
||||
|
||||
// Custom render with router and SkillProvider
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
route?: string;
|
||||
path?: string; // The route pattern, e.g., /skill/:id
|
||||
useProvider?: boolean;
|
||||
}
|
||||
|
||||
export function renderWithRouter(
|
||||
ui: React.ReactElement,
|
||||
{
|
||||
route = '/',
|
||||
path = '*',
|
||||
useProvider = true,
|
||||
...renderOptions
|
||||
}: CustomRenderOptions = {}
|
||||
): ReturnType<typeof render> {
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
{useProvider ? (
|
||||
<SkillProvider>
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
</Routes>
|
||||
</SkillProvider>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
</Routes>
|
||||
)}
|
||||
</MemoryRouter>
|
||||
),
|
||||
...renderOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export everything from testing-library
|
||||
export * from '@testing-library/react';
|
||||
Reference in New Issue
Block a user