diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 888d62a11..2629ba151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,13 @@ jobs: - name: Test run: bun run test + env: + VITE_CONVEX_URL: https://example.invalid - name: Coverage run: bun run coverage + env: + VITE_CONVEX_URL: https://example.invalid - name: ClawHub CLI Verify run: bun run --cwd packages/clawhub verify diff --git a/convex/users.ts b/convex/users.ts index cc0de3231..3efa7602f 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -467,6 +467,41 @@ export const getByHandle = query({ }, }); +/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */ +export const getHoverStats = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + const skills = []; + let cursor: string | null = null; + + for (;;) { + const page = await ctx.db + .query("skills") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.userId)) + .paginate({ cursor, numItems: 100 }); + skills.push(...page.page); + if (page.isDone) { + break; + } + cursor = page.continueCursor; + } + + const active = skills.filter((s) => !s.softDeletedAt); + let totalStars = 0; + let totalDownloads = 0; + for (const s of active) { + totalStars += s.stats?.stars ?? 0; + totalDownloads += s.stats?.downloads ?? 0; + } + + return { + publishedSkills: active.length, + totalStars, + totalDownloads, + }; + }, +}); + export const getReservedHandleInternal = internalQuery({ args: { handle: v.string() }, handler: async (ctx, args) => { diff --git a/public/clawd-logo.png b/public/clawd-logo.png index 450e7d406..5b4f98839 100644 Binary files a/public/clawd-logo.png and b/public/clawd-logo.png differ diff --git a/public/clawd-mark.png b/public/clawd-mark.png index dd8a2c7ed..7533d1545 100644 Binary files a/public/clawd-mark.png and b/public/clawd-mark.png differ diff --git a/public/favicon.ico b/public/favicon.ico index ff0aff670..f5619bbfc 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 000000000..2476f5188 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo192.png b/public/logo192.png index fc44b0a37..1a63f2d5c 100644 Binary files a/public/logo192.png and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png index a4e47a654..5b4f98839 100644 Binary files a/public/logo512.png and b/public/logo512.png differ diff --git a/src/__tests__/header.test.tsx b/src/__tests__/header.test.tsx index 7e9b57c86..846466f96 100644 --- a/src/__tests__/header.test.tsx +++ b/src/__tests__/header.test.tsx @@ -20,12 +20,14 @@ vi.mock("@convex-dev/auth/react", () => ({ }), })); +const authStatusMock = vi.fn(() => ({ + isAuthenticated: false, + isLoading: false, + me: null, +})); + vi.mock("../lib/useAuthStatus", () => ({ - useAuthStatus: () => ({ - isAuthenticated: false, - isLoading: false, - me: null, - }), + useAuthStatus: () => authStatusMock(), })); vi.mock("../lib/theme", () => ({ @@ -125,5 +127,4 @@ describe("Header", () => { expect(screen.getAllByText("Users")).toHaveLength(2); expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy(); expect(convexQueryMock).not.toHaveBeenCalled(); - }); -}); + });}); diff --git a/src/__tests__/import.route.test.tsx b/src/__tests__/import.route.test.tsx index 0fba7b1e3..9de383615 100644 --- a/src/__tests__/import.route.test.tsx +++ b/src/__tests__/import.route.test.tsx @@ -17,6 +17,7 @@ const useAuthStatusMock = vi.fn(); let useActionCallCount = 0; vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useQuery: (...args: unknown[]) => useQueryMock(...args), useAction: () => { const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3]; diff --git a/src/__tests__/packages-publish-route.test.tsx b/src/__tests__/packages-publish-route.test.tsx index 209c80ce3..9d1425644 100644 --- a/src/__tests__/packages-publish-route.test.tsx +++ b/src/__tests__/packages-publish-route.test.tsx @@ -26,6 +26,7 @@ const useAuthStatusMock = vi.fn(); const originalFetch = globalThis.fetch; vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useMutation: () => generateUploadUrl, useAction: () => publishRelease, useQuery: () => undefined, diff --git a/src/__tests__/search-route.test.ts b/src/__tests__/search-route.test.ts index d7dc4449c..adb2dd907 100644 --- a/src/__tests__/search-route.test.ts +++ b/src/__tests__/search-route.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it, vi } from "vitest"; +process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud"; + + +vi.mock("../convex/client", () => ({ + convex: {}, + convexHttp: { query: vi.fn() }, +})); + vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({ __config: config, diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx index 2e3cf4e79..60c3fb415 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -6,6 +6,18 @@ import { SkillDetailPage } from "../components/SkillDetailPage"; const navigateMock = vi.fn(); const useAuthStatusMock = vi.fn(); +process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud"; + + +vi.mock("../components/UserBadge", () => ({ + UserBadge: () => null, +})); + +vi.mock("../convex/client", () => ({ + convex: {}, + convexHttp: { query: vi.fn() }, +})); + vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: unknown }) => children, useNavigate: () => navigateMock, @@ -15,6 +27,7 @@ const useQueryMock = vi.fn(); const getReadmeMock = vi.fn(); vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: () => vi.fn(), useAction: () => getReadmeMock, diff --git a/src/__tests__/skill-route-loader.test.ts b/src/__tests__/skill-route-loader.test.ts index bb64a85ef..5aac513a5 100644 --- a/src/__tests__/skill-route-loader.test.ts +++ b/src/__tests__/skill-route-loader.test.ts @@ -17,6 +17,7 @@ vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: { + beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown; loader?: (args: { params: { owner: string; slug: string } }) => Promise; component?: unknown; head?: unknown; @@ -31,6 +32,7 @@ vi.mock("../lib/skillPage", () => ({ async function loadRoute() { return (await import("../routes/$owner/$slug")).Route as unknown as { __config: { + beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown; loader?: (args: { params: { owner: string; slug: string } }) => Promise; head?: (args: { params: { owner: string; slug: string }; @@ -45,6 +47,14 @@ async function loadRoute() { }; } +async function runBeforeLoad(params: { owner: string; slug: string }) { + const route = await loadRoute(); + const beforeLoad = route.__config.beforeLoad as ((args: { + params: { owner: string; slug: string }; + }) => unknown) | undefined; + return beforeLoad?.({ params }); +} + async function runLoader(params: { owner: string; slug: string }) { const route = await loadRoute(); const loader = route.__config.loader as (args: { @@ -71,6 +81,18 @@ function runHead( } describe("skill route loader", () => { + it("allows numeric owner handles in beforeLoad", () => { + expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow(); + }); + + it("allows raw owner ids in beforeLoad", () => { + expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow(); + }); + + it("allows raw publisher ids in beforeLoad", () => { + expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow(); + }); + beforeEach(() => { fetchSkillPageDataMock.mockReset(); }); diff --git a/src/__tests__/skills-index-load-more.test.tsx b/src/__tests__/skills-index-load-more.test.tsx index bf6165375..418134a09 100644 --- a/src/__tests__/skills-index-load-more.test.tsx +++ b/src/__tests__/skills-index-load-more.test.tsx @@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useAction: (...args: unknown[]) => convexReactMocks.useAction(...args), useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args), })); diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index 78a4291fb..f99f2f659 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useAction: (...args: unknown[]) => convexReactMocks.useAction(...args), useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args), })); diff --git a/src/__tests__/upload.route.test.tsx b/src/__tests__/upload.route.test.tsx index 017a439c0..526916dc8 100644 --- a/src/__tests__/upload.route.test.tsx +++ b/src/__tests__/upload.route.test.tsx @@ -22,6 +22,7 @@ const useAuthStatusMock = vi.fn(); let useActionCallCount = 0; vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: () => generateUploadUrl, useAction: () => { diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index 76aecdbcb..950736147 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import { convex } from "../convex/client"; import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage"; import { clearAuthError, setAuthError } from "../lib/useAuthError"; +import { TooltipProvider } from "./ui/tooltip"; import { UserBootstrap } from "./UserBootstrap"; function getPendingAuthCode() { @@ -82,10 +83,12 @@ export function AuthErrorHandler() { export function AppProviders({ children }: { children: React.ReactNode }) { return ( - - - - {children} + + + + + {children} + ); } diff --git a/src/components/DeploymentDriftBanner.tsx b/src/components/DeploymentDriftBanner.tsx index a3419dc58..6e4b360d8 100644 --- a/src/components/DeploymentDriftBanner.tsx +++ b/src/components/DeploymentDriftBanner.tsx @@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() { return (
Deploy mismatch detected. Frontend expects backend build {drift.expectedBuildSha}{" "} but Convex reports {drift.actualBuildSha}. diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 8aa440b93..8d0a638fb 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router"; +import { FOOTER_NAV_SECTIONS } from "../lib/nav-items"; import { getSiteName } from "../lib/site"; export function Footer() { @@ -8,90 +9,29 @@ export function Footer() {
diff --git a/src/components/InstallSwitcher.tsx b/src/components/InstallSwitcher.tsx index ac4816a47..db6642306 100644 --- a/src/components/InstallSwitcher.tsx +++ b/src/components/InstallSwitcher.tsx @@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro type="button" className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${ pm === entry.id - ? "bg-[color:var(--accent)] text-white shadow-sm" + ? "bg-accent text-accent-fg shadow-sm" : "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]" }`} role="tab" diff --git a/src/components/PluginListItem.tsx b/src/components/PluginListItem.tsx index b55a9105c..adad956c1 100644 --- a/src/components/PluginListItem.tsx +++ b/src/components/PluginListItem.tsx @@ -1,5 +1,6 @@ import { Link } from "@tanstack/react-router"; import { MarketplaceIcon } from "./MarketplaceIcon"; +import { Badge } from "./ui/badge"; import { familyLabel } from "../lib/packageLabels"; import type { PackageListItem } from "../lib/packageApi"; @@ -20,8 +21,8 @@ export function PluginListItem({ item }: PluginListItemProps) { ) : null} {item.displayName} - {familyLabel(item.family)} - {item.isOfficial ? Verified : null} + {familyLabel(item.family)} + {item.isOfficial ? Verified : null}

{item.summary ?? "Plugin package for agent workflows."}

diff --git a/src/components/SkillCard.tsx b/src/components/SkillCard.tsx index 1eca6134f..60aa5fe47 100644 --- a/src/components/SkillCard.tsx +++ b/src/components/SkillCard.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; import type { ReactNode } from "react"; import { MarketplaceIcon } from "./MarketplaceIcon"; +import { Badge } from "./ui/badge"; import type { PublicSkill } from "../lib/publicUser"; type SkillCardProps = { @@ -32,15 +33,15 @@ export function SkillCard({ {hasTags ? (
{badges.map((label) => ( -
+ {label} -
+ ))} - {chip ?
{chip}
: null} + {chip ? {chip} : null} {platformLabels?.map((label) => ( -
+ {label} -
+ ))}
) : null} diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index 530a11f39..24997a11b 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -7,6 +7,7 @@ import type { Doc, Id } from "../../convex/_generated/dataModel"; import { canManageSkill, isModerator } from "../lib/roles"; import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage"; import { useAuthStatus } from "../lib/useAuthStatus"; +import { Card } from "./ui/card"; import { ClientOnly } from "./ClientOnly"; import { SkillCommentsPanel } from "./SkillCommentsPanel"; import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs"; @@ -330,9 +331,9 @@ export function SkillDetailPage({ if (isLoadingSkill || wantsCanonicalRedirect) { return (
-
+
Loading skill…
-
+
); } @@ -340,7 +341,7 @@ export function SkillDetailPage({ if (result === null || !skill) { return (
-
Skill not found.
+ Skill not found.
); } @@ -403,25 +404,25 @@ export function SkillDetailPage({
{nixSnippet ? ( -
-

+ +

Install via Nix

-
+                
                   {nixSnippet}
                 
-

+ ) : null} {configExample ? ( -
-

+ +

Config example

-
+                
                   {configExample}
                 
-

+ ) : null} -

+ +

Comments

-

+

Loading comments...

-

+ } > {readmeContent}
) : readmeError ? ( -
+

No README available

This skill doesn't have a SKILL.md file yet.

) : ( -
+
Loading README...
)} diff --git a/src/components/SkillDiffCard.tsx b/src/components/SkillDiffCard.tsx index 3fff78cb0..72678995c 100644 --- a/src/components/SkillDiffCard.tsx +++ b/src/components/SkillDiffCard.tsx @@ -13,6 +13,7 @@ import { selectDefaultFilePath, sortVersionsBySemver, } from "../lib/diffing"; +import { Button } from "./ui/button"; import { ClientOnly } from "./ClientOnly"; type SkillDiffCardProps = { @@ -278,10 +279,10 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
-

+

Compare versions

-

+

Inline or side-by-side diff for any file.

@@ -323,8 +324,8 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa {renderOptions(versionOptions)}
- +
- + ) : null} diff --git a/src/components/SkillInstallCard.tsx b/src/components/SkillInstallCard.tsx index c065d304b..d26c37ab4 100644 --- a/src/components/SkillInstallCard.tsx +++ b/src/components/SkillInstallCard.tsx @@ -1,4 +1,5 @@ import type { ClawdisSkillMetadata } from "clawhub-schema"; +import { Badge } from "./ui/badge"; import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils"; type SkillInstallCardProps = { @@ -35,11 +36,11 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
{hasRuntimeRequirements ? (
-

+

Runtime requirements

- {clawdis?.emoji ?
{clawdis.emoji} Clawdis
: null} + {clawdis?.emoji ? {clawdis.emoji} Clawdis : null} {osLabels.length ? (
OS @@ -79,31 +80,24 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { {envVars.length > 0 ? (
Environment variables -
+
{envVars.map((env, index) => (
- {env.name} + {env.name} {env.required === false ? ( - + optional ) : env.required === true ? ( - + required ) : null} {env.description ? ( - + — {env.description} ) : null} @@ -117,7 +111,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { ) : null} {hasDependencies ? (
-

+

Dependencies

@@ -125,25 +119,19 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
{dep.name} - + {dep.type} {dep.version ? ` ${dep.version}` : ""} {dep.url ? ( -
+ ) : null} {dep.repository && dep.repository !== dep.url ? ( -
+
Source @@ -157,7 +145,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { ) : null} {hasInstallSpecs ? (
-

+

Install

@@ -168,7 +156,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
{spec.label ?? formatInstallLabel(spec)} {spec.bins?.length ? ( -
+
Bins: {spec.bins.join(", ")}
) : null} @@ -182,14 +170,14 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { ) : null} {hasLinks ? (
-

+

Links

{links?.homepage ? ( @@ -197,7 +185,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { {links?.repository ? ( diff --git a/src/components/SkillListItem.tsx b/src/components/SkillListItem.tsx index ec0ad606d..14d8cd062 100644 --- a/src/components/SkillListItem.tsx +++ b/src/components/SkillListItem.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; import { Package, Star } from "lucide-react"; import { MarketplaceIcon } from "./MarketplaceIcon"; +import { Badge } from "./ui/badge"; import { getSkillBadges } from "../lib/badges"; import { formatCompactStat } from "../lib/numberFormat"; import type { PublicPublisher, PublicSkill } from "../lib/publicUser"; @@ -31,9 +32,9 @@ export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) ) : null} {skill.displayName} {badges.map((b) => ( - + {b} - + ))}
{skill.summary ?

{skill.summary}

: null} diff --git a/src/components/SkillMetadataSidebar.tsx b/src/components/SkillMetadataSidebar.tsx index ca8fe7bd1..f83550460 100644 --- a/src/components/SkillMetadataSidebar.tsx +++ b/src/components/SkillMetadataSidebar.tsx @@ -9,6 +9,8 @@ import { formatCompactStat } from "../lib/numberFormat"; import type { PublicPublisher, PublicSkill } from "../lib/publicUser"; import { getRuntimeEnv } from "../lib/runtimeEnv"; import { timeAgo } from "../lib/timeAgo"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; import { UserBadge } from "./UserBadge"; type SkillMetadataSidebarProps = { @@ -44,13 +46,11 @@ export function SkillMetadataSidebar({ {!nixPlugin && !isMalwareBlocked && !isRemoved ? ( ) : null} @@ -118,9 +118,9 @@ export function SkillMetadataSidebar({

Tags

{tagEntries.map(([tag]) => ( - + {tag} - + ))}
diff --git a/src/components/SkillSecurityScanResults.tsx b/src/components/SkillSecurityScanResults.tsx index 740974e13..66d729632 100644 --- a/src/components/SkillSecurityScanResults.tsx +++ b/src/components/SkillSecurityScanResults.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Badge } from "./ui/badge"; type LlmAnalysisDimension = { name: string; @@ -388,9 +389,9 @@ export function SecurityScanResults({
Capability signals
{visibleCapabilityTags.map((tag) => ( - + {SKILL_CAPABILITY_LABELS[tag] ?? tag} - + ))}
diff --git a/src/components/UserBadge.tsx b/src/components/UserBadge.tsx index 7f727f6e0..cc8e95e46 100644 --- a/src/components/UserBadge.tsx +++ b/src/components/UserBadge.tsx @@ -1,5 +1,12 @@ +import { Package, Star, Download } from "lucide-react"; +import { useEffect, useState } from "react"; +import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; +import { convexHttp } from "../convex/client"; import { hasOwnProperty } from "../lib/hasOwnProperty"; +import { formatCompactStat } from "../lib/numberFormat"; import type { PublicPublisher, PublicUser } from "../lib/publicUser"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; type UserBadgeProps = { user: PublicUser | PublicPublisher | null | undefined; @@ -40,7 +47,14 @@ export function UserBadge({ displayName!.toLowerCase() !== handle!.toLowerCase(); const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase(); - return ( + // Resolve userId for stats query — PublicUser has _id directly, + // PublicPublisher has linkedUserId + const userId = + user && hasOwnProperty(user, "kind") + ? (user as PublicPublisher).linkedUserId ?? null + : user?._id ?? null; + + const badge = ( {prefix ? {prefix} : null} ); + + if (!userId) return badge; + + return ( + + {badge} + + + ); +} + +type HoverStats = { publishedSkills: number; totalStars: number; totalDownloads: number }; + +function UserStatsTooltipContent({ + userId, + displayName, + handle, +}: { + userId: string; + displayName: string | null; + handle: string | null; +}) { + const [stats, setStats] = useState(null); + const [fetched, setFetched] = useState(false); + + // One-shot fetch on mount (tooltip content only mounts when open) + useEffect(() => { + if (fetched) return; + setFetched(true); + void convexHttp + .query(api.users.getHoverStats, { userId: userId as Id<"users"> }) + .then(setStats) + .catch(() => {}); + }, [userId, fetched]); + + return ( + e.preventDefault()} + > +
+ {displayName && ( + + {displayName} + + )} + {handle && ( + @{handle} + )} +
+
+ {stats === null ? ( + Loading... + ) : ( + <> + + + {formatCompactStat(stats.publishedSkills)} + + + + {formatCompactStat(stats.totalStars)} + + + + {formatCompactStat(stats.totalDownloads)} + + + )} +
+
+ ); } diff --git a/src/components/layout/Container.tsx b/src/components/layout/Container.tsx index 31febd9fc..6100ad0ca 100644 --- a/src/components/layout/Container.tsx +++ b/src/components/layout/Container.tsx @@ -11,9 +11,9 @@ const Container = React.forwardRef( ref={ref} className={cn( "mx-auto w-full px-4 sm:px-6 lg:px-7", - size === "default" && "max-w-[1200px]", - size === "narrow" && "max-w-[900px]", - size === "wide" && "max-w-[1400px]", + size === "default" && "max-w-page-max", + size === "narrow" && "max-w-page-narrow", + size === "wide" && "max-w-page-max", className, )} {...props} diff --git a/src/components/skeletons/DashboardSkeleton.tsx b/src/components/skeletons/DashboardSkeleton.tsx index 9c17a0560..43df9bf3a 100644 --- a/src/components/skeletons/DashboardSkeleton.tsx +++ b/src/components/skeletons/DashboardSkeleton.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton"; export function DashboardSkeleton() { return ( -
+
{/* Header */}
diff --git a/src/components/skeletons/SkillDetailSkeleton.tsx b/src/components/skeletons/SkillDetailSkeleton.tsx index 447add135..9b01fb05d 100644 --- a/src/components/skeletons/SkillDetailSkeleton.tsx +++ b/src/components/skeletons/SkillDetailSkeleton.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton"; export function SkillDetailSkeleton() { return ( -
+
{/* Breadcrumb */} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 7a1456409..583a3a5a8 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -10,37 +10,16 @@ const Badge = React.forwardRef( ( "inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]", "disabled:pointer-events-none disabled:opacity-60", - // Hover lift (matches .btn:hover) - "hover:not-disabled:-translate-y-px hover:not-disabled:shadow-[0_10px_20px_rgba(29,26,23,0.12)]", + // Hover lift + "hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover", // Variant styles variant === "default" && "border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]", variant === "primary" && - "border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white dark:from-[#c35640] dark:to-[#953827] dark:shadow-[0_10px_22px_rgba(58,23,16,0.42),inset_0_1px_0_rgba(255,201,184,0.18)]", + "border border-accent bg-accent/10 text-[color:var(--ink)]", variant === "destructive" && - "border border-red-300/40 bg-red-50 text-red-700 hover:not-disabled:bg-red-100 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300", + "border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg", variant === "ghost" && "border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0", variant === "outline" && diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index d70d23e47..f5029e81b 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -7,7 +7,7 @@ const Card = React.forwardRef` */ + to: string; + /** Optional search params object passed to `` */ + search?: Record; + /** Optional lucide icon name shown beside the label in navbar tabs */ + icon?: NavIconName; + /** Link only shown when user is authenticated */ + authRequired: boolean; + /** Link only shown for staff / moderator users */ + staffOnly: boolean; + /** Link only shown when siteMode === "souls" */ + soulModeOnly: boolean; + /** Link hidden when siteMode === "souls" */ + soulModeHide: boolean; +} + +// --------------------------------------------------------------------------- +// Search-param shapes (kept here so Header, Footer, and mobile menu all agree) +// --------------------------------------------------------------------------- + +const SKILLS_SEARCH = { + q: undefined, + sort: undefined, + dir: undefined, + highlighted: undefined, + nonSuspicious: undefined, + view: undefined, + focus: undefined, +} as const; + +const SOULS_SEARCH = { + q: undefined, + sort: undefined, + dir: undefined, + view: undefined, + focus: undefined, +} as const; + +const USERS_SEARCH = { q: undefined } as const; + +const MANAGEMENT_SEARCH = { skill: undefined } as const; + +// --------------------------------------------------------------------------- +// Primary nav items (desktop tabs row + mobile dropdown top section) +// These map to the "content-type" tabs: Skills | Plugins | Souls +// In soul-mode the order is: ClawHub (external), Souls +// In skills-mode: Skills, Plugins, Souls +// --------------------------------------------------------------------------- + +export const PRIMARY_NAV_ITEMS: NavItem[] = [ + { + label: "Skills", + to: "/skills", + search: SKILLS_SEARCH, + icon: "wrench", + authRequired: false, + staffOnly: false, + soulModeOnly: false, + soulModeHide: true, + }, + { + label: "Plugins", + to: "/plugins", + icon: "plug", + authRequired: false, + staffOnly: false, + soulModeOnly: false, + soulModeHide: true, + }, + { + label: "Souls", + to: "/souls", + search: SOULS_SEARCH, + icon: "ghost", + authRequired: false, + staffOnly: false, + soulModeOnly: false, + // In soul-mode this is the primary tab; in skills-mode it is also shown. + soulModeHide: false, + }, +]; + +// --------------------------------------------------------------------------- +// Secondary nav items (secondary tabs row + mobile dropdown lower section) +// --------------------------------------------------------------------------- + +export const SECONDARY_NAV_ITEMS: NavItem[] = [ + { + label: "Users", + to: "/users", + search: USERS_SEARCH, + authRequired: false, + staffOnly: false, + soulModeOnly: false, + soulModeHide: true, + }, + { + label: "About", + to: "/about", + authRequired: false, + staffOnly: false, + soulModeOnly: false, + soulModeHide: true, + }, + { + label: "Stars", + to: "/stars", + authRequired: true, + staffOnly: false, + soulModeOnly: false, + soulModeHide: false, + }, + { + label: "Dashboard", + to: "/dashboard", + authRequired: true, + staffOnly: false, + soulModeOnly: false, + soulModeHide: false, + }, + { + label: "Management", + to: "/management", + search: MANAGEMENT_SEARCH, + authRequired: true, + staffOnly: true, + soulModeOnly: false, + soulModeHide: false, + }, +]; + +// --------------------------------------------------------------------------- +// Footer sections +// --------------------------------------------------------------------------- + +export interface FooterNavSection { + title: string; + items: FooterNavItem[]; +} + +export type FooterNavItem = + | { kind: "link"; label: string; to: string; search?: Record } + | { kind: "external"; label: string; href: string } + | { kind: "text"; label: string }; + +export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [ + { + title: "Browse", + items: [ + { kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH }, + { kind: "link", label: "Plugins", to: "/plugins" }, + { kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH }, + { kind: "link", label: "Users", to: "/users" }, + { + kind: "link", + label: "Staff Picks", + to: "/skills", + search: { + q: undefined, + sort: undefined, + dir: undefined, + highlighted: true, + nonSuspicious: undefined, + view: undefined, + focus: undefined, + }, + }, + { + kind: "link", + label: "Search", + to: "/search", + search: { q: undefined, type: undefined }, + }, + ], + }, + { + title: "Publish", + items: [ + { + kind: "link", + label: "Publish Skill", + to: "/publish-skill", + search: { updateSlug: undefined }, + }, + { + kind: "link", + label: "Publish Plugin", + to: "/publish-plugin", + search: { + ownerHandle: undefined, + name: undefined, + displayName: undefined, + family: undefined, + nextVersion: undefined, + sourceRepo: undefined, + }, + }, + { + kind: "external", + label: "Documentation", + href: "https://github.com/openclaw/clawhub", + }, + ], + }, + { + title: "Community", + items: [ + { kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" }, + { kind: "link", label: "About", to: "/about" }, + { kind: "external", label: "OpenClaw", href: "https://openclaw.ai" }, + ], + }, + { + title: "Platform", + items: [ + { kind: "text", label: "MIT Licensed" }, + { kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" }, + { kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Filter a nav item array based on current mode/auth/staff context. */ +export function filterNavItems( + items: NavItem[], + ctx: { isSoulMode: boolean; isAuthenticated: boolean; isStaff: boolean }, +): NavItem[] { + return items.filter((item) => { + if (item.soulModeOnly && !ctx.isSoulMode) return false; + if (item.soulModeHide && ctx.isSoulMode) return false; + if (item.authRequired && !ctx.isAuthenticated) return false; + if (item.staffOnly && !ctx.isStaff) return false; + return true; + }); +} diff --git a/src/routes/$owner/$slug.tsx b/src/routes/$owner/$slug.tsx index 61cf02d98..536d4daff 100644 --- a/src/routes/$owner/$slug.tsx +++ b/src/routes/$owner/$slug.tsx @@ -1,9 +1,16 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; import { SkillDetailPage } from "../../components/SkillDetailPage"; import { buildSkillMeta } from "../../lib/og"; import { fetchSkillPageData } from "../../lib/skillPage"; export const Route = createFileRoute("/$owner/$slug")({ + beforeLoad: ({ params }) => { + const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner); + const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:"); + if (!isHandle && !isOwnerId) { + throw notFound(); + } + }, loader: async ({ params }) => { const data = await fetchSkillPageData(params.slug); const canonicalOwner = data.initialData?.result?.owner?.handle ?? null; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 887bb7b53..5d08ca06e 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -92,6 +92,24 @@ export const Route = createRootRoute({ rel: "stylesheet", href: appCss, }, + { + rel: "icon", + href: "/favicon.ico", + type: "image/x-icon", + }, + { + rel: "icon", + href: "/logo.svg", + type: "image/svg+xml", + }, + { + rel: "apple-touch-icon", + href: "/logo192.png", + }, + { + rel: "manifest", + href: "/manifest.json", + }, ], }; }, diff --git a/src/routes/about.tsx b/src/routes/about.tsx index 9ac7e1cce..68ce8e144 100644 --- a/src/routes/about.tsx +++ b/src/routes/about.tsx @@ -1,4 +1,6 @@ import { createFileRoute, Link } from '@tanstack/react-router'; +import { Badge } from '../components/ui/badge'; +import { Button } from '../components/ui/button'; import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site'; const prohibitedCategories = [ @@ -84,9 +86,9 @@ function AboutPage() {
-
- About - Policy +
+ About + Policy

