mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix: improve auth flow and webkit compatibility (#1555)
* fix: improve auth flow, skill filters, and webkit compatibility * test: mock auth actions in settings route * fix: remove stale skills toolbar props * fix: address skills filter and webkit review feedback * test: avoid monaco lazy import in skill detail test * fix: recompute other skills category filter
This commit is contained in:
@@ -24,6 +24,10 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/SkillDiffCard", () => ({
|
||||
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
|
||||
}));
|
||||
|
||||
describe("SkillDetailPage", () => {
|
||||
const skillId = "skills:1" as Id<"skills">;
|
||||
const ownerId = "users:1" as Id<"users">;
|
||||
|
||||
@@ -301,6 +301,29 @@ describe("SkillsIndex", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows and clears the active capability tag filter", async () => {
|
||||
searchMock = { tag: "crypto" };
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
|
||||
expect(capabilityChip).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(capabilityChip);
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
replace?: boolean;
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.replace).toBe(true);
|
||||
expect(lastCall.search({ tag: "crypto" })).toEqual({
|
||||
tag: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows load-more button when more results are available", async () => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
convexHttpMock.query.mockResolvedValue({
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { startThemeTransition } from "../lib/theme-transition";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthError } from "../lib/useAuthError";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -24,7 +24,7 @@ import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { signOut } = useAuthActions();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const siteMode = getSiteMode();
|
||||
@@ -37,7 +37,6 @@ export default function Header() {
|
||||
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
|
||||
const isStaff = isModerator(me);
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const setTheme = (next: "system" | "light" | "dark") => {
|
||||
startThemeTransition({
|
||||
@@ -311,25 +310,14 @@ export default function Header() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
<SignInButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
void signIn(
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(
|
||||
getUserFacingAuthError(error, "Sign in failed. Please try again."),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Sign in</span>
|
||||
<span className="hidden text-white/70 sm:inline">with GitHub</span>
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,8 +325,3 @@ export default function Header() {
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
|
||||
const signInMock = vi.fn();
|
||||
const clearAuthErrorMock = vi.fn();
|
||||
const setAuthErrorMock = vi.fn();
|
||||
const getUserFacingAuthErrorMock = vi.fn();
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
signIn: signInMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthError", () => ({
|
||||
clearAuthError: () => clearAuthErrorMock(),
|
||||
setAuthError: (message: string) => setAuthErrorMock(message),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/authErrorMessage", () => ({
|
||||
getUserFacingAuthError: (error: unknown, fallback: string) =>
|
||||
getUserFacingAuthErrorMock(error, fallback),
|
||||
}));
|
||||
|
||||
describe("SignInButton", () => {
|
||||
beforeEach(() => {
|
||||
signInMock.mockReset();
|
||||
clearAuthErrorMock.mockReset();
|
||||
setAuthErrorMock.mockReset();
|
||||
getUserFacingAuthErrorMock.mockReset();
|
||||
getUserFacingAuthErrorMock.mockImplementation((_, fallback) => fallback);
|
||||
window.history.replaceState(null, "", "/skills?q=test#top");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts GitHub sign-in with the current relative URL by default", async () => {
|
||||
signInMock.mockResolvedValue({ signingIn: true });
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInMock).toHaveBeenCalledWith("github", {
|
||||
redirectTo: "/skills?q=test#top",
|
||||
});
|
||||
});
|
||||
expect(clearAuthErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(setAuthErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a generic error when sign-in resolves without redirecting", async () => {
|
||||
signInMock.mockResolvedValue({ signingIn: false });
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces user-facing auth errors when sign-in rejects", async () => {
|
||||
const failure = new Error("oauth failed");
|
||||
signInMock.mockRejectedValue(failure);
|
||||
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
|
||||
failure,
|
||||
"Sign in failed. Please try again.",
|
||||
);
|
||||
expect(setAuthErrorMock).toHaveBeenCalledWith("GitHub auth unavailable");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { clearAuthError, setAuthError } from "../lib/useAuthError";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
type ButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
|
||||
redirectTo?: string;
|
||||
};
|
||||
|
||||
export function SignInButton({
|
||||
redirectTo,
|
||||
children = "Sign in with GitHub",
|
||||
...props
|
||||
}: SignInButtonProps) {
|
||||
const { signIn } = useAuthActions();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
const next = redirectTo ?? getCurrentRelativeUrl();
|
||||
void signIn("github", next ? { redirectTo: next } : undefined)
|
||||
.then((result) => {
|
||||
if (result?.signingIn === false) {
|
||||
setAuthError("Sign in failed. Please try again.");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setAuthError(
|
||||
getUserFacingAuthError(error, "Sign in failed. Please try again."),
|
||||
);
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { canManageSkill, isModerator } from "../lib/roles";
|
||||
import { hasOwnProperty } from "../lib/hasOwnProperty";
|
||||
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
@@ -37,13 +38,11 @@ type SkillDetailPageProps = {
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
if (hasOwnProperty(error, "data")) {
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (typeof data === "string" && data.trim()) return data.trim();
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"message" in data &&
|
||||
hasOwnProperty(data, "message") &&
|
||||
typeof (data as { message?: unknown }).message === "string"
|
||||
) {
|
||||
const message = (data as { message?: string }).message?.trim();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasOwnProperty } from "../lib/hasOwnProperty";
|
||||
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
|
||||
|
||||
type UserBadgeProps = {
|
||||
@@ -17,11 +18,13 @@ export function UserBadge({
|
||||
link = true,
|
||||
showName = false,
|
||||
}: UserBadgeProps) {
|
||||
const userName = user && "name" in user ? user.name?.trim() : undefined;
|
||||
const userName = hasOwnProperty(user, "name") && typeof user.name === "string"
|
||||
? user.name.trim()
|
||||
: undefined;
|
||||
const displayName = user?.displayName?.trim() || userName || null;
|
||||
const handle = user?.handle ?? fallbackHandle ?? null;
|
||||
const href =
|
||||
user?.handle && "kind" in user
|
||||
user?.handle && hasOwnProperty(user, "kind")
|
||||
? user.kind === "org"
|
||||
? `/orgs/${encodeURIComponent(user.handle)}`
|
||||
: `/u/${encodeURIComponent(user.handle)}`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SkillCategory = {
|
||||
slug: string;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
};
|
||||
|
||||
export const SKILL_CATEGORIES: SkillCategory[] = [
|
||||
{ slug: "mcp-tools", label: "MCP Tools", keywords: ["mcp", "tool", "server"] },
|
||||
{ slug: "prompts", label: "Prompts", keywords: ["prompt", "template", "system"] },
|
||||
{ slug: "workflows", label: "Workflows", keywords: ["workflow", "pipeline", "chain"] },
|
||||
{ slug: "dev-tools", label: "Dev Tools", keywords: ["dev", "debug", "lint", "test", "build"] },
|
||||
{ slug: "data", label: "Data & APIs", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
|
||||
{ slug: "security", label: "Security", keywords: ["security", "scan", "auth", "encrypt"] },
|
||||
{ slug: "automation", label: "Automation", keywords: ["auto", "cron", "schedule", "bot"] },
|
||||
{ slug: "other", label: "Other", keywords: [] },
|
||||
];
|
||||
|
||||
export const ALL_CATEGORY_KEYWORDS = SKILL_CATEGORIES.flatMap((c) => c.keywords);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hasOwnProperty } from "./hasOwnProperty";
|
||||
|
||||
type ConvexLikeErrorData =
|
||||
| string
|
||||
| {
|
||||
@@ -24,9 +26,12 @@ export function getUserFacingConvexError(error: unknown, fallback: string) {
|
||||
const candidates: string[] = [];
|
||||
const maybe = error as ConvexLikeError;
|
||||
|
||||
if (maybe && typeof maybe === "object" && "data" in maybe) {
|
||||
if (hasOwnProperty(maybe, "data")) {
|
||||
if (typeof maybe.data === "string") candidates.push(maybe.data);
|
||||
if (maybe.data && typeof maybe.data === "object" && typeof maybe.data.message === "string") {
|
||||
if (
|
||||
hasOwnProperty(maybe.data, "message") &&
|
||||
typeof maybe.data.message === "string"
|
||||
) {
|
||||
candidates.push(maybe.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function hasOwnProperty<K extends PropertyKey>(
|
||||
value: unknown,
|
||||
key: K,
|
||||
): value is Record<K, unknown> {
|
||||
return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
+16
-3
@@ -4,6 +4,7 @@ import type {
|
||||
PackageVerificationSummary,
|
||||
} from "clawhub-schema";
|
||||
import { ApiRoutes } from "clawhub-schema/routes";
|
||||
import { hasOwnProperty } from "./hasOwnProperty";
|
||||
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
|
||||
|
||||
export type PackageListItem = {
|
||||
@@ -117,6 +118,11 @@ type PluginCatalogResult = {
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
type PackageCatalogBrowseResponse = {
|
||||
items: PackageListItem[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
type PackageApiErrorOptions = {
|
||||
status: number;
|
||||
retryAfterSeconds?: number | null;
|
||||
@@ -302,10 +308,17 @@ export async function fetchPluginCatalog(params: {
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
|
||||
return {
|
||||
items: response.results.map((entry) => entry.package),
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const browseResponse = response as PackageCatalogBrowseResponse;
|
||||
return {
|
||||
items:
|
||||
"results" in response ? response.results.map((entry) => entry.package) : response.items,
|
||||
nextCursor: "results" in response ? null : response.nextCursor,
|
||||
items: browseResponse.items,
|
||||
nextCursor: browseResponse.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,26 @@ import { Settings } from "./settings";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const useAuthActionsMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: (...args: unknown[]) => useMutationMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => useAuthActionsMock(),
|
||||
}));
|
||||
|
||||
describe("Settings", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useMutationMock.mockReset();
|
||||
useAuthActionsMock.mockReset();
|
||||
useMutationMock.mockReturnValue(vi.fn());
|
||||
useAuthActionsMock.mockReturnValue({
|
||||
signIn: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("skips token loading until auth has resolved", () => {
|
||||
|
||||
+4
-24
@@ -1,14 +1,12 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { SignInButton } from "../../components/SignInButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
|
||||
import { getUserFacingAuthError } from "../../lib/authErrorMessage";
|
||||
import { getClawHubSiteUrl, normalizeClawHubSiteOrigin } from "../../lib/site";
|
||||
import { setAuthError, useAuthError } from "../../lib/useAuthError";
|
||||
import { useAuthError } from "../../lib/useAuthError";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
export const Route = createFileRoute("/cli/auth")({
|
||||
@@ -17,7 +15,6 @@ export const Route = createFileRoute("/cli/auth")({
|
||||
|
||||
function CliAuth() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn } = useAuthActions();
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const createToken = useMutation(api.tokens.create);
|
||||
|
||||
@@ -35,7 +32,6 @@ function CliAuth() {
|
||||
const label =
|
||||
(decodeLabel(search.label_b64) ?? search.label ?? "CLI token").trim() || "CLI token";
|
||||
const state = typeof search.state === "string" ? search.state.trim() : "";
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const safeRedirect = useMemo(() => isAllowedRedirectUri(redirectUri), [redirectUri]);
|
||||
const registry = useMemo(() => {
|
||||
@@ -139,23 +135,12 @@ function CliAuth() {
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
<SignInButton
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
void signIn(
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(
|
||||
getUserFacingAuthError(error, "Sign in failed. Please try again."),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
@@ -214,8 +199,3 @@ function decodeLabel(value: string | undefined) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
@@ -120,7 +121,10 @@ function Dashboard() {
|
||||
return (
|
||||
<Container className="py-10">
|
||||
<Card>
|
||||
<CardContent>Sign in to access your dashboard.</CardContent>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<span>Sign in to access your dashboard.</span>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
@@ -224,7 +225,11 @@ export function ImportGitHub() {
|
||||
<EmptyState
|
||||
title={isLoading ? "Loading..." : "Sign in to import and publish skills"}
|
||||
description="You need to be signed in to import skills from GitHub."
|
||||
/>
|
||||
>
|
||||
{!isLoading ? (
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
@@ -14,6 +13,7 @@ import { api } from "../../convex/_generated/api";
|
||||
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||
@@ -44,7 +44,6 @@ export const Route = createFileRoute("/publish-skill")({
|
||||
|
||||
export function Upload() {
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
const { signIn } = useAuthActions();
|
||||
const { updateSlug } = useSearch({ from: "/publish-skill" });
|
||||
const siteMode = getSiteMode();
|
||||
const isSoulMode = siteMode === "souls";
|
||||
@@ -347,8 +346,9 @@ export function Upload() {
|
||||
<EmptyState
|
||||
title={`Sign in to publish a ${contentLabel}`}
|
||||
description="You need to be signed in to publish skills on ClawHub."
|
||||
action={{ label: "Sign in", onClick: () => void signIn("github") }}
|
||||
/>
|
||||
>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</EmptyState>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
@@ -364,7 +364,7 @@ export function Upload() {
|
||||
event.preventDefault();
|
||||
setHasAttempted(true);
|
||||
if (!validation.ready) {
|
||||
if (validationRef.current && "scrollIntoView" in validationRef.current) {
|
||||
if (typeof validationRef.current?.scrollIntoView === "function") {
|
||||
validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -107,7 +108,10 @@ export function Settings() {
|
||||
return (
|
||||
<Container size="narrow" className="py-10">
|
||||
<Card>
|
||||
<CardContent>Sign in to access settings.</CardContent>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<span>Sign in to access settings.</span>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { ArrowDownUp, Check, Grid3X3, List, Search, X } from "lucide-react";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Check,
|
||||
Database,
|
||||
GitBranch,
|
||||
Grid3X3,
|
||||
List,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Plug,
|
||||
Search,
|
||||
Shield,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import { SKILL_CAPABILITY_TAGS } from "../../../convex/lib/skillCapabilityTags";
|
||||
import { useMemo } from "react";
|
||||
import { SKILL_CATEGORIES, type SkillCategory } from "../../lib/categories";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
@@ -40,6 +56,17 @@ const SKILL_CAPABILITY_LABELS: Record<string, string> = {
|
||||
"posts-externally": "External posting",
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
"mcp-tools": <Plug size={13} />,
|
||||
prompts: <MessageSquare size={13} />,
|
||||
workflows: <GitBranch size={13} />,
|
||||
"dev-tools": <Wrench size={13} />,
|
||||
data: <Database size={13} />,
|
||||
security: <Shield size={13} />,
|
||||
automation: <Zap size={13} />,
|
||||
other: <Package size={13} />,
|
||||
};
|
||||
|
||||
export function SkillsToolbar({
|
||||
searchInputRef,
|
||||
query,
|
||||
@@ -58,6 +85,26 @@ export function SkillsToolbar({
|
||||
onToggleDir,
|
||||
onToggleView,
|
||||
}: SkillsToolbarProps) {
|
||||
const activeCategory = useMemo(() => {
|
||||
if (query === "__other__") return "other";
|
||||
if (!query) return undefined;
|
||||
return SKILL_CATEGORIES.find((c) =>
|
||||
c.keywords.some((k) => k === query.trim().toLowerCase()),
|
||||
)?.slug;
|
||||
}, [query]);
|
||||
|
||||
const handleCategoryChange = (cat: SkillCategory | undefined) => {
|
||||
if (!cat) {
|
||||
onQueryChange("");
|
||||
} else if (cat.slug === "other") {
|
||||
onQueryChange("__other__");
|
||||
} else if (cat.keywords[0]) {
|
||||
onQueryChange(cat.keywords[0]);
|
||||
} else {
|
||||
onQueryChange("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Search row */}
|
||||
@@ -91,18 +138,30 @@ export function SkillsToolbar({
|
||||
<FilterChip active={nonSuspiciousOnly} onClick={onToggleNonSuspicious}>
|
||||
Clean only
|
||||
</FilterChip>
|
||||
<Select value={capabilityTag ?? "__all__"} onValueChange={onCapabilityTagChange}>
|
||||
{capabilityTag ? (
|
||||
<FilterChip
|
||||
active
|
||||
onClick={() => onCapabilityTagChange("__all__")}
|
||||
icon={<X className="h-3 w-3" />}
|
||||
>
|
||||
{SKILL_CAPABILITY_LABELS[capabilityTag] ?? capabilityTag}
|
||||
</FilterChip>
|
||||
) : null}
|
||||
<Select value={activeCategory ?? "__all__"} onValueChange={(v) => handleCategoryChange(v === "__all__" ? undefined : SKILL_CATEGORIES.find((c) => c.slug === v))}>
|
||||
<SelectTrigger
|
||||
className="w-auto min-w-[156px] min-h-[36px] py-1.5 text-xs font-semibold"
|
||||
aria-label="Filter by tag"
|
||||
aria-label="Filter by category"
|
||||
>
|
||||
<SelectValue placeholder="All tags" />
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All tags</SelectItem>
|
||||
{SKILL_CAPABILITY_TAGS.map((tag) => (
|
||||
<SelectItem key={tag} value={tag}>
|
||||
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
|
||||
<SelectItem value="__all__">All categories</SelectItem>
|
||||
{SKILL_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat.slug} value={cat.slug}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="opacity-60">{CATEGORY_ICONS[cat.slug]}</span>
|
||||
{cat.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -131,11 +190,11 @@ export function SkillsToolbar({
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleDir}
|
||||
aria-label={`Sort direction: ${dir === "asc" ? "ascending" : "descending"}`}
|
||||
className="min-h-[36px] px-2"
|
||||
className="min-h-[36px] px-2 rounded-[var(--radius-sm)]"
|
||||
>
|
||||
<ArrowDownUp
|
||||
className={`h-4 w-4 transition-transform ${dir === "asc" ? "rotate-180" : ""}`}
|
||||
@@ -143,7 +202,7 @@ export function SkillsToolbar({
|
||||
</Button>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="inline-flex items-center rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-0.5">
|
||||
<div className="inline-flex items-center rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={view === "list" ? onToggleView : undefined}
|
||||
@@ -178,23 +237,26 @@ function FilterChip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
icon,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] border px-3 py-1.5 text-xs font-semibold transition-all duration-150 ${
|
||||
className={`inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 min-h-[36px] text-xs font-semibold transition-all duration-150 ${
|
||||
active
|
||||
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)]"
|
||||
: "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)]"
|
||||
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
{active && <Check className="h-3 w-3" />}
|
||||
{active && !icon && <Check className="h-3 w-3" />}
|
||||
{icon && <span className={active ? "opacity-100" : "opacity-60"}>{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAction } from "convex/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { convexHttp } from "../../convex/client";
|
||||
import { ALL_CATEGORY_KEYWORDS } from "../../lib/categories";
|
||||
import { parseDir, parseSort, toListSort, type SortDir, type SortKey } from "./-params";
|
||||
import type { SkillListEntry, SkillSearchEntry } from "./-types";
|
||||
|
||||
@@ -60,8 +61,9 @@ export function useSkillsBrowseModel({
|
||||
const capabilityTag = search.tag;
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
|
||||
const isOtherCategory = query === "__other__";
|
||||
const trimmedQuery = useMemo(() => query.trim(), [query]);
|
||||
const hasQuery = trimmedQuery.length > 0;
|
||||
const hasQuery = !isOtherCategory && trimmedQuery.length > 0;
|
||||
const sort: SortKey =
|
||||
search.sort === "relevance" && !hasQuery
|
||||
? "downloads"
|
||||
@@ -192,6 +194,12 @@ export function useSkillsBrowseModel({
|
||||
}, [hasQuery, listResults, searchResults]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (isOtherCategory) {
|
||||
return baseItems.filter((entry) => {
|
||||
const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
|
||||
return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw));
|
||||
});
|
||||
}
|
||||
if (!hasQuery) {
|
||||
return baseItems;
|
||||
}
|
||||
@@ -233,7 +241,7 @@ export function useSkillsBrowseModel({
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}, [baseItems, dir, hasQuery, sort]);
|
||||
}, [baseItems, dir, hasQuery, isOtherCategory, sort]);
|
||||
|
||||
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList;
|
||||
const canLoadMore = hasQuery
|
||||
|
||||
@@ -78,11 +78,11 @@ export function SkillsIndex() {
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
Skills
|
||||
{totalSkillsText && (
|
||||
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
|
||||
({totalSkillsText})
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
|
||||
({model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly
|
||||
? model.sorted.length.toLocaleString("en-US")
|
||||
: totalSkillsText ?? "…"})
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
{model.isLoadingSkills
|
||||
@@ -112,11 +112,14 @@ export function SkillsIndex() {
|
||||
/>
|
||||
|
||||
{/* Results count */}
|
||||
{!model.isLoadingSkills && model.sorted.length > 0 && (
|
||||
{model.sorted.length > 0 && (
|
||||
<p className="text-xs font-medium text-[color:var(--ink-soft)]">
|
||||
Showing {model.sorted.length}
|
||||
{totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
|
||||
{model.sorted.length}
|
||||
{!model.hasQuery && totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
|
||||
{model.hasQuery ? ` matching "${model.query}"` : ""}
|
||||
{model.highlightedOnly || model.nonSuspiciousOnly || model.capabilityTag
|
||||
? ` (filtered)`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
@@ -31,7 +32,9 @@ function Stars() {
|
||||
icon={Star}
|
||||
title="Sign in to see your highlights"
|
||||
description="Star skills for quick access later."
|
||||
/>
|
||||
>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</EmptyState>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api } from "../../../convex/_generated/api";
|
||||
import type { Doc } from "../../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { SignInButton } from "../../components/SignInButton";
|
||||
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
@@ -236,7 +237,9 @@ function InstalledSection(props: {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
|
||||
<EmptyState title="Sign in to view your installed skills" />
|
||||
<EmptyState title="Sign in to view your installed skills">
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+119
-1
@@ -5,7 +5,7 @@ import { devtools } from "@tanstack/devtools-vite";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { nitro } from "nitro/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import viteTsConfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -40,6 +40,123 @@ function handleRollupWarning(
|
||||
warn(warning);
|
||||
}
|
||||
|
||||
type SourceReplacement = readonly [from: string, to: string];
|
||||
|
||||
const reflectHas = (target: string, key: string) => `Reflect.has(${target}, ${JSON.stringify(key)})`;
|
||||
|
||||
const arkSafariInOperatorFixes = [
|
||||
{
|
||||
suffix: "/node_modules/.vite/deps/arktype.js",
|
||||
replacements: [
|
||||
['"expression" in value', reflectHas("value", "expression")],
|
||||
['"toJSON" in o', reflectHas("o", "toJSON")],
|
||||
['"morphs" in schema', reflectHas("schema", "morphs")],
|
||||
['"branches" in schema', reflectHas("schema", "branches")],
|
||||
['"unit" in schema', reflectHas("schema", "unit")],
|
||||
['"reference" in schema', reflectHas("schema", "reference")],
|
||||
['"proto" in schema', reflectHas("schema", "proto")],
|
||||
['"domain" in schema', reflectHas("schema", "domain")],
|
||||
['"value" in transformedInner', reflectHas("transformedInner", "value")],
|
||||
['"default" in this.inner', reflectHas("this.inner", "default")],
|
||||
['"variadic" in schema', reflectHas("schema", "variadic")],
|
||||
['"prefix" in schema', reflectHas("schema", "prefix")],
|
||||
['"defaultables" in schema', reflectHas("schema", "defaultables")],
|
||||
['"optionals" in schema', reflectHas("schema", "optionals")],
|
||||
['"postfix" in schema', reflectHas("schema", "postfix")],
|
||||
['"minVariadicLength" in schema', reflectHas("schema", "minVariadicLength")],
|
||||
['"description" in ctx', reflectHas("ctx", "description")],
|
||||
['"data" in input', reflectHas("input", "data")],
|
||||
['"get" in desc', reflectHas("desc", "get")],
|
||||
['"set" in desc', reflectHas("desc", "set")],
|
||||
] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/util/out/serialize.js",
|
||||
replacements: [
|
||||
['"expression" in value', reflectHas("value", "expression")],
|
||||
['"toJSON" in o', reflectHas("o", "toJSON")],
|
||||
],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/parse.js",
|
||||
replacements: [
|
||||
['"morphs" in schema', reflectHas("schema", "morphs")],
|
||||
['"branches" in schema', reflectHas("schema", "branches")],
|
||||
['"unit" in schema', reflectHas("schema", "unit")],
|
||||
['"reference" in schema', reflectHas("schema", "reference")],
|
||||
['"proto" in schema', reflectHas("schema", "proto")],
|
||||
['"domain" in schema', reflectHas("schema", "domain")],
|
||||
] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/node.js",
|
||||
replacements: [['"value" in transformedInner', reflectHas("transformedInner", "value")]] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/scope.js",
|
||||
replacements: [['"branches" in schema', reflectHas("schema", "branches")]] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/structure/optional.js",
|
||||
replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/structure/sequence.js",
|
||||
replacements: [
|
||||
['"variadic" in schema', reflectHas("schema", "variadic")],
|
||||
['"prefix" in schema', reflectHas("schema", "prefix")],
|
||||
['"defaultables" in schema', reflectHas("schema", "defaultables")],
|
||||
['"optionals" in schema', reflectHas("schema", "optionals")],
|
||||
['"postfix" in schema', reflectHas("schema", "postfix")],
|
||||
['"minVariadicLength" in schema', reflectHas("schema", "minVariadicLength")],
|
||||
] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/structure/prop.js",
|
||||
replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/shared/implement.js",
|
||||
replacements: [['"description" in ctx', reflectHas("ctx", "description")]] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/schema/out/shared/errors.js",
|
||||
replacements: [['"data" in input', reflectHas("input", "data")]] satisfies SourceReplacement[],
|
||||
},
|
||||
{
|
||||
suffix: "/node_modules/@ark/util/out/clone.js",
|
||||
replacements: [
|
||||
['"get" in desc', reflectHas("desc", "get")],
|
||||
['"set" in desc', reflectHas("desc", "set")],
|
||||
] satisfies SourceReplacement[],
|
||||
},
|
||||
] as const;
|
||||
|
||||
function patchArkSafariInOperator(): Plugin {
|
||||
return {
|
||||
name: "patch-ark-safari-in-operator",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
const normalizedId = id.split("?")[0].replace(/\\/g, "/");
|
||||
const fix = arkSafariInOperatorFixes.find((entry) => normalizedId.endsWith(entry.suffix));
|
||||
if (!fix) return null;
|
||||
|
||||
let nextCode = code;
|
||||
for (const [from, to] of fix.replacements) {
|
||||
if (!nextCode.includes(from)) {
|
||||
this.error(`Expected to patch ${from} in ${normalizedId}`);
|
||||
}
|
||||
nextCode = nextCode.replaceAll(from, to);
|
||||
}
|
||||
|
||||
return {
|
||||
code: nextCode,
|
||||
map: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: {
|
||||
dedupe: ["convex", "@convex-dev/auth", "react", "react-dom"],
|
||||
@@ -54,6 +171,7 @@ const config = defineConfig({
|
||||
include: ["convex/react", "convex/browser"],
|
||||
},
|
||||
plugins: [
|
||||
patchArkSafariInOperator(),
|
||||
devtools(),
|
||||
nitro({
|
||||
serverDir: "server",
|
||||
|
||||
Reference in New Issue
Block a user