Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2c856e68a | ||
|
|
47822918c5 | ||
|
|
74b834b4dd | ||
|
|
77abd47e16 | ||
|
|
1f7de02144 | ||
|
|
c8a515a785 | ||
|
|
cbacfb4812 | ||
|
|
84a48f28f9 | ||
|
|
ceebcc5bca | ||
|
|
4d880acccc | ||
|
|
5e7e4e83bd |
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 704 B |
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<rect width="512" height="512" rx="0" fill="none"/>
|
||||
<!-- Body segments -->
|
||||
<g fill="#F03020" stroke="#fff" stroke-width="6" stroke-linejoin="round">
|
||||
<!-- Tail fan - center -->
|
||||
<ellipse cx="256" cy="460" rx="42" ry="30"/>
|
||||
<!-- Tail fan - left -->
|
||||
<ellipse cx="210" cy="455" rx="32" ry="24" transform="rotate(25 210 455)"/>
|
||||
<!-- Tail fan - right -->
|
||||
<ellipse cx="302" cy="455" rx="32" ry="24" transform="rotate(-25 302 455)"/>
|
||||
<!-- Lower body segment -->
|
||||
<rect x="208" y="380" width="96" height="55" rx="12"/>
|
||||
<!-- Mid-lower body -->
|
||||
<rect x="204" y="335" width="104" height="55" rx="12"/>
|
||||
<!-- Mid body -->
|
||||
<rect x="200" y="290" width="112" height="55" rx="14"/>
|
||||
<!-- Upper body / thorax -->
|
||||
<ellipse cx="256" cy="255" rx="72" ry="65"/>
|
||||
<!-- Head -->
|
||||
<circle cx="256" cy="195" r="58"/>
|
||||
<!-- Left claw arm -->
|
||||
<path d="M195 230 Q150 190 120 170 Q100 155 95 140" stroke-width="18" fill="none" stroke-linecap="round"/>
|
||||
<!-- Right claw arm -->
|
||||
<path d="M317 230 Q362 190 392 170 Q412 155 417 140" stroke-width="18" fill="none" stroke-linecap="round"/>
|
||||
<!-- Left claw -->
|
||||
<ellipse cx="88" cy="115" rx="52" ry="42" transform="rotate(-20 88 115)"/>
|
||||
<!-- Right claw -->
|
||||
<ellipse cx="424" cy="115" rx="52" ry="42" transform="rotate(20 424 115)"/>
|
||||
<!-- Left claw notch -->
|
||||
<path d="M65 95 Q78 80 72 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
|
||||
<!-- Right claw notch -->
|
||||
<path d="M447 95 Q434 80 440 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
|
||||
<!-- Left legs -->
|
||||
<path d="M205 290 Q175 295 155 305" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M202 320 Q172 330 152 340" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M205 350 Q178 358 158 370" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<!-- Right legs -->
|
||||
<path d="M307 290 Q337 295 357 305" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M310 320 Q340 330 360 340" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M307 350 Q334 358 354 370" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
<!-- Eyes -->
|
||||
<circle cx="236" cy="185" r="10" fill="#000"/>
|
||||
<circle cx="276" cy="185" r="10" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 169 KiB |
@@ -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();
|
||||
});
|
||||
});
|
||||
});});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -26,6 +26,7 @@ const useAuthStatusMock = vi.fn();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => publishRelease,
|
||||
useQuery: () => undefined,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown>;
|
||||
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<unknown>;
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 (
|
||||
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</ConvexAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
margin: "16px auto 0",
|
||||
width: "min(1100px, calc(100vw - 32px))",
|
||||
border: "1px solid #f59e0b",
|
||||
background: "#fff7ed",
|
||||
color: "#9a3412",
|
||||
borderRadius: "14px",
|
||||
padding: "12px 16px",
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
|
||||
>
|
||||
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
|
||||
but Convex reports <code>{drift.actualBuildSha}</code>.
|
||||
|
||||
@@ -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() {
|
||||
<div className="site-footer-inner">
|
||||
<div className="site-footer-divider" aria-hidden="true" />
|
||||
<div className="footer-grid">
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Browse</h4>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
<Link to="/plugins">Plugins</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
<Link to="/users">Users</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Staff Picks
|
||||
</Link>
|
||||
<Link to="/search" search={{ q: undefined, type: undefined }}>
|
||||
Search
|
||||
</Link>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Publish</h4>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish Plugin
|
||||
</Link>
|
||||
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Community</h4>
|
||||
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
<Link to="/about">About</Link>
|
||||
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
|
||||
OpenClaw
|
||||
</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Platform</h4>
|
||||
<span>MIT Licensed</span>
|
||||
<a href="https://vercel.com" target="_blank" rel="noreferrer">
|
||||
Deployed on Vercel
|
||||
</a>
|
||||
<a href="https://www.convex.dev" target="_blank" rel="noreferrer">
|
||||
Powered by Convex
|
||||
</a>
|
||||
</div>
|
||||
{FOOTER_NAV_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="footer-col">
|
||||
<h4 className="footer-col-title">{section.title}</h4>
|
||||
{section.items.map((item) => {
|
||||
if (item.kind === "link") {
|
||||
return (
|
||||
<Link key={item.label} to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (item.kind === "external") {
|
||||
return (
|
||||
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// kind === "text"
|
||||
return <span key={item.label}>{item.label}</span>;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Github, Menu, Monitor, Moon, Search, Sun } from "lucide-react";
|
||||
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
filterNavItems,
|
||||
type NavIconName,
|
||||
PRIMARY_NAV_ITEMS,
|
||||
SECONDARY_NAV_ITEMS,
|
||||
} from "../lib/nav-items";
|
||||
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 { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,6 +26,12 @@ import {
|
||||
} from "./ui/dropdown-menu";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
const NAV_ICONS: Record<NavIconName, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
wrench: Wrench,
|
||||
plug: Plug,
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
@@ -34,6 +47,13 @@ export default function Header() {
|
||||
const handle = me?.handle ?? me?.displayName ?? "user";
|
||||
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
|
||||
const isStaff = isModerator(me);
|
||||
const hasResolvedUser = Boolean(me);
|
||||
const navCtx = useMemo(
|
||||
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
|
||||
[hasResolvedUser, isSoulMode, isStaff],
|
||||
);
|
||||
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
@@ -115,87 +135,20 @@ export default function Header() {
|
||||
<a href={clawHubUrl}>ClawHub</a>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/plugins">Plugins</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
{primaryItems.map((item) => (
|
||||
<DropdownMenuItem key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/users" search={{ q: undefined }}>
|
||||
Users
|
||||
))}
|
||||
{secondaryItems.map((item) => (
|
||||
<DropdownMenuItem key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about">About</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{me ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stars">Stars</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{me ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/management" search={{ skill: undefined }}>
|
||||
Management
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
@@ -263,26 +216,21 @@ export default function Header() {
|
||||
) : (
|
||||
<>
|
||||
{authError ? (
|
||||
<div className="error" role="alert" style={{ fontSize: "0.85rem", marginRight: 8 }}>
|
||||
<div className="error mr-2 text-[0.85rem]" role="alert">
|
||||
{authError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAuthError}
|
||||
aria-label="Dismiss"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
@@ -298,7 +246,7 @@ export default function Header() {
|
||||
<Github size={16} aria-hidden="true" />
|
||||
<span className="sign-in-label">Sign in</span>
|
||||
<span className="sign-in-provider">with GitHub</span>
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -327,88 +275,32 @@ export default function Header() {
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
className="navbar-tab"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
className="navbar-tab"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link to="/plugins" className="navbar-tab">
|
||||
Plugins
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/souls"
|
||||
className="navbar-tab"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
)}
|
||||
{primaryItems.map((item) => {
|
||||
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
className="navbar-tab"
|
||||
search={item.search ?? {}}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="navbar-tabs-secondary">
|
||||
{isSoulMode ? null : (
|
||||
<Link to="/users" search={{ q: undefined }} className="navbar-tab navbar-tab-secondary">
|
||||
Users
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link to="/about" className="navbar-tab navbar-tab-secondary">
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{me ? (
|
||||
<Link to="/stars" className="navbar-tab navbar-tab-secondary">
|
||||
Stars
|
||||
</Link>
|
||||
) : null}
|
||||
{me ? (
|
||||
<Link to="/dashboard" className="navbar-tab navbar-tab-secondary">
|
||||
Dashboard
|
||||
</Link>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
{secondaryItems.map((item) => (
|
||||
<Link
|
||||
to="/management"
|
||||
search={{ skill: undefined }}
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
search={item.search ?? {}}
|
||||
className="navbar-tab navbar-tab-secondary"
|
||||
>
|
||||
Manage
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
<span className="skill-list-item-name">{item.displayName}</span>
|
||||
<span className="tag tag-compact">{familyLabel(item.family)}</span>
|
||||
{item.isOfficial ? <span className="tag tag-compact tag-accent">Verified</span> : null}
|
||||
<Badge variant="compact">{familyLabel(item.family)}</Badge>
|
||||
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
|
||||
</div>
|
||||
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
|
||||
<div className="skill-list-item-meta">
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="skill-card-tags">
|
||||
{badges.map((label) => (
|
||||
<div key={label} className="tag">
|
||||
<Badge key={label}>
|
||||
{label}
|
||||
</div>
|
||||
</Badge>
|
||||
))}
|
||||
{chip ? <div className="tag tag-accent tag-compact">{chip}</div> : null}
|
||||
{chip ? <Badge variant="accent">{chip}</Badge> : null}
|
||||
{platformLabels?.map((label) => (
|
||||
<div key={label} className="tag tag-compact">
|
||||
<Badge key={label} variant="compact">
|
||||
{label}
|
||||
</div>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<main className="section">
|
||||
<div className="card">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading skill…</div>
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -340,7 +341,7 @@ export function SkillDetailPage({
|
||||
if (result === null || !skill) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Skill not found.</div>
|
||||
<Card>Skill not found.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -403,25 +404,25 @@ export function SkillDetailPage({
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
{nixSnippet ? (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code" style={{ marginTop: 8 }}>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{configExample ? (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code" style={{ marginTop: 8 }}>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{configExample}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
@@ -442,14 +443,14 @@ export function SkillDetailPage({
|
||||
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<div className="card">
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Comments
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
<p className="section-subtitle mt-3 mb-0">
|
||||
Loading comments...
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
|
||||
@@ -99,14 +99,14 @@ export function SkillDetailTabs({
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
|
||||
</div>
|
||||
) : readmeError ? (
|
||||
<div className="empty-state" style={{ padding: "var(--space-6) var(--space-4)" }}>
|
||||
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
|
||||
<p className="empty-state-title">No README available</p>
|
||||
<p className="empty-state-body">
|
||||
This skill doesn't have a SKILL.md file yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat" style={{ padding: 16 }}>
|
||||
<div className="stat p-4">
|
||||
Loading README...
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
<div className={containerClass}>
|
||||
<div className="diff-header">
|
||||
<div>
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Compare versions
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
<p className="section-subtitle m-0">
|
||||
Inline or side-by-side diff for any file.
|
||||
</p>
|
||||
</div>
|
||||
@@ -323,8 +324,8 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="btn diff-swap"
|
||||
<Button
|
||||
className="diff-swap"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLeftVersionId(rightVersionId);
|
||||
@@ -333,7 +334,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
disabled={!leftVersionId || !rightVersionId}
|
||||
>
|
||||
Swap
|
||||
</button>
|
||||
</Button>
|
||||
<div className="diff-select">
|
||||
<label htmlFor="diff-right">Right</label>
|
||||
<select
|
||||
|
||||
@@ -89,10 +89,10 @@ export function SkillFilesPanel({
|
||||
<div className="file-browser">
|
||||
<div className="file-list">
|
||||
<div className="file-list-header">
|
||||
<h3 className="section-title" style={{ fontSize: "1.05rem", margin: 0 }}>
|
||||
<h3 className="section-title text-[1.05rem] m-0">
|
||||
Files
|
||||
</h3>
|
||||
<span className="section-subtitle" style={{ margin: 0 }}>
|
||||
<span className="section-subtitle m-0">
|
||||
{latestFiles.length} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat"
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
|
||||
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
export type SkillModerationInfo = {
|
||||
@@ -188,13 +190,13 @@ export function SkillHeader({
|
||||
<div className="skill-hero-header">
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
<h1 className="section-title m-0">
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
{latestVersion?.version ? (
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <span className="tag tag-accent">Plugin bundle (nix)</span> : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
|
||||
|
||||
@@ -210,11 +212,11 @@ export function SkillHeader({
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat">⭐ {formattedStats.stars}</span>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formattedStats.installsAllTime} all-time</span>
|
||||
</div>
|
||||
<div className="skill-hero-meta-row">
|
||||
@@ -227,7 +229,7 @@ export function SkillHeader({
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
@@ -240,7 +242,7 @@ export function SkillHeader({
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
@@ -255,14 +257,14 @@ export function SkillHeader({
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<span key={badge} className="tag tag-compact">
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<span className={`tag tag-compact${isAutoHidden || isRemoved ? " tag-accent" : ""}`}>
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,14 +281,16 @@ export function SkillHeader({
|
||||
</button>
|
||||
) : null}
|
||||
{isAuthenticated ? (
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={onOpenReport}>
|
||||
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
|
||||
Report
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link className="btn btn-sm" to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
<Button asChild size="sm">
|
||||
<Link to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,12 +358,12 @@ export function SkillHeader({
|
||||
|
||||
<div className="skill-tag-row">
|
||||
{tagEntries.length === 0 ? (
|
||||
<span className="section-subtitle" style={{ margin: 0 }}>
|
||||
<span className="section-subtitle m-0">
|
||||
No tags yet.
|
||||
</span>
|
||||
) : (
|
||||
tagEntries.map(([tag, versionId]) => (
|
||||
<span key={tag} className="tag">
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
@@ -375,7 +379,7 @@ export function SkillHeader({
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -405,9 +409,9 @@ export function SkillHeader({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" type="submit">
|
||||
<Button type="submit">
|
||||
Update tag
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
<div className="skill-hero-panels">
|
||||
{hasRuntimeRequirements ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Runtime requirements
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{clawdis?.emoji ? <div className="tag">{clawdis.emoji} Clawdis</div> : null}
|
||||
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
|
||||
{osLabels.length ? (
|
||||
<div className="stat">
|
||||
<strong>OS</strong>
|
||||
@@ -79,31 +80,24 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
{envVars.length > 0 ? (
|
||||
<div className="stat">
|
||||
<strong>Environment variables</strong>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{envVars.map((env, index) => (
|
||||
<div
|
||||
key={`${env.name}-${index}`}
|
||||
style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}
|
||||
className="flex items-baseline gap-2"
|
||||
>
|
||||
<code style={{ fontSize: "0.85rem" }}>{env.name}</code>
|
||||
<code className="text-[0.85rem]">{env.name}</code>
|
||||
{env.required === false ? (
|
||||
<span style={{ color: "var(--ink-soft)", fontSize: "0.75rem" }}>
|
||||
<span className="text-ink-soft text-[0.75rem]">
|
||||
optional
|
||||
</span>
|
||||
) : env.required === true ? (
|
||||
<span style={{ color: "var(--ink-accent)", fontSize: "0.75rem" }}>
|
||||
<span className="text-ink-accent text-[0.75rem]">
|
||||
required
|
||||
</span>
|
||||
) : null}
|
||||
{env.description ? (
|
||||
<span style={{ color: "var(--ink-soft)", fontSize: "0.8rem" }}>
|
||||
<span className="text-ink-soft text-[0.8rem]">
|
||||
— {env.description}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -117,7 +111,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
) : null}
|
||||
{hasDependencies ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Dependencies
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
@@ -125,25 +119,19 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
<div key={`${dep.name}-${index}`} className="stat">
|
||||
<div>
|
||||
<strong>{dep.name}</strong>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ink-soft)",
|
||||
fontSize: "0.85rem",
|
||||
marginLeft: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span className="text-ink-soft text-[0.85rem] ml-2">
|
||||
{dep.type}
|
||||
{dep.version ? ` ${dep.version}` : ""}
|
||||
</span>
|
||||
{dep.url ? (
|
||||
<div style={{ fontSize: "0.8rem", wordBreak: "break-all" }}>
|
||||
<div className="text-[0.8rem] break-all">
|
||||
<a href={dep.url} target="_blank" rel="noopener noreferrer">
|
||||
{dep.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{dep.repository && dep.repository !== dep.url ? (
|
||||
<div style={{ fontSize: "0.8rem" }}>
|
||||
<div className="text-[0.8rem]">
|
||||
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
|
||||
Source
|
||||
</a>
|
||||
@@ -157,7 +145,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
) : null}
|
||||
{hasInstallSpecs ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Install
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
@@ -168,7 +156,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
<div>
|
||||
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
|
||||
{spec.bins?.length ? (
|
||||
<div style={{ color: "var(--ink-soft)", fontSize: "0.85rem" }}>
|
||||
<div className="text-ink-soft text-[0.85rem]">
|
||||
Bins: {spec.bins.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -182,14 +170,14 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
) : null}
|
||||
{hasLinks ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Links
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{links?.homepage ? (
|
||||
<div className="stat">
|
||||
<strong>Homepage</strong>
|
||||
<a href={links.homepage} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
|
||||
<a href={links.homepage} target="_blank" rel="noopener noreferrer" className="break-all">
|
||||
{links.homepage}
|
||||
</a>
|
||||
</div>
|
||||
@@ -197,7 +185,7 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
{links?.repository ? (
|
||||
<div className="stat">
|
||||
<strong>Repository</strong>
|
||||
<a href={links.repository} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
|
||||
<a href={links.repository} target="_blank" rel="noopener noreferrer" className="break-all">
|
||||
{links.repository}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
<span className="skill-list-item-name">{skill.displayName}</span>
|
||||
{badges.map((b) => (
|
||||
<span key={b} className="tag tag-compact">
|
||||
<Badge key={b} variant="compact">
|
||||
{b}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Download</h3>
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
|
||||
style={{ width: "100%", justifyContent: "center" }}
|
||||
>
|
||||
Download zip
|
||||
</a>
|
||||
<Button asChild variant="primary" className="w-full justify-center">
|
||||
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -118,9 +118,9 @@ export function SkillMetadataSidebar({
|
||||
<h3 className="sidebar-card-title">Tags</h3>
|
||||
<div className="sidebar-tags">
|
||||
{tagEntries.map(([tag]) => (
|
||||
<span key={tag} className="tag tag-compact">
|
||||
<Badge key={tag} variant="compact">
|
||||
{tag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
type LlmAnalysisDimension = {
|
||||
name: string;
|
||||
@@ -388,9 +389,9 @@ export function SecurityScanResults({
|
||||
<div className="scan-findings-title">Capability signals</div>
|
||||
<div className="scan-capability-tags">
|
||||
{visibleCapabilityTags.map((tag) => (
|
||||
<span key={tag} className="tag scan-capability-tag">
|
||||
<Badge key={tag} className="scan-capability-tag">
|
||||
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="scan-capability-note">
|
||||
|
||||
@@ -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 = (
|
||||
<span className={`user-badge user-badge-${size}`}>
|
||||
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
|
||||
<span className="user-avatar" aria-hidden="true">
|
||||
@@ -67,4 +81,77 @@ export function UserBadge({
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!userId) return badge;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||
<UserStatsTooltipContent userId={userId} displayName={displayName} handle={handle} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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<HoverStats | null>(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 (
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="min-w-[140px] p-0"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-space-1 px-3 py-2">
|
||||
{displayName && (
|
||||
<span className="text-fs-sm font-semibold text-ink truncate max-w-[180px]">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{handle && (
|
||||
<span className="text-fs-xs text-ink-soft">@{handle}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-line flex items-center gap-space-3 px-3 py-2">
|
||||
{stats === null ? (
|
||||
<span className="text-fs-xs text-ink-soft">Loading...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Published skills">
|
||||
<Package size={12} />
|
||||
{formatCompactStat(stats.publishedSkills)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Stars received">
|
||||
<Star size={12} />
|
||||
{formatCompactStat(stats.totalStars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Total downloads">
|
||||
<Download size={12} />
|
||||
{formatCompactStat(stats.totalDownloads)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
|
||||
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}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-7 py-10">
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-52" />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function SkillDetailSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-7 py-10">
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<Skeleton className="mb-6 h-4 w-48" />
|
||||
|
||||
|
||||
@@ -10,37 +10,16 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles matching .tag
|
||||
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-[0.8rem] font-semibold",
|
||||
// Variant styles
|
||||
variant === "default" && [
|
||||
"bg-[rgba(43,198,164,0.16)] px-3 py-1 text-[#1a6b5b]",
|
||||
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
|
||||
],
|
||||
variant === "accent" && [
|
||||
"bg-[rgba(255,107,74,0.16)] px-3 py-1 text-[color:var(--accent-deep)]",
|
||||
"dark:bg-[rgba(232,106,71,0.24)] dark:text-[#ffd0bf]",
|
||||
],
|
||||
variant === "compact" && [
|
||||
"bg-[rgba(43,198,164,0.16)] px-2.5 py-0.5 text-[0.72rem] text-[#1a6b5b]",
|
||||
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
|
||||
],
|
||||
variant === "pending" && [
|
||||
"bg-[rgba(240,196,106,0.2)] px-3 py-1 text-[#8a6914]",
|
||||
"dark:bg-[rgba(243,201,122,0.18)] dark:text-[color:var(--gold)]",
|
||||
],
|
||||
variant === "success" && [
|
||||
"bg-emerald-100 px-3 py-1 text-emerald-700",
|
||||
"dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
],
|
||||
variant === "warning" && [
|
||||
"bg-amber-100 px-3 py-1 text-amber-700",
|
||||
"dark:bg-amber-900/30 dark:text-amber-300",
|
||||
],
|
||||
variant === "destructive" && [
|
||||
"bg-red-100 px-3 py-1 text-red-700",
|
||||
"dark:bg-red-900/30 dark:text-red-300",
|
||||
],
|
||||
// Base styles
|
||||
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-fs-sm font-semibold",
|
||||
// Variant styles — all token-driven, no dark: overrides needed
|
||||
variant === "default" && "bg-hover-bg px-3 py-1 text-ink-soft border border-line",
|
||||
variant === "accent" && "bg-active-bg px-3 py-1 text-accent-deep border border-line",
|
||||
variant === "compact" && "bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
|
||||
variant === "pending" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
|
||||
variant === "success" && "bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
|
||||
variant === "warning" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
|
||||
variant === "destructive" && "bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -33,15 +33,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
"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" &&
|
||||
|
||||
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .card
|
||||
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] transition-all duration-200 ease-out",
|
||||
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5 transition-all duration-200 ease-out",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .report-dialog-backdrop
|
||||
"fixed inset-0 z-80 grid place-items-center bg-[rgba(21,24,35,0.42)] p-5 backdrop-blur-[3px]",
|
||||
"fixed inset-0 z-80 grid place-items-center bg-overlay-bg p-5 backdrop-blur-[3px]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className,
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .report-dialog
|
||||
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-[0_24px_50px_rgba(18,22,34,0.24)]",
|
||||
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
|
||||
@@ -18,7 +18,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[180px] rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,16 +7,12 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
// Base styles matching .form-input
|
||||
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
|
||||
"placeholder:text-[rgba(88,115,133,0.72)]",
|
||||
// Focus styles matching .form-input:focus
|
||||
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
|
||||
// Dark mode matching [data-theme="dark"] .form-input
|
||||
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
|
||||
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
|
||||
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
|
||||
// Base styles
|
||||
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-input-border bg-input-bg",
|
||||
"placeholder:text-input-placeholder",
|
||||
// Focus
|
||||
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
// Disabled
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
|
||||
@@ -10,9 +10,8 @@ const Label = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .form-label
|
||||
"text-[0.74rem] font-bold uppercase tracking-[0.14em]",
|
||||
"text-[rgba(70,95,113,0.9)]",
|
||||
"dark:text-[rgba(206,227,238,0.76)]",
|
||||
"text-fs-xs font-bold uppercase tracking-[0.14em]",
|
||||
"text-label-fg",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,11 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .form-input styling for consistency
|
||||
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
|
||||
"placeholder:text-[rgba(88,115,133,0.72)]",
|
||||
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
|
||||
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
|
||||
// Matches form input token styling
|
||||
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-input-border bg-input-bg",
|
||||
"placeholder:text-input-placeholder",
|
||||
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
)}
|
||||
@@ -69,7 +68,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@@ -115,7 +114,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-[var(--radius-sm)] py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
|
||||
"focus:bg-[color:var(--surface-muted)] focus:text-[color:var(--ink)]",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
|
||||
@@ -15,7 +15,7 @@ const SheetOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-80 bg-[rgba(21,24,35,0.42)] backdrop-blur-[3px]",
|
||||
"fixed inset-0 z-80 bg-overlay-bg backdrop-blur-[3px]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className,
|
||||
|
||||
@@ -8,16 +8,12 @@ const Textarea = React.forwardRef<
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles matching .form-input
|
||||
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
|
||||
"placeholder:text-[rgba(88,115,133,0.72)]",
|
||||
// Base styles
|
||||
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-input-border bg-input-bg",
|
||||
"placeholder:text-input-placeholder",
|
||||
// Focus
|
||||
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
|
||||
// Dark mode
|
||||
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
|
||||
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
|
||||
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
|
||||
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
// Disabled
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
|
||||
@@ -9,7 +9,7 @@ const ToggleGroup = React.forwardRef<
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-[38px] items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
|
||||
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,7 +24,7 @@ const ToggleGroupItem = React.forwardRef<
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white",
|
||||
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-accent data-[state=on]:text-accent-fg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Standardized icon sizes for consistent sizing across the app */
|
||||
export const ICON_SM = 14;
|
||||
export const ICON_MD = 16;
|
||||
export const ICON_LG = 24;
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Shared navigation configuration used by Header and Footer to eliminate
|
||||
* triple duplication of nav link definitions.
|
||||
*/
|
||||
|
||||
/** Lucide icon name used as a key to look up the component at render time. */
|
||||
export type NavIconName = "wrench" | "plug" | "ghost";
|
||||
|
||||
export interface NavItem {
|
||||
/** Visible link text */
|
||||
label: string;
|
||||
/** Route path passed to `<Link to>` */
|
||||
to: string;
|
||||
/** Optional search params object passed to `<Link search>` */
|
||||
search?: Record<string, unknown>;
|
||||
/** 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<string, unknown> }
|
||||
| { 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
<main className="section about-page">
|
||||
<div className="about-hero">
|
||||
<div className="about-hero-copy">
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">About</span>
|
||||
<span className="tag tag-accent">Policy</span>
|
||||
<div className="skill-card-tags mb-3">
|
||||
<Badge>About</Badge>
|
||||
<Badge variant="accent">Policy</Badge>
|
||||
</div>
|
||||
<h1 className="about-title">What ClawHub will not host</h1>
|
||||
<p className="about-lead">
|
||||
@@ -148,17 +150,20 @@ function AboutPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Link className="btn btn-primary" to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
<a
|
||||
className="btn"
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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 (
|
||||
<main className="section">
|
||||
<div className="card">Sign in to access your dashboard.</div>
|
||||
<Card>Sign in to access your dashboard.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -129,31 +132,34 @@ function Dashboard() {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="empty-state">
|
||||
<h1 className="empty-state-title" style={{ fontSize: "1.4rem", fontFamily: "var(--font-display)" }}>
|
||||
<h1 className="empty-state-title text-[1.4rem] font-[family-name:var(--font-display)]">
|
||||
Welcome to ClawHub
|
||||
</h1>
|
||||
<p className="empty-state-body">
|
||||
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="btn"
|
||||
>
|
||||
Browse Skills
|
||||
</Link>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
Publish a Skill
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -164,14 +170,14 @@ function Dashboard() {
|
||||
<main className="section">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
<h1 className="section-title m-0">
|
||||
Publisher Dashboard
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
<p className="section-subtitle m-0">
|
||||
Manage your published skills and plugins.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{publishers && publishers.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
@@ -185,28 +191,31 @@ function Dashboard() {
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="card dashboard-owner-panel">
|
||||
<Card className="dashboard-owner-panel">
|
||||
<div className="dashboard-owner-grid">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Skills</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
<p className="section-subtitle mt-1.5 mb-0 mx-0">
|
||||
Hidden skill versions remain visible here while checks are pending.
|
||||
</p>
|
||||
</div>
|
||||
@@ -216,10 +225,12 @@ function Dashboard() {
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
|
||||
</div>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
@@ -240,7 +251,7 @@ function Dashboard() {
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
<p className="section-subtitle mt-1.5 mb-0 mx-0">
|
||||
Owner-only package view with VirusTotal, static scan, and verification state.
|
||||
</p>
|
||||
</div>
|
||||
@@ -250,14 +261,15 @@ function Dashboard() {
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
|
||||
</div>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
@@ -274,7 +286,7 @@ function Dashboard() {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -293,10 +305,10 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
</Link>
|
||||
<span className="dashboard-list-id">/{skill.slug}</span>
|
||||
{skill.pendingReview ? (
|
||||
<span className="tag tag-pending">
|
||||
<Badge variant="pending">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
Pending checks
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dashboard-inline-metrics">
|
||||
@@ -328,17 +340,20 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
)}
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }} className="btn btn-sm">
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
<Button asChild size="sm">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -376,17 +391,17 @@ function PackageStatusTag({
|
||||
label: string;
|
||||
tone: "default" | "pending" | "warning" | "danger" | "success";
|
||||
}) {
|
||||
const className =
|
||||
const variant =
|
||||
tone === "pending"
|
||||
? "tag tag-pending"
|
||||
? "pending"
|
||||
: tone === "warning"
|
||||
? "tag dashboard-tag-warning"
|
||||
? "warning"
|
||||
: tone === "danger"
|
||||
? "tag dashboard-tag-danger"
|
||||
? "destructive"
|
||||
: tone === "success"
|
||||
? "tag dashboard-tag-success"
|
||||
: "tag";
|
||||
return <span className={className}>{label}</span>;
|
||||
? "success"
|
||||
: "default";
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
}
|
||||
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
@@ -480,24 +495,27 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
</span>
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Release
|
||||
</Link>
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="btn btn-ghost btn-sm">
|
||||
View
|
||||
</Link>
|
||||
<Button asChild size="sm">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
}}
|
||||
>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Release
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { SkillListItem } from "../components/SkillListItem";
|
||||
import { SkillStatsTripletLine } from "../components/SkillStats";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { UserBadge } from "../components/UserBadge";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
@@ -83,28 +85,30 @@ function SkillsHome() {
|
||||
: "Public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own."}
|
||||
</p>
|
||||
<div className="home-hero-actions">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Browse All Skills & Plugins
|
||||
</Link>
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
className="btn home-hero-publish-btn"
|
||||
>
|
||||
+ Publish Yours
|
||||
</Link>
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Browse All Skills & Plugins
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="home-hero-publish-btn">
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
>
|
||||
+ Publish Yours
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="home-hero-explainer">
|
||||
Sharp filters. Clean listings. Discovery that feels more like a real index and less
|
||||
@@ -369,9 +373,9 @@ function OnlyCrabsHome() {
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
<Button variant="primary" type="submit">
|
||||
Search
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,7 +401,7 @@ function OnlyCrabsHome() {
|
||||
</div>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No souls yet. Be the first.</div>
|
||||
<Card>No souls yet. Be the first.</Card>
|
||||
) : (
|
||||
latest.map((soul) => (
|
||||
<SoulCard
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import {
|
||||
getSkillBadges,
|
||||
isSkillDeprecated,
|
||||
@@ -176,7 +179,7 @@ function Management() {
|
||||
if (!staff) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Management only.</div>
|
||||
<Card>Management only.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +187,7 @@ function Management() {
|
||||
if (!recentVersions || !reportedSkills || !duplicateCandidates) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Loading management console…</div>
|
||||
<Card>Loading management console…</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -259,8 +262,8 @@ function Management() {
|
||||
<h1 className="section-title">Management console</h1>
|
||||
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Reported skills
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
@@ -292,7 +295,7 @@ function Management() {
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
@{owner?.handle ?? owner?.name ?? "user"} · v{latestVersion?.version ?? "—"} ·
|
||||
{skill.reportCount ?? 0} report{(skill.reportCount ?? 0) === 1 ? "" : "s"}
|
||||
{skill.lastReportedAt
|
||||
@@ -315,17 +318,18 @@ function Management() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
No report reasons yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Link className="btn" to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
<button
|
||||
className="btn"
|
||||
<Button asChild>
|
||||
<Link to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const reason = window.prompt(
|
||||
@@ -340,10 +344,9 @@ function Management() {
|
||||
}}
|
||||
>
|
||||
{skill.softDeletedAt ? "Restore" : "Hide"}
|
||||
</button>
|
||||
</Button>
|
||||
{admin ? (
|
||||
<button
|
||||
className="btn"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Hard delete ${skill.displayName}?`)) return;
|
||||
@@ -351,7 +354,7 @@ function Management() {
|
||||
}}
|
||||
>
|
||||
Hard delete
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,14 +362,14 @@ function Management() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Skill tools
|
||||
</h2>
|
||||
{selectedSlug ? (
|
||||
<div className="section-subtitle" style={{ marginTop: 8 }}>
|
||||
<div className="section-subtitle mt-2">
|
||||
Managing "{selectedSlug}" ·{" "}
|
||||
<Link to="/management" search={{ skill: undefined }}>
|
||||
Clear selection
|
||||
@@ -406,7 +409,7 @@ function Management() {
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
@{owner?.handle ?? owner?.name ?? "user"} · v{latestVersion?.version ?? "—"} ·
|
||||
updated {formatTimestamp(skill.updatedAt)} · {moderationStatus}
|
||||
{badges.length ? ` · ${badges.join(", ").toLowerCase()}` : ""}
|
||||
@@ -414,14 +417,14 @@ function Management() {
|
||||
{skill.moderationFlags?.length ? (
|
||||
<div className="management-tags">
|
||||
{skill.moderationFlags.map((flag: string) => (
|
||||
<span key={flag} className="tag">
|
||||
<Badge key={flag}>
|
||||
{flag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="management-sublist">
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
Manual overrides
|
||||
</div>
|
||||
<section className="management-override-panel">
|
||||
@@ -453,29 +456,29 @@ function Management() {
|
||||
onChange={(event) => setSkillOverrideNote(event.target.value)}
|
||||
/>
|
||||
<div className="management-actions management-actions-start">
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
disabled={!skillOverrideNote.trim()}
|
||||
onClick={applySkillOverride}
|
||||
>
|
||||
{skill.manualOverride ? "Update okay override" : "Mark skill okay"}
|
||||
</button>
|
||||
</Button>
|
||||
{skill.manualOverride ? (
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
disabled={!skillOverrideNote.trim()}
|
||||
onClick={clearSkillOverride}
|
||||
>
|
||||
Clear skill override
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="management-sublist">
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
Recent audit activity
|
||||
</div>
|
||||
<section className="management-override-panel management-audit-panel">
|
||||
@@ -484,7 +487,7 @@ function Management() {
|
||||
<span>Last {SKILL_AUDIT_LOG_LIMIT} entries for this skill.</span>
|
||||
</div>
|
||||
{auditLogs.length === 0 ? (
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
No audit activity yet.
|
||||
</div>
|
||||
) : (
|
||||
@@ -537,8 +540,8 @@ function Management() {
|
||||
</label>
|
||||
<div className="management-control management-control-stack">
|
||||
<span className="mono">duplicate action</span>
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void setDuplicate({
|
||||
@@ -548,7 +551,7 @@ function Management() {
|
||||
}
|
||||
>
|
||||
Set duplicate
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{admin ? (
|
||||
<>
|
||||
@@ -568,8 +571,8 @@ function Management() {
|
||||
</label>
|
||||
<div className="management-control management-control-stack">
|
||||
<span className="mono">owner action</span>
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void changeOwner({
|
||||
@@ -579,22 +582,23 @@ function Management() {
|
||||
}
|
||||
>
|
||||
Change owner
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Link
|
||||
className="btn management-action-btn"
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerParam, slug: skill.slug }}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button asChild className="management-action-btn">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerParam, slug: skill.slug }}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const reason = window.prompt(
|
||||
@@ -609,9 +613,9 @@ function Management() {
|
||||
}}
|
||||
>
|
||||
{skill.softDeletedAt ? "Restore" : "Hide"}
|
||||
</button>
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void setBatch({
|
||||
@@ -621,10 +625,10 @@ function Management() {
|
||||
}
|
||||
>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</button>
|
||||
</Button>
|
||||
{admin ? (
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Hard delete ${skill.displayName}?`)) return;
|
||||
@@ -632,11 +636,11 @@ function Management() {
|
||||
}}
|
||||
>
|
||||
Hard delete
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{staff ? (
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
disabled={!canBanOwner}
|
||||
onClick={() => {
|
||||
@@ -650,12 +654,12 @@ function Management() {
|
||||
}}
|
||||
>
|
||||
Ban user
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{admin ? (
|
||||
<>
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void setOfficialBadge({
|
||||
@@ -665,9 +669,9 @@ function Management() {
|
||||
}
|
||||
>
|
||||
{isOfficial ? "Remove official" : "Mark official"}
|
||||
</button>
|
||||
<button
|
||||
className="btn management-action-btn"
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void setDeprecatedBadge({
|
||||
@@ -677,7 +681,7 @@ function Management() {
|
||||
}
|
||||
>
|
||||
{isDeprecated ? "Remove deprecated" : "Mark deprecated"}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -686,10 +690,10 @@ function Management() {
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Duplicate candidates
|
||||
</h2>
|
||||
<div className="management-list">
|
||||
@@ -711,7 +715,7 @@ function Management() {
|
||||
>
|
||||
{entry.skill.displayName}
|
||||
</Link>
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
@{entry.owner?.handle ?? entry.owner?.name ?? "user"} · v
|
||||
{entry.latestVersion?.version ?? "—"} · fingerprint{" "}
|
||||
{entry.fingerprint?.slice(0, 8)}
|
||||
@@ -721,27 +725,27 @@ function Management() {
|
||||
<div key={match.skill._id} className="management-subitem">
|
||||
<div>
|
||||
<strong>{match.skill.displayName}</strong>
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
@{match.owner?.handle ?? match.owner?.name ?? "user"} ·{" "}
|
||||
{match.skill.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Link
|
||||
className="btn"
|
||||
to="/$owner/$slug"
|
||||
params={{
|
||||
owner: resolveOwnerParam(
|
||||
match.owner?.handle ?? null,
|
||||
match.owner?._id ?? match.skill.ownerUserId,
|
||||
),
|
||||
slug: match.skill.slug,
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
className="btn"
|
||||
<Button asChild>
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{
|
||||
owner: resolveOwnerParam(
|
||||
match.owner?.handle ?? null,
|
||||
match.owner?._id ?? match.skill.ownerUserId,
|
||||
),
|
||||
slug: match.skill.slug,
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void setDuplicate({
|
||||
@@ -751,58 +755,15 @@ function Management() {
|
||||
}
|
||||
>
|
||||
Mark duplicate
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Link
|
||||
className="btn"
|
||||
to="/$owner/$slug"
|
||||
params={{
|
||||
owner: resolveOwnerParam(
|
||||
entry.owner?.handle ?? null,
|
||||
entry.owner?._id ?? entry.skill.ownerUserId,
|
||||
),
|
||||
slug: entry.skill.slug,
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
Recent pushes
|
||||
</h2>
|
||||
<div className="management-list">
|
||||
{recentVersions.length === 0 ? (
|
||||
<div className="stat">No recent versions.</div>
|
||||
) : (
|
||||
recentVersions.map((entry) => (
|
||||
<div key={entry.version._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
<strong>{entry.skill?.displayName ?? "Unknown skill"}</strong>
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
v{entry.version.version} · @{entry.owner?.handle ?? entry.owner?.name ?? "user"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
{entry.skill ? (
|
||||
<Link className="btn" to="/management" search={{ skill: entry.skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
) : null}
|
||||
{entry.skill ? (
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="btn"
|
||||
to="/$owner/$slug"
|
||||
params={{
|
||||
owner: resolveOwnerParam(
|
||||
@@ -814,17 +775,64 @@ function Management() {
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Recent pushes
|
||||
</h2>
|
||||
<div className="management-list">
|
||||
{recentVersions.length === 0 ? (
|
||||
<div className="stat">No recent versions.</div>
|
||||
) : (
|
||||
recentVersions.map((entry) => (
|
||||
<div key={entry.version._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
<strong>{entry.skill?.displayName ?? "Unknown skill"}</strong>
|
||||
<div className="section-subtitle m-0">
|
||||
v{entry.version.version} · @{entry.owner?.handle ?? entry.owner?.name ?? "user"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
{entry.skill ? (
|
||||
<Button asChild>
|
||||
<Link to="/management" search={{ skill: entry.skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{entry.skill ? (
|
||||
<Button asChild>
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{
|
||||
owner: resolveOwnerParam(
|
||||
entry.owner?.handle ?? null,
|
||||
entry.owner?._id ?? entry.skill.ownerUserId,
|
||||
),
|
||||
slug: entry.skill.slug,
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{admin ? (
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Users
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
@@ -848,7 +856,7 @@ function Management() {
|
||||
<div className="management-item-main">
|
||||
<span className="mono">@{user.handle ?? user.name ?? "user"}</span>
|
||||
{user.deletedAt || user.deactivatedAt ? (
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
<div className="section-subtitle m-0">
|
||||
{user.banReason && user.deletedAt
|
||||
? `Banned ${formatTimestamp(user.deletedAt)} · ${user.banReason}`
|
||||
: `Deleted ${formatTimestamp((user.deactivatedAt ?? user.deletedAt) as number)}`}
|
||||
@@ -869,8 +877,7 @@ function Management() {
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
<Button
|
||||
type="button"
|
||||
disabled={user._id === me?._id}
|
||||
onClick={() => {
|
||||
@@ -891,10 +898,9 @@ function Management() {
|
||||
}}
|
||||
>
|
||||
Ban user
|
||||
</button>
|
||||
</Button>
|
||||
{user.deletedAt && !user.deactivatedAt ? (
|
||||
<button
|
||||
className="btn"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const label = `@${user.handle ?? user.name ?? "user"}`;
|
||||
@@ -909,14 +915,14 @@ function Management() {
|
||||
}}
|
||||
>
|
||||
Unban user
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -296,8 +296,7 @@ function PluginDetailRoute() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<main className="section">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
@@ -373,13 +372,12 @@ function PluginDetailRoute() {
|
||||
<span className="text-sm">
|
||||
Latest release: <strong>v{pkg.latestVersion}</strong>
|
||||
</span>
|
||||
<a
|
||||
href={getPackageDownloadPath(name, pkg.latestVersion)}
|
||||
className="inline-flex min-h-[34px] w-full items-center justify-center gap-2 rounded-[var(--radius-pill)] border border-[color:var(--border-ui)] bg-transparent px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)] transition-all duration-200 no-underline hover:border-[color:var(--border-ui-hover)] hover:bg-[color:var(--surface)] sm:w-auto sm:whitespace-nowrap"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -579,7 +577,6 @@ function PluginDetailRoute() {
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<h1 className="browse-title">Plugins</h1>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
@@ -152,20 +153,21 @@ export function PluginsIndex() {
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<Link
|
||||
className="btn btn-primary"
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish
|
||||
</Link>
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<form className="browse-page-search" onSubmit={handleSearch}>
|
||||
@@ -221,10 +223,9 @@ export function PluginsIndex() {
|
||||
)}
|
||||
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 22 }}>
|
||||
<div className="mt-5 flex justify-center gap-3">
|
||||
{search.cursor ? (
|
||||
<button
|
||||
className="btn"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
@@ -233,11 +234,11 @@ export function PluginsIndex() {
|
||||
}}
|
||||
>
|
||||
First page
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
<Button
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
@@ -246,7 +247,7 @@ export function PluginsIndex() {
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<main className="browse-page">
|
||||
<h1 className="browse-title" style={{ marginBottom: 16 }}>
|
||||
<h1 className="browse-title mb-4">
|
||||
{search.q ? (
|
||||
<>
|
||||
Search results for <span style={{ color: "var(--accent)" }}>"{search.q}"</span>
|
||||
Search results for <span className="text-[color:var(--accent)]">"{search.q}"</span>
|
||||
</>
|
||||
) : (
|
||||
"Search"
|
||||
@@ -68,7 +69,7 @@ function UnifiedSearchPage() {
|
||||
</h1>
|
||||
|
||||
<form className="search-page-form" onSubmit={handleSearch}>
|
||||
<div className="browse-search-bar" style={{ maxWidth: 560, flex: 1 }}>
|
||||
<div className="browse-search-bar max-w-[560px] flex-1">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
@@ -120,19 +121,19 @@ function UnifiedSearchPage() {
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="card">
|
||||
<Card>
|
||||
<div className="loading-indicator">Searching...</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : !search.q ? (
|
||||
<div className="card" style={{ textAlign: "center", padding: 40 }}>
|
||||
<p style={{ color: "var(--ink-soft)" }}>
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">
|
||||
Enter a search term to find skills, plugins, and users
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : results.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: "center", padding: 40 }}>
|
||||
<p style={{ color: "var(--ink-soft)" }}>No results found for "{search.q}"</p>
|
||||
</div>
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">No results found for "{search.q}"</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{results.map((item) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SkillCard } from "../../components/SkillCard";
|
||||
import { SkillListItem } from "../../components/SkillListItem";
|
||||
import { getPlatformLabels } from "../../components/skillDetailUtils";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { UserBadge } from "../../components/UserBadge";
|
||||
import { getSkillBadges } from "../../lib/badges";
|
||||
import { buildSkillHref, type SkillListEntry } from "./-types";
|
||||
@@ -109,8 +110,7 @@ export function SkillsResults({
|
||||
{canLoadMore || isLoadingMore ? (
|
||||
<div
|
||||
ref={canAutoLoad ? loadMoreRef : null}
|
||||
className="card"
|
||||
style={{ marginTop: 16, display: "flex", justifyContent: "center" }}
|
||||
className="card mt-4 flex justify-center"
|
||||
>
|
||||
{canAutoLoad ? (
|
||||
isLoadingMore ? (
|
||||
@@ -119,9 +119,9 @@ export function SkillsResults({
|
||||
"Scroll to load more"
|
||||
)
|
||||
) : (
|
||||
<button className="btn" type="button" onClick={loadMore} disabled={isLoadingMore}>
|
||||
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -219,7 +219,7 @@ export function SkillsToolbar({
|
||||
onClick={view === "list" ? onToggleView : undefined}
|
||||
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
|
||||
view === "cards"
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
aria-label="Grid view"
|
||||
@@ -231,7 +231,7 @@ export function SkillsToolbar({
|
||||
onClick={view === "cards" ? onToggleView : undefined}
|
||||
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
|
||||
view === "list"
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
aria-label="List view"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
const sortKeys = ["newest", "downloads", "stars", "name", "updated"] as const;
|
||||
type SortKey = (typeof sortKeys)[number];
|
||||
@@ -26,9 +28,9 @@ function SoulsHoldingPage() {
|
||||
<main className="section souls-coming-page">
|
||||
<section className="souls-coming-hero">
|
||||
<div>
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">Souls</span>
|
||||
<span className="tag tag-accent">Coming soon</span>
|
||||
<div className="skill-card-tags mb-3">
|
||||
<Badge>Souls</Badge>
|
||||
<Badge variant="accent">Coming soon</Badge>
|
||||
</div>
|
||||
<h1 className="about-title">SOUL.md discovery is on deck</h1>
|
||||
<p className="about-lead">
|
||||
@@ -55,18 +57,22 @@ function SoulsHoldingPage() {
|
||||
<section className="about-enforcement">
|
||||
<div>
|
||||
<span className="about-callout-label">In the meantime</span>
|
||||
<p className="about-lead" style={{ marginBottom: 0 }}>
|
||||
<p className="about-lead mb-0">
|
||||
ClawHub already handles skills and plugins. Souls will get the same discovery treatment
|
||||
once the publishing flow is ready.
|
||||
</p>
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Link className="btn btn-primary" to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
|
||||
Browse Skills
|
||||
</Link>
|
||||
<Link className="btn" to="/users" search={{ q: undefined }}>
|
||||
Browse Users
|
||||
</Link>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/users" search={{ q: undefined }}>
|
||||
Browse Users
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Doc } from "../../../convex/_generated/dataModel";
|
||||
import { SkillListItem } from "../../components/SkillListItem";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import type { PublicSkill, PublicUser } from "../../lib/publicUser";
|
||||
|
||||
export const Route = createFileRoute("/u/$handle")({
|
||||
@@ -38,9 +41,9 @@ function UserProfile() {
|
||||
if (user === undefined) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading user…</div>
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +51,7 @@ function UserProfile() {
|
||||
if (user === null) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">User not found.</div>
|
||||
<Card>User not found.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -111,15 +114,15 @@ function UserProfile() {
|
||||
<>
|
||||
{published.length > 0 ? (
|
||||
<>
|
||||
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
|
||||
<h2 className="home-section-title mb-3">
|
||||
Published ({published.length})
|
||||
</h2>
|
||||
{isLoadingPublished ? (
|
||||
<div className="card">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading published skills...</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="results-list" style={{ marginBottom: 24 }}>
|
||||
<div className="results-list mb-6">
|
||||
{published.map((skill) => (
|
||||
<SkillListItem key={skill._id} skill={skill} />
|
||||
))}
|
||||
@@ -128,15 +131,15 @@ function UserProfile() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
|
||||
<h2 className="home-section-title mb-3">
|
||||
Stars ({skills.length})
|
||||
</h2>
|
||||
{isLoadingSkills ? (
|
||||
<div className="card">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading stars...</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="card">No stars yet.</div>
|
||||
<Card>No stars yet.</Card>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{skills.map((skill) => (
|
||||
@@ -161,12 +164,12 @@ function InstalledSection(props: {
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
<h2 className="section-title text-xl">
|
||||
Installed
|
||||
</h2>
|
||||
<div className="card">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading telemetry…</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -174,32 +177,31 @@ function InstalledSection(props: {
|
||||
if (data === null) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
<h2 className="section-title text-xl">
|
||||
Installed
|
||||
</h2>
|
||||
<div className="card">Sign in to view your installed skills.</div>
|
||||
<Card>Sign in to view your installed skills.</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
<h2 className="section-title text-xl">
|
||||
Installed
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ maxWidth: 760 }}>
|
||||
<p className="section-subtitle max-w-[760px]">
|
||||
Private view. Only you can see your folders/roots. Everyone else only sees aggregated
|
||||
install counts per skill.
|
||||
</p>
|
||||
<div className="profile-actions">
|
||||
<button className="btn" type="button" onClick={props.onToggleRemoved}>
|
||||
<Button type="button" onClick={props.onToggleRemoved}>
|
||||
{props.includeRemoved ? "Hide removed" : "Show removed"}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => setShowRaw((value) => !value)}>
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setShowRaw((value) => !value)}>
|
||||
{showRaw ? "Hide JSON" : "Show JSON"}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!window.confirm("Delete all telemetry data?")) return;
|
||||
@@ -207,23 +209,23 @@ function InstalledSection(props: {
|
||||
}}
|
||||
>
|
||||
Delete telemetry
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showRaw ? (
|
||||
<div className="card telemetry-json" style={{ marginBottom: 18 }}>
|
||||
<pre className="mono" style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
<Card className="telemetry-json mb-4">
|
||||
<pre className="mono m-0 whitespace-pre-wrap">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{data.roots.length === 0 ? (
|
||||
<div className="card">No telemetry yet. Run `clawhub sync` from the CLI.</div>
|
||||
<Card>No telemetry yet. Run `clawhub sync` from the CLI.</Card>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<div className="grid gap-4">
|
||||
{data.roots.map((root) => (
|
||||
<div key={root.rootId} className="card telemetry-root">
|
||||
<Card key={root.rootId} className="telemetry-root">
|
||||
<div className="telemetry-root-header">
|
||||
<div>
|
||||
<div className="telemetry-root-title">{root.label}</div>
|
||||
@@ -232,7 +234,7 @@ function InstalledSection(props: {
|
||||
{root.expiredAt ? " · stale" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tag">{root.skills.length} skills</div>
|
||||
<Badge>{root.skills.length} skills</Badge>
|
||||
</div>
|
||||
{root.skills.length === 0 ? (
|
||||
<div className="stat">No skills found in this root.</div>
|
||||
@@ -255,7 +257,7 @@ function InstalledSection(props: {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { convexHttp } from "../../convex/client";
|
||||
import type { PublicUser } from "../../lib/publicUser";
|
||||
|
||||
@@ -84,9 +85,9 @@ function UsersIndex() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading users...</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No users found</p>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@import "@fontsource/ibm-plex-mono/latin.css";
|
||||
|
||||
@theme {
|
||||
/* Colors — core palette */
|
||||
--color-bg: var(--bg);
|
||||
--color-bg-soft: var(--bg-soft);
|
||||
--color-surface: var(--surface);
|
||||
@@ -12,6 +13,7 @@
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-soft: var(--ink-soft);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-fg: var(--accent-fg);
|
||||
--color-accent-deep: var(--accent-deep);
|
||||
--color-seafoam: var(--seafoam);
|
||||
--color-gold: var(--gold);
|
||||
@@ -19,14 +21,70 @@
|
||||
--color-border-ui: var(--border-ui);
|
||||
--color-border-ui-hover: var(--border-ui-hover);
|
||||
--color-border-ui-active: var(--border-ui-active);
|
||||
--radius-lg: var(--radius-lg);
|
||||
--radius-md: var(--radius-md);
|
||||
--radius-sm: var(--radius-sm);
|
||||
--radius-pill: var(--radius-pill);
|
||||
|
||||
/* Colors — status */
|
||||
--color-status-success-bg: var(--status-success-bg);
|
||||
--color-status-success-fg: var(--status-success-fg);
|
||||
--color-status-warning-bg: var(--status-warning-bg);
|
||||
--color-status-warning-fg: var(--status-warning-fg);
|
||||
--color-status-error-bg: var(--status-error-bg);
|
||||
--color-status-error-fg: var(--status-error-fg);
|
||||
|
||||
/* Colors — form controls */
|
||||
--color-input-border: var(--input-border);
|
||||
--color-input-bg: var(--input-bg);
|
||||
--color-input-placeholder: var(--input-placeholder);
|
||||
--color-input-focus-border: var(--input-focus-border);
|
||||
--color-input-focus-ring: var(--input-focus-ring);
|
||||
--color-label-fg: var(--label-fg);
|
||||
|
||||
/* Colors — interactive states */
|
||||
--color-hover-bg: var(--hover-bg);
|
||||
--color-active-bg: var(--active-bg);
|
||||
|
||||
/* Colors — overlay */
|
||||
--color-overlay-bg: var(--overlay-bg);
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-space-1: var(--space-1);
|
||||
--spacing-space-2: var(--space-2);
|
||||
--spacing-space-3: var(--space-3);
|
||||
--spacing-space-4: var(--space-4);
|
||||
--spacing-space-5: var(--space-5);
|
||||
--spacing-space-6: var(--space-6);
|
||||
--spacing-space-7: var(--space-7);
|
||||
--spacing-space-8: var(--space-8);
|
||||
|
||||
/* Typography scale */
|
||||
--font-size-fs-xs: var(--fs-xs);
|
||||
--font-size-fs-sm: var(--fs-sm);
|
||||
--font-size-fs-base: var(--fs-base);
|
||||
--font-size-fs-md: var(--fs-md);
|
||||
--font-size-fs-lg: var(--fs-lg);
|
||||
--font-size-fs-xl: var(--fs-xl);
|
||||
--font-size-fs-2xl: var(--fs-2xl);
|
||||
--font-size-fs-3xl: var(--fs-3xl);
|
||||
|
||||
/* Radii */
|
||||
--radius-lg: var(--r-lg);
|
||||
--radius-md: var(--r-md);
|
||||
--radius-sm: var(--r-sm);
|
||||
--radius-pill: var(--r-pill);
|
||||
|
||||
/* Fonts */
|
||||
--font-display: var(--font-display);
|
||||
--font-body: var(--font-body);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
/* Layout widths */
|
||||
--width-page-max: var(--page-max);
|
||||
--width-page-narrow: var(--page-narrow);
|
||||
--width-page-min: var(--page-min);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-default: var(--shadow);
|
||||
--shadow-dialog: var(--shadow-dialog);
|
||||
--shadow-hover: var(--shadow-hover);
|
||||
}
|
||||
|
||||
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
@@ -43,23 +101,56 @@
|
||||
--surface-muted: #1a1a1a;
|
||||
--nav-bg: rgba(10, 10, 10, 0.95);
|
||||
--ink: #e0e0e0;
|
||||
--ink-soft: #808080;
|
||||
--ink-soft: #818181;
|
||||
--accent: #ffffff;
|
||||
--accent-fg: #0a0a0a;
|
||||
--accent-deep: #cccccc;
|
||||
--seafoam: #808080;
|
||||
--seafoam: #818181;
|
||||
--diff-added: #4a9;
|
||||
--diff-added-strong: #3a8;
|
||||
--diff-removed: #e55;
|
||||
--diff-removed-strong: #c44;
|
||||
--diff-diagonal: #ffffff11;
|
||||
--diff-scrollbar-rgb: 128, 128, 128;
|
||||
--gold: #999;
|
||||
--gold: #999999;
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--border-ui: rgba(255, 255, 255, 0.15);
|
||||
--border-ui-hover: rgba(255, 255, 255, 0.25);
|
||||
--border-ui-active: rgba(255, 255, 255, 0.4);
|
||||
--shadow: none;
|
||||
|
||||
/* Status colors — monochrome TUI */
|
||||
--status-success-bg: rgba(255, 255, 255, 0.06);
|
||||
--status-success-fg: #a0a0a0;
|
||||
--status-warning-bg: rgba(255, 255, 255, 0.06);
|
||||
--status-warning-fg: #999999;
|
||||
--status-error-bg: rgba(255, 255, 255, 0.06);
|
||||
--status-error-fg: #b0b0b0;
|
||||
|
||||
/* Form controls */
|
||||
--input-border: rgba(255, 255, 255, 0.12);
|
||||
--input-bg: rgba(14, 28, 37, 0.84);
|
||||
--input-placeholder: rgba(184, 205, 216, 0.68);
|
||||
--input-focus-border: rgba(255, 255, 255, 0.4);
|
||||
--input-focus-ring: rgba(255, 255, 255, 0.12);
|
||||
--label-fg: rgba(206, 227, 238, 0.76);
|
||||
|
||||
/* Interactive states */
|
||||
--hover-bg: rgba(255, 255, 255, 0.04);
|
||||
--active-bg: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Overlay */
|
||||
--overlay-bg: rgba(10, 10, 10, 0.65);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: none;
|
||||
|
||||
/* Layout widths */
|
||||
--page-max: 1536px;
|
||||
--page-narrow: 900px;
|
||||
--page-min: 320px;
|
||||
|
||||
/* Spacing scale */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
@@ -104,11 +195,39 @@
|
||||
--ink: #0a0a0a;
|
||||
--ink-soft: #555555;
|
||||
--accent: #0a0a0a;
|
||||
--accent-fg: #f0f0f0;
|
||||
--accent-deep: #333333;
|
||||
--line: rgba(0, 0, 0, 0.12);
|
||||
--border-ui: rgba(0, 0, 0, 0.2);
|
||||
--border-ui-hover: rgba(0, 0, 0, 0.35);
|
||||
--border-ui-active: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Status colors — light */
|
||||
--status-success-bg: rgba(0, 0, 0, 0.04);
|
||||
--status-success-fg: #555555;
|
||||
--status-warning-bg: rgba(0, 0, 0, 0.04);
|
||||
--status-warning-fg: #555555;
|
||||
--status-error-bg: rgba(0, 0, 0, 0.04);
|
||||
--status-error-fg: #333333;
|
||||
|
||||
/* Form controls — light */
|
||||
--input-border: rgba(0, 0, 0, 0.2);
|
||||
--input-bg: rgba(255, 255, 255, 0.94);
|
||||
--input-placeholder: rgba(88, 115, 133, 0.72);
|
||||
--input-focus-border: rgba(0, 0, 0, 0.5);
|
||||
--input-focus-ring: rgba(0, 0, 0, 0.08);
|
||||
--label-fg: rgba(70, 95, 113, 0.9);
|
||||
|
||||
/* Interactive states — light */
|
||||
--hover-bg: rgba(0, 0, 0, 0.04);
|
||||
--active-bg: rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Overlay — light */
|
||||
--overlay-bg: rgba(240, 240, 240, 0.65);
|
||||
|
||||
/* Shadows — light */
|
||||
--shadow-dialog: 0 24px 50px rgba(0, 0, 0, 0.12);
|
||||
--shadow-hover: none;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -155,6 +274,7 @@ code {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-width: var(--page-min);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -169,7 +289,7 @@ code {
|
||||
}
|
||||
|
||||
.site-footer-inner {
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -225,7 +345,7 @@ code {
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
max-width: 1360px;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 22px;
|
||||
display: flex;
|
||||
@@ -354,6 +474,10 @@ code {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.navbar-tab:hover svg {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.navbar-tab.active,
|
||||
.navbar-tab[data-status="active"] {
|
||||
color: var(--ink);
|
||||
@@ -361,6 +485,11 @@ code {
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.navbar-tab.active svg,
|
||||
.navbar-tab[data-status="active"] svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navbar-tab-secondary {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.55;
|
||||
@@ -389,17 +518,16 @@ code {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--ink);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
@@ -540,6 +668,7 @@ code {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* @deprecated — use <Button> from components/ui/button.tsx */
|
||||
.btn {
|
||||
border: 1px solid var(--border-ui);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -570,9 +699,9 @@ code {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
border: 1px solid var(--ink);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
@@ -587,7 +716,7 @@ code {
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
@@ -666,7 +795,7 @@ code {
|
||||
|
||||
.section {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-5) var(--space-7);
|
||||
box-sizing: border-box;
|
||||
@@ -736,6 +865,7 @@ code {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* @deprecated — use <Label> from components/ui/label.tsx */
|
||||
.form-label {
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.14em;
|
||||
@@ -744,6 +874,7 @@ code {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* @deprecated — use <Input> from components/ui/input.tsx */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -895,7 +1026,7 @@ code {
|
||||
}
|
||||
|
||||
.plugin-publish-lock-note.is-ready {
|
||||
color: #808080;
|
||||
color: #818181;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -1266,7 +1397,7 @@ code {
|
||||
|
||||
.upload-requirement.ok {
|
||||
background: rgba(43, 198, 164, 0.18);
|
||||
color: #808080;
|
||||
color: #818181;
|
||||
}
|
||||
|
||||
.upload-filelist {
|
||||
@@ -1356,7 +1487,7 @@ code {
|
||||
.upload-ready {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #808080;
|
||||
color: #818181;
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
@@ -1708,6 +1839,7 @@ code {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* @deprecated — use <Card> from components/ui/card.tsx */
|
||||
.card {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
@@ -2089,6 +2221,7 @@ code {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* @deprecated — use <Badge> from components/ui/badge.tsx */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2200,7 +2333,7 @@ code {
|
||||
|
||||
.star-toggle.is-active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent-deep);
|
||||
}
|
||||
|
||||
@@ -2215,7 +2348,7 @@ code {
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(43, 198, 164, 0.45);
|
||||
background: rgba(43, 198, 164, 0.14);
|
||||
color: #808080;
|
||||
color: #818181;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
@@ -2937,7 +3070,7 @@ code {
|
||||
|
||||
.diff-pill-added {
|
||||
background: rgba(43, 198, 164, 0.18);
|
||||
color: #808080;
|
||||
color: #818181;
|
||||
}
|
||||
|
||||
.diff-pill-removed {
|
||||
@@ -5226,7 +5359,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
position: relative;
|
||||
padding: var(--space-6) var(--space-5) var(--space-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
isolation: isolate;
|
||||
overflow: clip;
|
||||
@@ -5256,7 +5389,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.home-hero-inner {
|
||||
max-width: 1120px;
|
||||
max-width: var(--page-max);
|
||||
}
|
||||
|
||||
.home-hero-grid {
|
||||
@@ -5372,7 +5505,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
/* Home sections */
|
||||
|
||||
.home-section {
|
||||
max-width: 1200px;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-5) var(--space-6);
|
||||
}
|
||||
@@ -5562,7 +5695,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
/* ===== Browse Pages with Faceted Sidebar ===== */
|
||||
|
||||
.browse-page {
|
||||
max-width: 1450px;
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 20px var(--space-7);
|
||||
}
|
||||
|
||||