diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx index 1e88304d5..29364d207 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -24,6 +24,10 @@ vi.mock("../lib/useAuthStatus", () => ({ useAuthStatus: () => useAuthStatusMock(), })); +vi.mock("../components/SkillDiffCard", () => ({ + SkillDiffCard: () =>
, +})); + describe("SkillDetailPage", () => { const skillId = "skills:1" as Id<"skills">; const ownerId = "users:1" as Id<"users">; diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index a09e716ae..c1145c499 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -301,6 +301,29 @@ describe("SkillsIndex", () => { ); }); + it("shows and clears the active capability tag filter", async () => { + searchMock = { tag: "crypto" }; + render(); + await act(async () => {}); + + const capabilityChip = screen.getByRole("button", { name: /crypto/i }); + expect(capabilityChip).toBeTruthy(); + + await act(async () => { + fireEvent.click(capabilityChip); + }); + + expect(navigateMock).toHaveBeenCalled(); + const lastCall = navigateMock.mock.calls.at(-1)?.[0] as { + replace?: boolean; + search: (prev: Record) => Record; + }; + expect(lastCall.replace).toBe(true); + expect(lastCall.search({ tag: "crypto" })).toEqual({ + tag: undefined, + }); + }); + it("shows load-more button when more results are available", async () => { vi.stubGlobal("IntersectionObserver", undefined); convexHttpMock.query.mockResolvedValue({ diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5d5989f3c..baa9f1a67 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,13 +2,13 @@ import { useAuthActions } from "@convex-dev/auth/react"; import { Link } from "@tanstack/react-router"; import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react"; import { useMemo, useRef } from "react"; -import { getUserFacingAuthError } from "../lib/authErrorMessage"; import { gravatarUrl } from "../lib/gravatar"; import { isModerator } from "../lib/roles"; import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site"; import { applyTheme, useThemeMode } from "../lib/theme"; import { startThemeTransition } from "../lib/theme-transition"; -import { setAuthError, useAuthError } from "../lib/useAuthError"; +import { useAuthError } from "../lib/useAuthError"; +import { SignInButton } from "./SignInButton"; import { useAuthStatus } from "../lib/useAuthStatus"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Button } from "./ui/button"; @@ -24,7 +24,7 @@ import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; export default function Header() { const { isAuthenticated, isLoading, me } = useAuthStatus(); - const { signIn, signOut } = useAuthActions(); + const { signOut } = useAuthActions(); const { mode, setMode } = useThemeMode(); const toggleRef = useRef(null); const siteMode = getSiteMode(); @@ -37,7 +37,6 @@ export default function Header() { const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase(); const isStaff = isModerator(me); const { error: authError, clear: clearAuthError } = useAuthError(); - const signInRedirectTo = getCurrentRelativeUrl(); const setTheme = (next: "system" | "light" | "dark") => { startThemeTransition({ @@ -311,25 +310,14 @@ export default function Header() {
) : null} - + )} @@ -337,8 +325,3 @@ export default function Header() { ); } - -function getCurrentRelativeUrl() { - if (typeof window === "undefined") return "/"; - return `${window.location.pathname}${window.location.search}${window.location.hash}`; -} diff --git a/src/components/SignInButton.test.tsx b/src/components/SignInButton.test.tsx new file mode 100644 index 000000000..9bb015e1d --- /dev/null +++ b/src/components/SignInButton.test.tsx @@ -0,0 +1,84 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SignInButton } from "./SignInButton"; + +const signInMock = vi.fn(); +const clearAuthErrorMock = vi.fn(); +const setAuthErrorMock = vi.fn(); +const getUserFacingAuthErrorMock = vi.fn(); + +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: () => ({ + signIn: signInMock, + }), +})); + +vi.mock("../lib/useAuthError", () => ({ + clearAuthError: () => clearAuthErrorMock(), + setAuthError: (message: string) => setAuthErrorMock(message), +})); + +vi.mock("../lib/authErrorMessage", () => ({ + getUserFacingAuthError: (error: unknown, fallback: string) => + getUserFacingAuthErrorMock(error, fallback), +})); + +describe("SignInButton", () => { + beforeEach(() => { + signInMock.mockReset(); + clearAuthErrorMock.mockReset(); + setAuthErrorMock.mockReset(); + getUserFacingAuthErrorMock.mockReset(); + getUserFacingAuthErrorMock.mockImplementation((_, fallback) => fallback); + window.history.replaceState(null, "", "/skills?q=test#top"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("starts GitHub sign-in with the current relative URL by default", async () => { + signInMock.mockResolvedValue({ signingIn: true }); + + render(Sign in with GitHub); + fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" })); + + await waitFor(() => { + expect(signInMock).toHaveBeenCalledWith("github", { + redirectTo: "/skills?q=test#top", + }); + }); + expect(clearAuthErrorMock).toHaveBeenCalledTimes(1); + expect(setAuthErrorMock).not.toHaveBeenCalled(); + }); + + it("surfaces a generic error when sign-in resolves without redirecting", async () => { + signInMock.mockResolvedValue({ signingIn: false }); + + render(Sign in with GitHub); + fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" })); + + await waitFor(() => { + expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again."); + }); + }); + + it("surfaces user-facing auth errors when sign-in rejects", async () => { + const failure = new Error("oauth failed"); + signInMock.mockRejectedValue(failure); + getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable"); + + render(Sign in with GitHub); + fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" })); + + await waitFor(() => { + expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith( + failure, + "Sign in failed. Please try again.", + ); + expect(setAuthErrorMock).toHaveBeenCalledWith("GitHub auth unavailable"); + }); + }); +}); diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx new file mode 100644 index 000000000..c172f8795 --- /dev/null +++ b/src/components/SignInButton.tsx @@ -0,0 +1,48 @@ +import { useAuthActions } from "@convex-dev/auth/react"; +import type { ComponentProps } from "react"; +import { getUserFacingAuthError } from "../lib/authErrorMessage"; +import { clearAuthError, setAuthError } from "../lib/useAuthError"; +import { Button } from "./ui/button"; + +type ButtonProps = ComponentProps; + +type SignInButtonProps = Omit & { + redirectTo?: string; +}; + +export function SignInButton({ + redirectTo, + children = "Sign in with GitHub", + ...props +}: SignInButtonProps) { + const { signIn } = useAuthActions(); + + return ( + + ); +} + +function getCurrentRelativeUrl() { + if (typeof window === "undefined") return "/"; + return `${window.location.pathname}${window.location.search}${window.location.hash}`; +} diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index 4bb3048f9..be2dfc13d 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { api } from "../../convex/_generated/api"; import type { Doc, Id } from "../../convex/_generated/dataModel"; import { canManageSkill, isModerator } from "../lib/roles"; +import { hasOwnProperty } from "../lib/hasOwnProperty"; import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage"; import { useAuthStatus } from "../lib/useAuthStatus"; import { ClientOnly } from "./ClientOnly"; @@ -37,13 +38,11 @@ type SkillDetailPageProps = { type SkillFile = Doc<"skillVersions">["files"][number]; function formatReportError(error: unknown) { - if (error && typeof error === "object" && "data" in error) { + if (hasOwnProperty(error, "data")) { const data = (error as { data?: unknown }).data; if (typeof data === "string" && data.trim()) return data.trim(); if ( - data && - typeof data === "object" && - "message" in data && + hasOwnProperty(data, "message") && typeof (data as { message?: unknown }).message === "string" ) { const message = (data as { message?: string }).message?.trim(); diff --git a/src/components/UserBadge.tsx b/src/components/UserBadge.tsx index 3be52f86a..7f727f6e0 100644 --- a/src/components/UserBadge.tsx +++ b/src/components/UserBadge.tsx @@ -1,3 +1,4 @@ +import { hasOwnProperty } from "../lib/hasOwnProperty"; import type { PublicPublisher, PublicUser } from "../lib/publicUser"; type UserBadgeProps = { @@ -17,11 +18,13 @@ export function UserBadge({ link = true, showName = false, }: UserBadgeProps) { - const userName = user && "name" in user ? user.name?.trim() : undefined; + const userName = hasOwnProperty(user, "name") && typeof user.name === "string" + ? user.name.trim() + : undefined; const displayName = user?.displayName?.trim() || userName || null; const handle = user?.handle ?? fallbackHandle ?? null; const href = - user?.handle && "kind" in user + user?.handle && hasOwnProperty(user, "kind") ? user.kind === "org" ? `/orgs/${encodeURIComponent(user.handle)}` : `/u/${encodeURIComponent(user.handle)}` diff --git a/src/lib/categories.ts b/src/lib/categories.ts new file mode 100644 index 000000000..b585e611d --- /dev/null +++ b/src/lib/categories.ts @@ -0,0 +1,19 @@ +export type SkillCategory = { + slug: string; + label: string; + keywords: string[]; +}; + +export const SKILL_CATEGORIES: SkillCategory[] = [ + { slug: "mcp-tools", label: "MCP Tools", keywords: ["mcp", "tool", "server"] }, + { slug: "prompts", label: "Prompts", keywords: ["prompt", "template", "system"] }, + { slug: "workflows", label: "Workflows", keywords: ["workflow", "pipeline", "chain"] }, + { slug: "dev-tools", label: "Dev Tools", keywords: ["dev", "debug", "lint", "test", "build"] }, + { slug: "data", label: "Data & APIs", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] }, + { slug: "security", label: "Security", keywords: ["security", "scan", "auth", "encrypt"] }, + { slug: "automation", label: "Automation", keywords: ["auto", "cron", "schedule", "bot"] }, + { slug: "other", label: "Other", keywords: [] }, +]; + +export const ALL_CATEGORY_KEYWORDS = SKILL_CATEGORIES.flatMap((c) => c.keywords); + diff --git a/src/lib/convexError.ts b/src/lib/convexError.ts index c4ac3e399..259449f15 100644 --- a/src/lib/convexError.ts +++ b/src/lib/convexError.ts @@ -1,3 +1,5 @@ +import { hasOwnProperty } from "./hasOwnProperty"; + type ConvexLikeErrorData = | string | { @@ -24,9 +26,12 @@ export function getUserFacingConvexError(error: unknown, fallback: string) { const candidates: string[] = []; const maybe = error as ConvexLikeError; - if (maybe && typeof maybe === "object" && "data" in maybe) { + if (hasOwnProperty(maybe, "data")) { if (typeof maybe.data === "string") candidates.push(maybe.data); - if (maybe.data && typeof maybe.data === "object" && typeof maybe.data.message === "string") { + if ( + hasOwnProperty(maybe.data, "message") && + typeof maybe.data.message === "string" + ) { candidates.push(maybe.data.message); } } diff --git a/src/lib/hasOwnProperty.ts b/src/lib/hasOwnProperty.ts new file mode 100644 index 000000000..65262f9df --- /dev/null +++ b/src/lib/hasOwnProperty.ts @@ -0,0 +1,6 @@ +export function hasOwnProperty( + value: unknown, + key: K, +): value is Record { + return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key); +} diff --git a/src/lib/packageApi.ts b/src/lib/packageApi.ts index 2556eceab..ccd69e599 100644 --- a/src/lib/packageApi.ts +++ b/src/lib/packageApi.ts @@ -4,6 +4,7 @@ import type { PackageVerificationSummary, } from "clawhub-schema"; import { ApiRoutes } from "clawhub-schema/routes"; +import { hasOwnProperty } from "./hasOwnProperty"; import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv"; export type PackageListItem = { @@ -117,6 +118,11 @@ type PluginCatalogResult = { nextCursor: string | null; }; +type PackageCatalogBrowseResponse = { + items: PackageListItem[]; + nextCursor: string | null; +}; + type PackageApiErrorOptions = { status: number; retryAfterSeconds?: number | null; @@ -302,10 +308,17 @@ export async function fetchPluginCatalog(params: { executesCode: params.executesCode, limit: params.limit, }); + if (hasOwnProperty(response, "results") && Array.isArray(response.results)) { + return { + items: response.results.map((entry) => entry.package), + nextCursor: null, + }; + } + + const browseResponse = response as PackageCatalogBrowseResponse; return { - items: - "results" in response ? response.results.map((entry) => entry.package) : response.items, - nextCursor: "results" in response ? null : response.nextCursor, + items: browseResponse.items, + nextCursor: browseResponse.nextCursor, }; } diff --git a/src/routes/-settings.test.tsx b/src/routes/-settings.test.tsx index f51184058..51665d730 100644 --- a/src/routes/-settings.test.tsx +++ b/src/routes/-settings.test.tsx @@ -5,17 +5,26 @@ import { Settings } from "./settings"; const useQueryMock = vi.fn(); const useMutationMock = vi.fn(); +const useAuthActionsMock = vi.fn(); vi.mock("convex/react", () => ({ useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: (...args: unknown[]) => useMutationMock(...args), })); +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: () => useAuthActionsMock(), +})); + describe("Settings", () => { beforeEach(() => { useQueryMock.mockReset(); useMutationMock.mockReset(); + useAuthActionsMock.mockReset(); useMutationMock.mockReturnValue(vi.fn()); + useAuthActionsMock.mockReturnValue({ + signIn: vi.fn(), + }); }); it("skips token loading until auth has resolved", () => { diff --git a/src/routes/cli/auth.tsx b/src/routes/cli/auth.tsx index c97bce87f..cc4dcb727 100644 --- a/src/routes/cli/auth.tsx +++ b/src/routes/cli/auth.tsx @@ -1,14 +1,12 @@ -import { useAuthActions } from "@convex-dev/auth/react"; import { createFileRoute } from "@tanstack/react-router"; import { useMutation } from "convex/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { api } from "../../../convex/_generated/api"; import { Container } from "../../components/layout/Container"; -import { Button } from "../../components/ui/button"; +import { SignInButton } from "../../components/SignInButton"; import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; -import { getUserFacingAuthError } from "../../lib/authErrorMessage"; import { getClawHubSiteUrl, normalizeClawHubSiteOrigin } from "../../lib/site"; -import { setAuthError, useAuthError } from "../../lib/useAuthError"; +import { useAuthError } from "../../lib/useAuthError"; import { useAuthStatus } from "../../lib/useAuthStatus"; export const Route = createFileRoute("/cli/auth")({ @@ -17,7 +15,6 @@ export const Route = createFileRoute("/cli/auth")({ function CliAuth() { const { isAuthenticated, isLoading, me } = useAuthStatus(); - const { signIn } = useAuthActions(); const { error: authError, clear: clearAuthError } = useAuthError(); const createToken = useMutation(api.tokens.create); @@ -35,7 +32,6 @@ function CliAuth() { const label = (decodeLabel(search.label_b64) ?? search.label ?? "CLI token").trim() || "CLI token"; const state = typeof search.state === "string" ? search.state.trim() : ""; - const signInRedirectTo = getCurrentRelativeUrl(); const safeRedirect = useMemo(() => isAllowedRedirectUri(redirectUri), [redirectUri]); const registry = useMemo(() => { @@ -139,23 +135,12 @@ function CliAuth() {

) : null} - + @@ -214,8 +199,3 @@ function decodeLabel(value: string | undefined) { return null; } } - -function getCurrentRelativeUrl() { - if (typeof window === "undefined") return "/"; - return `${window.location.pathname}${window.location.search}${window.location.hash}`; -} diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 27d3aac0c..4e80503ca 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -18,6 +18,7 @@ import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { EmptyState } from "../components/EmptyState"; import { Container } from "../components/layout/Container"; +import { SignInButton } from "../components/SignInButton"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent } from "../components/ui/card"; @@ -120,7 +121,10 @@ function Dashboard() { return ( - Sign in to access your dashboard. + + Sign in to access your dashboard. + Sign in with GitHub + ); diff --git a/src/routes/import.tsx b/src/routes/import.tsx index 95c830209..450bd40d0 100644 --- a/src/routes/import.tsx +++ b/src/routes/import.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { api } from "../../convex/_generated/api"; import { EmptyState } from "../components/EmptyState"; import { Container } from "../components/layout/Container"; +import { SignInButton } from "../components/SignInButton"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card } from "../components/ui/card"; @@ -224,7 +225,11 @@ export function ImportGitHub() { + > + {!isLoading ? ( + Sign in with GitHub + ) : null} + ); diff --git a/src/routes/publish-skill.tsx b/src/routes/publish-skill.tsx index a159ef3b1..ba6848c7a 100644 --- a/src/routes/publish-skill.tsx +++ b/src/routes/publish-skill.tsx @@ -1,4 +1,3 @@ -import { useAuthActions } from "@convex-dev/auth/react"; import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { PLATFORM_SKILL_LICENSE, @@ -14,6 +13,7 @@ import { api } from "../../convex/_generated/api"; import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits"; import { EmptyState } from "../components/EmptyState"; import { Container } from "../components/layout/Container"; +import { SignInButton } from "../components/SignInButton"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent, CardTitle } from "../components/ui/card"; @@ -44,7 +44,6 @@ export const Route = createFileRoute("/publish-skill")({ export function Upload() { const { isAuthenticated, me } = useAuthStatus(); - const { signIn } = useAuthActions(); const { updateSlug } = useSearch({ from: "/publish-skill" }); const siteMode = getSiteMode(); const isSoulMode = siteMode === "souls"; @@ -347,8 +346,9 @@ export function Upload() { void signIn("github") }} - /> + > + Sign in with GitHub + ); @@ -364,7 +364,7 @@ export function Upload() { event.preventDefault(); setHasAttempted(true); if (!validation.ready) { - if (validationRef.current && "scrollIntoView" in validationRef.current) { + if (typeof validationRef.current?.scrollIntoView === "function") { validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); } return; diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 16ec70eaa..efa04feff 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -9,6 +9,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; +import { SignInButton } from "../components/SignInButton"; import { Dialog, DialogContent, @@ -107,7 +108,10 @@ export function Settings() { return ( - Sign in to access settings. + + Sign in to access settings. + Sign in with GitHub + ); diff --git a/src/routes/skills/-SkillsToolbar.tsx b/src/routes/skills/-SkillsToolbar.tsx index 125ea0945..38a002492 100644 --- a/src/routes/skills/-SkillsToolbar.tsx +++ b/src/routes/skills/-SkillsToolbar.tsx @@ -1,6 +1,22 @@ -import { ArrowDownUp, Check, Grid3X3, List, Search, X } from "lucide-react"; +import { + ArrowDownUp, + Check, + Database, + GitBranch, + Grid3X3, + List, + MessageSquare, + Package, + Plug, + Search, + Shield, + Wrench, + X, + Zap, +} from "lucide-react"; import type { RefObject } from "react"; -import { SKILL_CAPABILITY_TAGS } from "../../../convex/lib/skillCapabilityTags"; +import { useMemo } from "react"; +import { SKILL_CATEGORIES, type SkillCategory } from "../../lib/categories"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { @@ -40,6 +56,17 @@ const SKILL_CAPABILITY_LABELS: Record = { "posts-externally": "External posting", }; +const CATEGORY_ICONS: Record = { + "mcp-tools": , + prompts: , + workflows: , + "dev-tools": , + data: , + security: , + automation: , + other: , +}; + export function SkillsToolbar({ searchInputRef, query, @@ -58,6 +85,26 @@ export function SkillsToolbar({ onToggleDir, onToggleView, }: SkillsToolbarProps) { + const activeCategory = useMemo(() => { + if (query === "__other__") return "other"; + if (!query) return undefined; + return SKILL_CATEGORIES.find((c) => + c.keywords.some((k) => k === query.trim().toLowerCase()), + )?.slug; + }, [query]); + + const handleCategoryChange = (cat: SkillCategory | undefined) => { + if (!cat) { + onQueryChange(""); + } else if (cat.slug === "other") { + onQueryChange("__other__"); + } else if (cat.keywords[0]) { + onQueryChange(cat.keywords[0]); + } else { + onQueryChange(""); + } + }; + return (
{/* Search row */} @@ -91,18 +138,30 @@ export function SkillsToolbar({ Clean only - handleCategoryChange(v === "__all__" ? undefined : SKILL_CATEGORIES.find((c) => c.slug === v))}> - + - All tags - {SKILL_CAPABILITY_TAGS.map((tag) => ( - - {SKILL_CAPABILITY_LABELS[tag] ?? tag} + All categories + {SKILL_CATEGORIES.map((cat) => ( + + + {CATEGORY_ICONS[cat.slug]} + {cat.label} + ))} @@ -131,11 +190,11 @@ export function SkillsToolbar({ ); diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index ef712b8ab..68f5cd02b 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -2,6 +2,7 @@ import { useAction } from "convex/react"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { api } from "../../../convex/_generated/api"; import { convexHttp } from "../../convex/client"; +import { ALL_CATEGORY_KEYWORDS } from "../../lib/categories"; import { parseDir, parseSort, toListSort, type SortDir, type SortKey } from "./-params"; import type { SkillListEntry, SkillSearchEntry } from "./-types"; @@ -60,8 +61,9 @@ export function useSkillsBrowseModel({ const capabilityTag = search.tag; const searchSkills = useAction(api.search.searchSkills); + const isOtherCategory = query === "__other__"; const trimmedQuery = useMemo(() => query.trim(), [query]); - const hasQuery = trimmedQuery.length > 0; + const hasQuery = !isOtherCategory && trimmedQuery.length > 0; const sort: SortKey = search.sort === "relevance" && !hasQuery ? "downloads" @@ -192,6 +194,12 @@ export function useSkillsBrowseModel({ }, [hasQuery, listResults, searchResults]); const sorted = useMemo(() => { + if (isOtherCategory) { + return baseItems.filter((entry) => { + const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase(); + return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw)); + }); + } if (!hasQuery) { return baseItems; } @@ -233,7 +241,7 @@ export function useSkillsBrowseModel({ } }); return results; - }, [baseItems, dir, hasQuery, sort]); + }, [baseItems, dir, hasQuery, isOtherCategory, sort]); const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList; const canLoadMore = hasQuery diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index d89449c71..a49f62723 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -78,11 +78,11 @@ export function SkillsIndex() {

Skills - {totalSkillsText && ( - - ({totalSkillsText}) - - )} + + ({model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly + ? model.sorted.length.toLocaleString("en-US") + : totalSkillsText ?? "…"}) +

{model.isLoadingSkills @@ -112,11 +112,14 @@ export function SkillsIndex() { /> {/* Results count */} - {!model.isLoadingSkills && model.sorted.length > 0 && ( + {model.sorted.length > 0 && (

- Showing {model.sorted.length} - {totalSkillsText ? ` of ${totalSkillsText}` : ""} skills + {model.sorted.length} + {!model.hasQuery && totalSkillsText ? ` of ${totalSkillsText}` : ""} skills {model.hasQuery ? ` matching "${model.query}"` : ""} + {model.highlightedOnly || model.nonSuspiciousOnly || model.capabilityTag + ? ` (filtered)` + : ""}

)} diff --git a/src/routes/stars.tsx b/src/routes/stars.tsx index 512b21d59..d12b7ebe4 100644 --- a/src/routes/stars.tsx +++ b/src/routes/stars.tsx @@ -6,6 +6,7 @@ import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { EmptyState } from "../components/EmptyState"; import { Container } from "../components/layout/Container"; +import { SignInButton } from "../components/SignInButton"; import { Button } from "../components/ui/button"; import { formatCompactStat } from "../lib/numberFormat"; import type { PublicSkill } from "../lib/publicUser"; @@ -31,7 +32,9 @@ function Stars() { icon={Star} title="Sign in to see your highlights" description="Star skills for quick access later." - /> + > + Sign in with GitHub + ); diff --git a/src/routes/u/$handle.tsx b/src/routes/u/$handle.tsx index be0f8bdf5..f8d61d084 100644 --- a/src/routes/u/$handle.tsx +++ b/src/routes/u/$handle.tsx @@ -7,6 +7,7 @@ import { api } from "../../../convex/_generated/api"; import type { Doc } from "../../../convex/_generated/dataModel"; import { EmptyState } from "../../components/EmptyState"; import { Container } from "../../components/layout/Container"; +import { SignInButton } from "../../components/SignInButton"; import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton"; import { SkillCard } from "../../components/SkillCard"; import { SkillStatsTripletLine } from "../../components/SkillStats"; @@ -236,7 +237,9 @@ function InstalledSection(props: { return (

Installed

- + + Sign in with GitHub +
); } diff --git a/vite.config.ts b/vite.config.ts index 3e85c4dc2..792f5cda4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,7 @@ import { devtools } from "@tanstack/devtools-vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import { nitro } from "nitro/vite"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import viteTsConfigPaths from "vite-tsconfig-paths"; const require = createRequire(import.meta.url); @@ -40,6 +40,123 @@ function handleRollupWarning( warn(warning); } +type SourceReplacement = readonly [from: string, to: string]; + +const reflectHas = (target: string, key: string) => `Reflect.has(${target}, ${JSON.stringify(key)})`; + +const arkSafariInOperatorFixes = [ + { + suffix: "/node_modules/.vite/deps/arktype.js", + replacements: [ + ['"expression" in value', reflectHas("value", "expression")], + ['"toJSON" in o', reflectHas("o", "toJSON")], + ['"morphs" in schema', reflectHas("schema", "morphs")], + ['"branches" in schema', reflectHas("schema", "branches")], + ['"unit" in schema', reflectHas("schema", "unit")], + ['"reference" in schema', reflectHas("schema", "reference")], + ['"proto" in schema', reflectHas("schema", "proto")], + ['"domain" in schema', reflectHas("schema", "domain")], + ['"value" in transformedInner', reflectHas("transformedInner", "value")], + ['"default" in this.inner', reflectHas("this.inner", "default")], + ['"variadic" in schema', reflectHas("schema", "variadic")], + ['"prefix" in schema', reflectHas("schema", "prefix")], + ['"defaultables" in schema', reflectHas("schema", "defaultables")], + ['"optionals" in schema', reflectHas("schema", "optionals")], + ['"postfix" in schema', reflectHas("schema", "postfix")], + ['"minVariadicLength" in schema', reflectHas("schema", "minVariadicLength")], + ['"description" in ctx', reflectHas("ctx", "description")], + ['"data" in input', reflectHas("input", "data")], + ['"get" in desc', reflectHas("desc", "get")], + ['"set" in desc', reflectHas("desc", "set")], + ] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/util/out/serialize.js", + replacements: [ + ['"expression" in value', reflectHas("value", "expression")], + ['"toJSON" in o', reflectHas("o", "toJSON")], + ], + }, + { + suffix: "/node_modules/@ark/schema/out/parse.js", + replacements: [ + ['"morphs" in schema', reflectHas("schema", "morphs")], + ['"branches" in schema', reflectHas("schema", "branches")], + ['"unit" in schema', reflectHas("schema", "unit")], + ['"reference" in schema', reflectHas("schema", "reference")], + ['"proto" in schema', reflectHas("schema", "proto")], + ['"domain" in schema', reflectHas("schema", "domain")], + ] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/schema/out/node.js", + replacements: [['"value" in transformedInner', reflectHas("transformedInner", "value")]] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/schema/out/scope.js", + replacements: [['"branches" in schema', reflectHas("schema", "branches")]] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/schema/out/structure/optional.js", + replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/schema/out/structure/sequence.js", + replacements: [ + ['"variadic" in schema', reflectHas("schema", "variadic")], + ['"prefix" in schema', reflectHas("schema", "prefix")], + ['"defaultables" in schema', reflectHas("schema", "defaultables")], + ['"optionals" in schema', reflectHas("schema", "optionals")], + ['"postfix" in schema', reflectHas("schema", "postfix")], + ['"minVariadicLength" in schema', reflectHas("schema", "minVariadicLength")], + ] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/schema/out/structure/prop.js", + replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/schema/out/shared/implement.js", + replacements: [['"description" in ctx', reflectHas("ctx", "description")]] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/schema/out/shared/errors.js", + replacements: [['"data" in input', reflectHas("input", "data")]] satisfies SourceReplacement[], + }, + { + suffix: "/node_modules/@ark/util/out/clone.js", + replacements: [ + ['"get" in desc', reflectHas("desc", "get")], + ['"set" in desc', reflectHas("desc", "set")], + ] satisfies SourceReplacement[], + }, +] as const; + +function patchArkSafariInOperator(): Plugin { + return { + name: "patch-ark-safari-in-operator", + enforce: "pre", + transform(code, id) { + const normalizedId = id.split("?")[0].replace(/\\/g, "/"); + const fix = arkSafariInOperatorFixes.find((entry) => normalizedId.endsWith(entry.suffix)); + if (!fix) return null; + + let nextCode = code; + for (const [from, to] of fix.replacements) { + if (!nextCode.includes(from)) { + this.error(`Expected to patch ${from} in ${normalizedId}`); + } + nextCode = nextCode.replaceAll(from, to); + } + + return { + code: nextCode, + map: null, + }; + }, + }; +} + const config = defineConfig({ resolve: { dedupe: ["convex", "@convex-dev/auth", "react", "react-dom"], @@ -54,6 +171,7 @@ const config = defineConfig({ include: ["convex/react", "convex/browser"], }, plugins: [ + patchArkSafariInOperator(), devtools(), nitro({ serverDir: "server",