Skip to content

Commit 1636137

Browse files
authored
feat(leaderboard): add votes, sort/filter, preview modal, scoring updates (#7)
* feat(security): add content scanning for SKILL.md files Implement security scanning layer for SKILL.md content with risk labels, sanitization, and UI warnings. Includes database schema updates, API integration, CLI warnings, and comprehensive documentation. * test: add vitest setup and unit tests for content-scanner and parseIdentifier - Add vitest configuration (vitest.config.ts) with workspace support - Implement 30 unit tests for content-scanner (prompt injection, encoding, payloads, edge cases) - Implement 8 unit tests for parseIdentifier (slug parsing, GitHub format detection) - Add vitest devDependency and test scripts to package.json - Security fix: Add bidirectional override characters (U+202A-U+202E) to content scanner regex for defense against Unicode direction spoofing - Export parseIdentifier from use.ts for testability - Update pnpm-lock.yaml with vitest dependencies * docs: update CLI README, CLAUDE.md, and project docs for Phase 3+4 completion - Update packages/cli/README.md: document publish command, security scanning details, identifier format guidelines - Update CLAUDE.md: clarify CLI identifier resolution strategy and content security warnings - Update code-standards.md: add content security scanning section with risk labels and sanitization procedures - Update codebase-summary.md: document security-focused content scanner with regex patterns for injection detection - Update system-architecture.md: add content security layer architecture and defense mechanisms - Mark Phase 3 (Integration Testing) as complete with test coverage summary - Mark Phase 4 (Docs & Deploy) as complete with all documentation updates - Update plan.md with overall redesign completion status and next milestones * feat(leaderboard): add votes, sort/filter, preview modal, scoring updates - Add votes table with upvote/downvote (migration 0007) - Add vote API endpoint with rate limiting (10/min per user) - Add user interactions API for batch favorites+votes fetch - Expand leaderboard API with description, category, netVotes, category filter - Add sort tabs (best/rating/installs/trending/newest) and category dropdown - Add skill preview modal with description, stats, and CTA - Add vote arrows and favorite heart to leaderboard rows - Implement optimistic UI with revert on error - Make author names clickable GitHub links (with username validation) - Update search boost scoring: 8 signals (votes at 7%, RRF 50%→43%) - Update leaderboard composite: 7 signals (votes 10%, rating 35%→30%, installs 25%→20%) - Update recompute pipeline with maxNetVotes normalization - Add auth state to home page for interaction overlay * docs: update plans and project docs for leaderboard enhancements completion - Mark all 4 phases complete in plan.md - Update phase files with checked TODOs - Update codebase-summary.md with votes table and new endpoints - Update system-architecture.md with 8-signal scoring
1 parent ef47ca4 commit 1636137

74 files changed

Lines changed: 42804 additions & 235 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ apps/web/app/
5353
└── app.css # Tailwind + theme tokens
5454
5555
packages/cli/src/ # `skillx` npm package
56-
├── commands/ # search, use, report, config
56+
├── commands/ # search, use, publish, report, config
5757
├── lib/ # api-client, config-store
5858
└── index.ts # Commander.js entry
5959
```
@@ -65,6 +65,8 @@ pnpm install # Install all dependencies
6565
pnpm dev # Start dev server (apps/web) → http://localhost:5173
6666
pnpm build # Build for production
6767
pnpm typecheck # TypeScript check
68+
pnpm test # Run unit tests (vitest) once
69+
pnpm test:watch # Run unit tests in watch mode
6870
pnpm seed # Seed DB (requires ADMIN_SECRET env var)
6971

7072
# Inside apps/web:
@@ -88,14 +90,16 @@ pnpm db:migrate:remote # Apply migrations to remote D1
8890
| `/api/skills/:slug/review` | api.skill-review.ts | Session/Key |
8991
| `/api/skills/:slug/favorite` | api.skill-favorite.ts | Session/Key |
9092
| `/api/skills/:slug/install` | api.skill-install.ts | Optional (API key or X-Device-Id) |
93+
| `/api/skills/:slug/vote` | api.skill-vote.ts | Session |
9194
| `/api/report` | api.usage-report.ts | Session/Key |
9295
| `/api/user/api-keys` | api.user-api-keys.ts | Session |
96+
| `/api/user/interactions` | api.user-interactions.ts | Session (graceful fallback) |
9397
| `/api/skills/register` | api.skill-register.ts | Session/Key |
9498
| `/api/admin/seed` | api.admin.seed.ts | Admin secret |
9599

96100
## Database Tables (Drizzle schema)
97101

98-
`skills`, `ratings`, `reviews`, `favorites`, `usageStats`, `apiKeys`, `installs`
102+
`skills`, `ratings`, `reviews`, `favorites`, `votes`, `usageStats`, `apiKeys`, `installs`
99103
Plus Better Auth tables: `user`, `session`, `account`, `verification`
100104

101105
## Key Patterns
@@ -109,7 +113,11 @@ Search algorithm: `./docs/search-algorithm.md`
109113

110114
**Auth**: `getSession(request, env)` for session, `requireAuth(request, env)` for redirect
111115

112-
**Search**: Query → Embed (Workers AI) → Vectorize + FTS5 parallel → RRF fusion → Boost scoring → Filter → Cache (KV)
116+
**Search**: Query → Embed (Workers AI) → Vectorize + FTS5 parallel → RRF fusion → 8-signal Boost scoring (RRF 43%, rating 15%, stars 10%, usage 8%, success 7%, votes 7%, recency 5%, favorites 5%) → Filter → Cache (KV)
117+
118+
**Leaderboard**: 7-signal composite scoring (rating 30%, installs 20%, stars 15%, votes 10%, success 10%, recency 10%, favorites 5%). Sort tabs (best/rating/installs/trending/newest), category filter, preview modal. Client-side interaction overlay (votes + favorites) on KV-cached data.
119+
120+
**Vote API**: POST `/api/skills/:slug/vote` with `{ type: 'up'|'down'|'none' }`. Rate limited 10 votes/min per user. Atomic count update via SQL subquery.
113121

114122
**CLI `skillx use` resolution**: `author/skill` (two-part → DB slug `author-skill`) | `org/repo/skill` (three-part → DB slug `org-skill`, fallback register from GitHub) | `slug` (direct lookup, fallback search) | `"keywords"` (search mode)
115123

‎apps/web/app/components/home-leaderboard.tsx‎

Lines changed: 201 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,215 @@
11
import { useState, useEffect, useRef, useCallback } from "react";
2-
import { LeaderboardTable } from "./leaderboard-table";
2+
import { LeaderboardTable, type LeaderboardEntry } from "./leaderboard-table";
3+
import { LeaderboardControls } from "./leaderboard-controls";
4+
import { SkillPreviewModal } from "./skill-preview-modal";
35

46
const PAGE_SIZE = 20;
57

6-
interface LeaderboardEntry {
7-
rank: number;
8-
slug: string;
9-
name: string;
10-
author: string;
11-
installs: number;
12-
rating: number;
13-
}
14-
158
interface HomeLeaderboardProps {
169
initialEntries: LeaderboardEntry[];
1710
initialHasMore: boolean;
11+
categories: string[];
12+
isAuthenticated?: boolean;
1813
}
1914

2015
export function HomeLeaderboard({
2116
initialEntries,
2217
initialHasMore,
18+
categories,
19+
isAuthenticated = false,
2320
}: HomeLeaderboardProps) {
2421
const [entries, setEntries] = useState(initialEntries);
2522
const [hasMore, setHasMore] = useState(initialHasMore);
2623
const [isLoading, setIsLoading] = useState(false);
27-
const sentinelRef = useRef<HTMLDivElement>(null);
24+
const [sort, setSort] = useState("best");
25+
const [category, setCategory] = useState("");
26+
const [previewEntry, setPreviewEntry] = useState<LeaderboardEntry | null>(null);
2827

28+
// User interaction state (client-side overlay on KV-cached data)
29+
const [userFavorites, setUserFavorites] = useState<Set<string>>(new Set());
30+
const [userVotes, setUserVotes] = useState<Map<string, "up" | "down">>(new Map());
31+
const [interactionsLoaded, setInteractionsLoaded] = useState(false);
32+
33+
const sentinelRef = useRef<HTMLDivElement>(null);
2934
const loadingRef = useRef(false);
3035
const hasMoreRef = useRef(initialHasMore);
3136
const entriesLengthRef = useRef(initialEntries.length);
37+
const sortRef = useRef("best");
38+
const categoryRef = useRef("");
39+
40+
// Fetch user interactions for displayed entries
41+
const fetchInteractions = useCallback(async (slugs: string[]) => {
42+
if (!isAuthenticated || slugs.length === 0) return;
43+
try {
44+
const res = await fetch(`/api/user/interactions?slugs=${slugs.join(",")}`);
45+
if (!res.ok) return;
46+
const data = await res.json() as { favorites: string[]; votes: Record<string, "up" | "down"> };
47+
setUserFavorites((prev) => {
48+
const next = new Set(prev);
49+
for (const slug of data.favorites) next.add(slug);
50+
return next;
51+
});
52+
setUserVotes((prev) => {
53+
const next = new Map(prev);
54+
for (const [slug, type] of Object.entries(data.votes)) next.set(slug, type);
55+
return next;
56+
});
57+
setInteractionsLoaded(true);
58+
} catch {
59+
// Non-blocking — graceful degradation
60+
}
61+
}, [isAuthenticated]);
3262

33-
const loadMore = useCallback(async () => {
34-
if (loadingRef.current || !hasMoreRef.current) return;
63+
// Fetch interactions on mount for initial entries
64+
useEffect(() => {
65+
if (isAuthenticated && !interactionsLoaded) {
66+
fetchInteractions(entries.map((e) => e.slug));
67+
}
68+
}, [isAuthenticated, interactionsLoaded, entries, fetchInteractions]);
69+
70+
const fetchEntries = useCallback(async (
71+
sortMode: string,
72+
cat: string,
73+
offset: number,
74+
append: boolean,
75+
) => {
76+
if (loadingRef.current) return;
3577
loadingRef.current = true;
3678
setIsLoading(true);
3779

3880
try {
39-
const res = await fetch(
40-
`/api/leaderboard?sort=best&offset=${entriesLengthRef.current}&limit=${PAGE_SIZE}`
41-
);
42-
const data = (await res.json()) as {
43-
entries: LeaderboardEntry[];
44-
hasMore: boolean;
45-
};
46-
setEntries((prev) => {
47-
const updated = [...prev, ...data.entries];
48-
entriesLengthRef.current = updated.length;
49-
return updated;
81+
const params = new URLSearchParams({
82+
sort: sortMode,
83+
offset: String(offset),
84+
limit: String(PAGE_SIZE),
5085
});
86+
if (cat) params.set("category", cat);
87+
88+
const res = await fetch(`/api/leaderboard?${params}`);
89+
const data = await res.json() as { entries: LeaderboardEntry[]; hasMore: boolean };
90+
91+
if (append) {
92+
setEntries((prev) => {
93+
const updated = [...prev, ...data.entries];
94+
entriesLengthRef.current = updated.length;
95+
return updated;
96+
});
97+
} else {
98+
setEntries(data.entries);
99+
entriesLengthRef.current = data.entries.length;
100+
}
51101
hasMoreRef.current = data.hasMore;
52102
setHasMore(data.hasMore);
103+
104+
// Fetch interactions for new entries
105+
if (isAuthenticated && data.entries.length > 0) {
106+
fetchInteractions(data.entries.map((e) => e.slug));
107+
}
53108
} catch {
54109
// Silent fail — observer retries on next intersection
55110
} finally {
56111
loadingRef.current = false;
57112
setIsLoading(false);
58113
}
114+
}, [isAuthenticated, fetchInteractions]);
115+
116+
const loadMore = useCallback(() => {
117+
if (!hasMoreRef.current || loadingRef.current) return;
118+
fetchEntries(sortRef.current, categoryRef.current, entriesLengthRef.current, true);
119+
}, [fetchEntries]);
120+
121+
const handleSortChange = useCallback((newSort: string) => {
122+
setSort(newSort);
123+
sortRef.current = newSort;
124+
entriesLengthRef.current = 0;
125+
fetchEntries(newSort, categoryRef.current, 0, false);
126+
}, [fetchEntries]);
127+
128+
const handleCategoryChange = useCallback((newCategory: string) => {
129+
setCategory(newCategory);
130+
categoryRef.current = newCategory;
131+
entriesLengthRef.current = 0;
132+
fetchEntries(sortRef.current, newCategory, 0, false);
133+
}, [fetchEntries]);
134+
135+
// Optimistic vote handler
136+
const handleVote = useCallback(async (slug: string, type: "up" | "down" | "none") => {
137+
const prevVote = userVotes.get(slug) ?? null;
138+
const prevNetVotes = entries.find((e) => e.slug === slug)?.netVotes ?? 0;
139+
140+
// Calculate delta
141+
let delta = 0;
142+
if (type === "none") {
143+
delta = prevVote === "up" ? -1 : prevVote === "down" ? 1 : 0;
144+
} else if (type === "up") {
145+
delta = prevVote === "down" ? 2 : prevVote === "up" ? 0 : 1;
146+
} else {
147+
delta = prevVote === "up" ? -2 : prevVote === "down" ? 0 : -1;
148+
}
149+
150+
// Optimistic update
151+
setUserVotes((prev) => {
152+
const next = new Map(prev);
153+
if (type === "none") next.delete(slug);
154+
else next.set(slug, type);
155+
return next;
156+
});
157+
setEntries((prev) =>
158+
prev.map((e) => (e.slug === slug ? { ...e, netVotes: e.netVotes + delta } : e))
159+
);
160+
161+
try {
162+
const res = await fetch(`/api/skills/${slug}/vote`, {
163+
method: "POST",
164+
headers: { "Content-Type": "application/json" },
165+
body: JSON.stringify({ type }),
166+
});
167+
if (!res.ok) throw new Error("Vote failed");
168+
} catch {
169+
// Revert
170+
setUserVotes((prev) => {
171+
const next = new Map(prev);
172+
if (prevVote) next.set(slug, prevVote);
173+
else next.delete(slug);
174+
return next;
175+
});
176+
setEntries((prev) =>
177+
prev.map((e) => (e.slug === slug ? { ...e, netVotes: prevNetVotes } : e))
178+
);
179+
}
180+
}, [userVotes, entries]);
181+
182+
// Optimistic favorite handler
183+
const handleFavoriteToggle = useCallback(async (slug: string) => {
184+
const wasFavorited = userFavorites.has(slug);
185+
186+
// Optimistic update
187+
setUserFavorites((prev) => {
188+
const next = new Set(prev);
189+
if (wasFavorited) next.delete(slug);
190+
else next.add(slug);
191+
return next;
192+
});
193+
194+
try {
195+
const res = await fetch(`/api/skills/${slug}/favorite`, { method: "POST" });
196+
if (!res.ok) throw new Error("Favorite failed");
197+
} catch {
198+
// Revert
199+
setUserFavorites((prev) => {
200+
const next = new Set(prev);
201+
if (wasFavorited) next.add(slug);
202+
else next.delete(slug);
203+
return next;
204+
});
205+
}
206+
}, [userFavorites]);
207+
208+
const handleAuthRequired = useCallback(() => {
209+
window.location.href = `/api/auth/signin/github?callbackURL=${encodeURIComponent(window.location.pathname + window.location.search)}`;
59210
}, []);
60211

212+
// Infinite scroll observer
61213
useEffect(() => {
62214
const sentinel = sentinelRef.current;
63215
if (!sentinel) return;
@@ -75,7 +227,31 @@ export function HomeLeaderboard({
75227

76228
return (
77229
<>
78-
<LeaderboardTable entries={entries} />
230+
<LeaderboardControls
231+
sort={sort}
232+
onSortChange={handleSortChange}
233+
category={category}
234+
onCategoryChange={handleCategoryChange}
235+
categories={categories}
236+
/>
237+
238+
<LeaderboardTable
239+
entries={entries}
240+
onPreview={setPreviewEntry}
241+
userFavorites={userFavorites}
242+
userVotes={userVotes}
243+
isAuthenticated={isAuthenticated}
244+
onVote={handleVote}
245+
onFavoriteToggle={handleFavoriteToggle}
246+
onAuthRequired={handleAuthRequired}
247+
/>
248+
249+
{previewEntry && (
250+
<SkillPreviewModal
251+
entry={previewEntry}
252+
onClose={() => setPreviewEntry(null)}
253+
/>
254+
)}
79255

80256
{/* Infinite scroll sentinel */}
81257
<div ref={sentinelRef} className="flex justify-center py-8">
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const SORT_OPTIONS = [
2+
{ value: "best", label: "Best" },
3+
{ value: "rating", label: "Rating" },
4+
{ value: "installs", label: "Installs" },
5+
{ value: "trending", label: "Trending" },
6+
{ value: "newest", label: "Newest" },
7+
];
8+
9+
interface LeaderboardControlsProps {
10+
sort: string;
11+
onSortChange: (sort: string) => void;
12+
category: string;
13+
onCategoryChange: (category: string) => void;
14+
categories: string[];
15+
}
16+
17+
export function LeaderboardControls({
18+
sort,
19+
onSortChange,
20+
category,
21+
onCategoryChange,
22+
categories,
23+
}: LeaderboardControlsProps) {
24+
return (
25+
<div className="mb-4 flex flex-wrap items-center gap-3">
26+
{/* Sort tabs */}
27+
<div className="flex gap-1 rounded-lg border border-sx-border bg-sx-bg-elevated p-1">
28+
{SORT_OPTIONS.map((opt) => (
29+
<button
30+
key={opt.value}
31+
onClick={() => onSortChange(opt.value)}
32+
className={`rounded-md px-3 py-1.5 font-mono text-xs transition-colors ${
33+
sort === opt.value
34+
? "bg-sx-accent text-sx-bg font-bold"
35+
: "text-sx-fg-muted hover:text-sx-fg"
36+
}`}
37+
>
38+
{opt.label}
39+
</button>
40+
))}
41+
</div>
42+
43+
{/* Category filter */}
44+
<select
45+
value={category}
46+
onChange={(e) => onCategoryChange(e.target.value)}
47+
className="rounded-lg border border-sx-border bg-sx-bg-elevated px-3 py-1.5 font-mono text-xs text-sx-fg"
48+
>
49+
<option value="">All Categories</option>
50+
{categories.map((c) => (
51+
<option key={c} value={c}>
52+
{c}
53+
</option>
54+
))}
55+
</select>
56+
</div>
57+
);
58+
}

0 commit comments

Comments
 (0)