What ClawHub will not host

@@ -148,17 +150,20 @@ function AboutPage() {

- - Browse Skills - - - Reviewer Doc - + +
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index a77fae623..96e1b8e82 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -16,6 +16,9 @@ import { useEffect, useState } from "react"; import semver from "semver"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; +import { Badge } from "../components/ui/badge"; +import { Button } from "../components/ui/button"; +import { Card } from "../components/ui/card"; import { formatCompactStat } from "../lib/numberFormat"; import { familyLabel } from "../lib/packageLabels"; import type { PublicSkill } from "../lib/publicUser"; @@ -113,7 +116,7 @@ function Dashboard() { if (!me) { return (
-
Sign in to access your dashboard.
+ Sign in to access your dashboard.
); } @@ -129,31 +132,34 @@ function Dashboard() { return (
-

+

Welcome to ClawHub

You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.

-
- - Publish a Skill - - - Browse Skills - +
+ +
@@ -164,14 +170,14 @@ function Dashboard() {
-

+

Publisher Dashboard

-

+

Manage your published skills and plugins.

-
+
{publishers && publishers.length > 0 ? ( - + {user.deletedAt && !user.deactivatedAt ? ( - + ) : null}
)) )}
-
+ ) : null} ); diff --git a/src/routes/plugins/$name.tsx b/src/routes/plugins/$name.tsx index a16c1e420..c18ba18f3 100644 --- a/src/routes/plugins/$name.tsx +++ b/src/routes/plugins/$name.tsx @@ -296,8 +296,7 @@ function PluginDetailRoute() { : []; return ( -
- +
{/* Header card */} @@ -373,13 +372,12 @@ function PluginDetailRoute() { Latest release: v{pkg.latestVersion} - - +
) : null} @@ -579,7 +577,6 @@ function PluginDetailRoute() { ) : null}
- ); } diff --git a/src/routes/plugins/index.tsx b/src/routes/plugins/index.tsx index 1261eb617..a63149232 100644 --- a/src/routes/plugins/index.tsx +++ b/src/routes/plugins/index.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, Search } from "lucide-react"; import { useEffect, useState } from "react"; import { BrowseSidebar } from "../../components/BrowseSidebar"; import { PluginListItem } from "../../components/PluginListItem"; +import { Button } from "../../components/ui/button"; import { fetchPluginCatalog, isRateLimitedPackageApiError, @@ -143,7 +144,7 @@ export function PluginsIndex() {

Plugins

-
+
- - Publish - +
@@ -221,10 +223,9 @@ export function PluginsIndex() { )} {!search.q && (search.cursor || nextCursor) ? ( -
+
{search.cursor ? ( - + ) : null} {nextCursor ? ( - + ) : null}
) : null} diff --git a/src/routes/search.tsx b/src/routes/search.tsx index 4e233ab90..d4c783427 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { PluginListItem } from "../components/PluginListItem"; import { SkillListItem } from "../components/SkillListItem"; import { UserListItem } from "../components/UserListItem"; +import { Card } from "../components/ui/card"; import type { PublicSkill, PublicUser } from "../lib/publicUser"; import { useUnifiedSearch, @@ -57,10 +58,10 @@ function UnifiedSearchPage() { return (
-

+

{search.q ? ( <> - Search results for "{search.q}" + Search results for "{search.q}" ) : ( "Search" @@ -68,7 +69,7 @@ function UnifiedSearchPage() {

-
+