mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat(ui): move creator into skill and plugin detail hero (#2842)
* feat(ui): move creator into skill and plugin detail hero Show publisher name, handle, and official badge below the summary in the shared detail hero, remove the sidebar Creator row, and resolve official status from backend publisher data plus client fallback lookup. * fix(ui): avoid extra official publisher detail reads * fix(ui): align creator hero types with package API
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -970,10 +970,11 @@ describe("plugin detail route", () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
const { container } = render(<Component />);
|
||||
|
||||
expect(screen.getAllByText("Official").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("Official").length).toBeGreaterThan(0);
|
||||
expect(container.querySelector(".skill-hero-creator .official-badge-icon-only")).toBeTruthy();
|
||||
expect(container.querySelector(".skill-hero-title-row .official-tag")).toBeNull();
|
||||
expect(screen.queryByText("Verified")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1106,7 +1107,8 @@ describe("plugin detail route", () => {
|
||||
const typeRow = sidebarRows.find((row) => row.text.includes("Code Plugin"));
|
||||
expect(downloadsRow?.hasDownload).toBe(false);
|
||||
expect(downloadOnlyRow).toBeTruthy();
|
||||
expect(creatorRow).toBeTruthy();
|
||||
expect(creatorRow).toBeUndefined();
|
||||
expect(document.querySelector(".skill-hero-creator")?.textContent).toContain("Demo Owner");
|
||||
expect(typeRow).toBeTruthy();
|
||||
expect(sidebarRows.at(-1)).toEqual(downloadOnlyRow);
|
||||
expect(screen.getByRole("link", { name: /Download/i }).getAttribute("href")).toBe(
|
||||
|
||||
@@ -1128,7 +1128,16 @@ describe("SkillDetailPage", () => {
|
||||
const securityAuditLabelIndex = sidebarLabels.findIndex((label) =>
|
||||
label?.startsWith("Security audit"),
|
||||
);
|
||||
expect(securityAuditLabelIndex).toBe(sidebarLabels.indexOf("Creator") + 1);
|
||||
expect(sidebarLabels).not.toContain("Creator");
|
||||
expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0);
|
||||
const heroCreator = document.querySelector(".skill-hero-creator");
|
||||
expect(heroCreator).toBeTruthy();
|
||||
const summary = document.querySelector(".skill-summary-block");
|
||||
expect(
|
||||
summary &&
|
||||
heroCreator &&
|
||||
summary.compareDocumentPosition(heroCreator) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Security checks across malware telemetry and agentic risk",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const DETAIL_HERO_TOPIC_LIMIT = 4;
|
||||
|
||||
type DetailPageShellProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
|
||||
@@ -14,14 +14,27 @@ export function OfficialTag({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OfficialBadge({ className }: { className?: string }) {
|
||||
type OfficialBadgeProps = {
|
||||
className?: string;
|
||||
iconOnly?: boolean;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function OfficialBadge({ className, iconOnly = false, size = 12 }: OfficialBadgeProps) {
|
||||
if (iconOnly) {
|
||||
const iconClassName = className
|
||||
? `official-badge-icon-only ${className}`
|
||||
: "official-badge-icon-only";
|
||||
return <BadgeCheck size={size} className={iconClassName} aria-label="Official" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={className ? `official-badge ${className}` : "official-badge"}
|
||||
aria-label="Official"
|
||||
title="Official"
|
||||
>
|
||||
<BadgeCheck size={12} aria-hidden="true" />
|
||||
<BadgeCheck size={size} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useHeroCreatorPublisher", () => ({
|
||||
useHeroCreatorPublisher: ({ owner }: { owner?: PublicPublisher | null }) => owner,
|
||||
}));
|
||||
|
||||
describe("SkillHeader", () => {
|
||||
function sidebarStatsRoot(container: HTMLElement) {
|
||||
const node = container.querySelector(".detail-sidebar-stats");
|
||||
@@ -34,6 +38,22 @@ describe("SkillHeader", () => {
|
||||
return node as HTMLElement;
|
||||
}
|
||||
|
||||
function heroCreatorRoot(container: HTMLElement) {
|
||||
const node = container.querySelector(".skill-hero-creator");
|
||||
if (!node) throw new Error("Missing .skill-hero-creator");
|
||||
return node as HTMLElement;
|
||||
}
|
||||
|
||||
function expectCreatorBelowSummary(container: HTMLElement) {
|
||||
const summary = container.querySelector(".skill-summary-block");
|
||||
const creator = container.querySelector(".skill-hero-creator");
|
||||
expect(summary).toBeTruthy();
|
||||
expect(creator).toBeTruthy();
|
||||
expect(
|
||||
summary!.compareDocumentPosition(creator!) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
function setViewportWidth(width: number) {
|
||||
vi.stubGlobal("matchMedia", (query: string) => {
|
||||
const minWidth = /\(min-width:\s*(\d+)px\)/.exec(query)?.[1];
|
||||
@@ -156,7 +176,10 @@ describe("SkillHeader", () => {
|
||||
expect(onRequireSignIn).toHaveBeenCalledTimes(2);
|
||||
expect(onToggleStar).not.toHaveBeenCalled();
|
||||
expect(onOpenReport).not.toHaveBeenCalled();
|
||||
expect(within(sidebarStatsRoot(container)).getByText("Creator")).toBeTruthy();
|
||||
expectCreatorBelowSummary(container);
|
||||
expect(within(heroCreatorRoot(container)).getByText("Local")).toBeTruthy();
|
||||
expect(within(heroCreatorRoot(container)).getByText("@local")).toBeTruthy();
|
||||
expect(within(sidebarStatsRoot(container)).queryByText("Creator")).toBeNull();
|
||||
expect(within(sidebarStatsRoot(container)).getByText("Downloads")).toBeTruthy();
|
||||
expect(within(sidebarStatsRoot(container)).getByText("2")).toBeTruthy();
|
||||
expect(container.querySelector('a[href="/local"]')).toBeTruthy();
|
||||
@@ -184,18 +207,22 @@ describe("SkillHeader", () => {
|
||||
it("keeps desktop-width sidebar details expanded at 1071px", () => {
|
||||
const { container } = renderHeader();
|
||||
|
||||
expect(within(sidebarStatsRoot(container)).getByText("Creator")).toBeTruthy();
|
||||
expectCreatorBelowSummary(container);
|
||||
expect(within(heroCreatorRoot(container)).getByText("Local")).toBeTruthy();
|
||||
expect(within(sidebarStatsRoot(container)).queryByText("Creator")).toBeNull();
|
||||
expect(within(sidebarStatsRoot(container)).getByText("Downloads")).toBeTruthy();
|
||||
expect(container.querySelector(".detail-mobile-master-tab-list")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses mobile master tabs and creator placement below the title below 901px", () => {
|
||||
it("places creator below the summary and keeps it out of the sidebar below 901px", () => {
|
||||
setViewportWidth(488);
|
||||
const { container } = renderHeader();
|
||||
|
||||
const creator = container.querySelector(".skill-hero-mobile-creator");
|
||||
const creator = container.querySelector(".skill-hero-creator");
|
||||
const statsPanel = container.querySelector("#skill-mobile-master-panel-stats");
|
||||
expectCreatorBelowSummary(container);
|
||||
expect(creator?.textContent).toContain("Local");
|
||||
expect(creator?.textContent).toContain("@local");
|
||||
expect(statsPanel?.textContent).not.toContain("Creator");
|
||||
expect(statsPanel?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
@@ -330,7 +357,7 @@ describe("SkillHeader", () => {
|
||||
expect(container.querySelectorAll(".metric-trend-marker-line")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows the Official tag in the title for official owner skills", () => {
|
||||
it("shows the Official badge on the creator for official publishers", () => {
|
||||
const { container } = renderHeader({
|
||||
owner: {
|
||||
...owner,
|
||||
@@ -338,8 +365,9 @@ describe("SkillHeader", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Official")).toBeTruthy();
|
||||
expect(container.querySelector(".official-tag")).toBeTruthy();
|
||||
const creator = container.querySelector(".skill-hero-creator");
|
||||
expect(creator?.querySelector(".user-name-row .official-badge-icon-only")).toBeTruthy();
|
||||
expect(container.querySelector(".skill-hero-title-row .official-tag")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders canonical topics in the detail hero", () => {
|
||||
@@ -388,7 +416,8 @@ describe("SkillHeader", () => {
|
||||
const { container } = renderHeader({ showArchiveMetadata: false });
|
||||
|
||||
expect(within(sidebarStatsRoot(container)).getByText("Downloads")).toBeTruthy();
|
||||
expect(within(sidebarStatsRoot(container)).getByText("Creator")).toBeTruthy();
|
||||
expect(within(sidebarStatsRoot(container)).queryByText("Creator")).toBeNull();
|
||||
expect(within(heroCreatorRoot(container)).getByText("Local")).toBeTruthy();
|
||||
expect(within(sidebarStatsRoot(container)).getByText("Last updated")).toBeTruthy();
|
||||
expect(screen.queryByText("Current version")).toBeNull();
|
||||
expect(screen.queryByText("License")).toBeNull();
|
||||
|
||||
@@ -5,18 +5,18 @@ import { Flag, Settings, ShieldCheck, Star, Upload } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import type { ActivityTrend } from "../lib/activityTrend";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { getSkillBadges, isSkillOfficial } from "../lib/badges";
|
||||
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
|
||||
import { buildSkillCategoryBrowseHref, type SkillCategory } from "../lib/categories";
|
||||
import { formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import { buildPublisherProfileHref } from "../lib/ownerRoute";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { useHeroCreatorPublisher } from "../lib/useHeroCreatorPublisher";
|
||||
import { ActivityMetricLabel } from "./ActivityMetricLabel";
|
||||
import { DetailHero } from "./DetailPageShell";
|
||||
import { DetailHero, DETAIL_HERO_TOPIC_LIMIT } from "./DetailPageShell";
|
||||
import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary";
|
||||
import { useDownloadsSidebarMetricBlock } from "./DownloadsMetricCard";
|
||||
import { OfficialTag } from "./OfficialBadge";
|
||||
import { SidebarMetadata } from "./SidebarMetadata";
|
||||
import { buildSkillHref } from "./skillDetailUtils";
|
||||
import { SkillCommandLineCard } from "./SkillInstallSurface";
|
||||
@@ -48,7 +48,6 @@ type SkillCanonical = {
|
||||
owner: { handle: string | null; userId: Id<"users"> | null };
|
||||
};
|
||||
|
||||
const MAX_HEADER_TOPICS = 5;
|
||||
const SUMMARY_COLLAPSE_THRESHOLD = 220;
|
||||
|
||||
type MobileDetailPanel = "content" | "stats";
|
||||
@@ -173,8 +172,11 @@ export function SkillHeader({
|
||||
const hasOwnerActions = Boolean(newVersionHref) || Boolean(settingsHref);
|
||||
const showReportAction = !canManage || isStaff;
|
||||
const badges = getSkillBadges(skill);
|
||||
const isOfficial = badges.includes("Official") || owner?.official === true;
|
||||
const titleBadges = badges.filter((badge) => badge !== "Official");
|
||||
const heroCreatorPublisher = useHeroCreatorPublisher({
|
||||
owner,
|
||||
skillOfficial: isSkillOfficial(skill),
|
||||
});
|
||||
const showHeroMeta = Boolean((forkOf && forkOfHref) || canonicalHref);
|
||||
const showTitleBadges = titleBadges.length > 0;
|
||||
const headerDescription =
|
||||
@@ -182,7 +184,7 @@ export function SkillHeader({
|
||||
const headerTopics = (skill.topics ?? [])
|
||||
.map((topic) => topic.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_HEADER_TOPICS);
|
||||
.slice(0, DETAIL_HERO_TOPIC_LIMIT);
|
||||
const headerCategories = (categories ?? (category ? [category] : [])).slice(0, 3);
|
||||
const hasSummaryToggle = headerDescription.length > SUMMARY_COLLAPSE_THRESHOLD;
|
||||
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
|
||||
@@ -281,6 +283,7 @@ export function SkillHeader({
|
||||
securityAuditSummary={securityAuditSummary}
|
||||
activityTrend={activityTrend}
|
||||
activityTrendLoading={activityTrendLoading}
|
||||
hideCreator
|
||||
/>
|
||||
{renderSidebarActions()}
|
||||
</>
|
||||
@@ -401,7 +404,6 @@ export function SkillHeader({
|
||||
) : null}
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="skill-page-title">{skill.displayName}</h1>
|
||||
{isOfficial ? <OfficialTag /> : null}
|
||||
{showTitleBadges ? (
|
||||
<div className="skill-title-badges">
|
||||
{titleBadges.map((badge) => (
|
||||
@@ -413,18 +415,29 @@ export function SkillHeader({
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</div>
|
||||
{owner || ownerHandle ? (
|
||||
<div className="skill-hero-mobile-creator">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
disableTooltip
|
||||
/>
|
||||
{showHeroMeta ? (
|
||||
<div className="skill-hero-meta-row" aria-label="Skill lineage">
|
||||
{forkOf && forkOfHref ? (
|
||||
<span className="skill-hero-meta-item">
|
||||
<span className="skill-hero-meta-label">{forkOfLabel}</span>
|
||||
<a className="skill-hero-meta-link" href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? (
|
||||
<span className="skill-hero-meta-version">({forkOf.version})</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<span className="skill-hero-meta-item">
|
||||
<span className="skill-hero-meta-label">canonical</span>
|
||||
<a className="skill-hero-meta-link" href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -447,41 +460,27 @@ export function SkillHeader({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{owner || ownerHandle ? (
|
||||
<div className="skill-hero-creator">
|
||||
<UserBadge
|
||||
user={heroCreatorPublisher}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
stackMutedHandleBelowName
|
||||
disableTooltip
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showHeroMeta ? (
|
||||
<div className="skill-hero-meta-row">
|
||||
{forkOf && forkOfHref ? (
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
{forkOf && forkOfHref ? (
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
) : null}
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -142,6 +142,29 @@ describe("UserBadge", () => {
|
||||
expect(container.querySelector(".official-tag")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("places the official badge beside the display name in hero creator layout", () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<UserBadge
|
||||
user={orgPublisher}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
stackMutedHandleBelowName
|
||||
disableTooltip
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const nameRow = container.querySelector(".user-name-row");
|
||||
expect(nameRow?.querySelector(".user-name")?.textContent).toBe("OpenClaw");
|
||||
expect(nameRow?.querySelector(".official-badge-icon-only")).toBeTruthy();
|
||||
expect(nameRow?.querySelector(".official-badge")).toBeFalsy();
|
||||
expect(container.querySelector(".user-badge > .official-badge")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("falls back to the legacy hover metric during rollout", () => {
|
||||
expect(
|
||||
getHoverTotalDownloads({
|
||||
|
||||
@@ -30,6 +30,8 @@ type UserBadgeProps = {
|
||||
showHandle?: boolean;
|
||||
/** Sidebar creator row: `Display Name / @handle` with muted handle suffix. */
|
||||
showMutedHandle?: boolean;
|
||||
/** Hero creator row: stack `@handle` below the display name. */
|
||||
stackMutedHandleBelowName?: boolean;
|
||||
disableTooltip?: boolean;
|
||||
};
|
||||
|
||||
@@ -42,6 +44,7 @@ export function UserBadge({
|
||||
showName = false,
|
||||
showHandle = true,
|
||||
showMutedHandle = false,
|
||||
stackMutedHandleBelowName = false,
|
||||
disableTooltip = false,
|
||||
}: UserBadgeProps) {
|
||||
const userName =
|
||||
@@ -51,7 +54,10 @@ export function UserBadge({
|
||||
const href = handle ? buildPublisherProfileHref(handle) : null;
|
||||
const label = handle ? `@${handle}` : "user";
|
||||
const image = user?.image ?? null;
|
||||
const showInlineMutedHandle = showMutedHandle && Boolean(handle) && Boolean(displayName);
|
||||
const showStackedMutedHandle =
|
||||
stackMutedHandleBelowName && showMutedHandle && Boolean(handle) && Boolean(displayName);
|
||||
const showInlineMutedHandle =
|
||||
!stackMutedHandleBelowName && showMutedHandle && Boolean(handle) && Boolean(displayName);
|
||||
const resolvedShowHandle = showMutedHandle ? !displayName && Boolean(handle) : showHandle;
|
||||
const hasUsefulName =
|
||||
showName &&
|
||||
@@ -68,6 +74,14 @@ export function UserBadge({
|
||||
const userId =
|
||||
user && hasOwnProperty(user, "kind") ? (user.linkedUserId ?? null) : (user?._id ?? null);
|
||||
|
||||
const officialBadge = isOfficial ? (
|
||||
<OfficialBadge
|
||||
className="user-name-official-badge"
|
||||
iconOnly={stackMutedHandleBelowName}
|
||||
size={stackMutedHandleBelowName ? 14 : 12}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const badgeContent = (
|
||||
<>
|
||||
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
|
||||
@@ -80,7 +94,10 @@ export function UserBadge({
|
||||
</span>
|
||||
{hasUsefulName ? (
|
||||
<>
|
||||
<span className="user-name">{displayName}</span>
|
||||
<span className="user-name-row">
|
||||
<span className="user-name">{displayName}</span>
|
||||
{officialBadge}
|
||||
</span>
|
||||
{showInlineMutedHandle ? (
|
||||
<>
|
||||
<span className="user-name-sep" aria-hidden="true">
|
||||
@@ -95,8 +112,11 @@ export function UserBadge({
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{showStackedMutedHandle ? (
|
||||
<span className="user-handle user-handle-muted">{label}</span>
|
||||
) : null}
|
||||
{resolvedShowHandle ? <span className="user-handle">{label}</span> : null}
|
||||
{isOfficial ? <OfficialBadge /> : null}
|
||||
{isOfficial && !hasUsefulName ? officialBadge : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PublicPublisher } from "./publicUser";
|
||||
import { useHeroCreatorPublisher } from "./useHeroCreatorPublisher";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
const owner = {
|
||||
_id: "publishers:openclaw",
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
} as PublicPublisher;
|
||||
|
||||
describe("useHeroCreatorPublisher", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
});
|
||||
|
||||
it("enriches detail owners from the canonical publisher lookup", () => {
|
||||
useQueryMock.mockReturnValue({ ...owner, official: true });
|
||||
|
||||
const { result } = renderHook(() => useHeroCreatorPublisher({ owner }));
|
||||
|
||||
expect(useQueryMock.mock.calls[0]?.[1]).toEqual({ handle: "openclaw" });
|
||||
expect(result.current).toEqual({ ...owner, official: true });
|
||||
});
|
||||
|
||||
it("skips the publisher lookup when the item already proves official status", () => {
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useHeroCreatorPublisher({ owner, skillOfficial: true }));
|
||||
|
||||
expect(useQueryMock.mock.calls[0]?.[1]).toBe("skip");
|
||||
expect(result.current).toEqual({ ...owner, official: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
|
||||
type HeroCreatorPublisher = {
|
||||
_id?: string;
|
||||
_creationTime?: number;
|
||||
kind?: "user" | "org";
|
||||
handle?: string | null;
|
||||
displayName?: string | null;
|
||||
image?: string | null;
|
||||
bio?: string | null;
|
||||
linkedUserId?: string;
|
||||
official?: boolean;
|
||||
};
|
||||
|
||||
type UseHeroCreatorPublisherArgs = {
|
||||
owner: HeroCreatorPublisher | null | undefined;
|
||||
skillOfficial?: boolean;
|
||||
packageOfficial?: boolean;
|
||||
};
|
||||
|
||||
export function useHeroCreatorPublisher({
|
||||
owner,
|
||||
skillOfficial = false,
|
||||
packageOfficial = false,
|
||||
}: UseHeroCreatorPublisherArgs) {
|
||||
const shouldLookupPublisherOfficial =
|
||||
Boolean(owner?.handle) && owner?.official !== true && !skillOfficial && !packageOfficial;
|
||||
const publisherOfficialLookup = useQuery(
|
||||
api.publishers.getByHandle,
|
||||
shouldLookupPublisherOfficial && owner?.handle ? { handle: owner.handle } : "skip",
|
||||
) as HeroCreatorPublisher | null | undefined;
|
||||
|
||||
if (!owner) return owner;
|
||||
const showOfficial =
|
||||
owner.official === true ||
|
||||
publisherOfficialLookup?.official === true ||
|
||||
skillOfficial ||
|
||||
packageOfficial;
|
||||
return showOfficial ? { ...owner, official: true as const } : owner;
|
||||
}
|
||||
@@ -29,7 +29,11 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { CatalogMetadataEditor } from "../../components/CatalogMetadataEditor";
|
||||
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
|
||||
import {
|
||||
DetailHero,
|
||||
DETAIL_HERO_TOPIC_LIMIT,
|
||||
DetailPageShell,
|
||||
} from "../../components/DetailPageShell";
|
||||
import {
|
||||
DetailSecuritySummary,
|
||||
DetailSecuritySummaryLabel,
|
||||
@@ -39,7 +43,6 @@ import { EmptyState } from "../../components/EmptyState";
|
||||
import { InstallCopyButton } from "../../components/InstallCopyButton";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { MarkdownPreview } from "../../components/MarkdownPreview";
|
||||
import { OfficialTag } from "../../components/OfficialBadge";
|
||||
import {
|
||||
PLUGIN_VERSIONS_PAGE_SIZE,
|
||||
PluginVersionsPanel,
|
||||
@@ -94,6 +97,7 @@ import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl";
|
||||
import { timeAgo } from "../../lib/timeAgo";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
import { useDeferredPackageActivityTrend } from "../../lib/useDeferredActivityTrend";
|
||||
import { useHeroCreatorPublisher } from "../../lib/useHeroCreatorPublisher";
|
||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||
|
||||
type PluginDetailRateLimitState = {
|
||||
@@ -1136,10 +1140,14 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
const headerTopics = (pkg.topics ?? [])
|
||||
.map((topic) => topic.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
.slice(0, DETAIL_HERO_TOPIC_LIMIT);
|
||||
const headerSummary = pkg.summary ?? "No summary provided.";
|
||||
const hasSummaryToggle = headerSummary.length > 220;
|
||||
const owner = detail.owner;
|
||||
const heroCreatorPublisher = useHeroCreatorPublisher({
|
||||
owner,
|
||||
packageOfficial: pkg.isOfficial === true,
|
||||
});
|
||||
const latestRelease = version?.version ?? null;
|
||||
const isDownloadBlocked =
|
||||
pkg.scanStatus === "malicious" || latestRelease?.verification?.scanStatus === "malicious";
|
||||
@@ -1431,24 +1439,20 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
);
|
||||
})()
|
||||
: null;
|
||||
const ownerMetadataValue = owner ? (
|
||||
const pluginHeroCreator = heroCreatorPublisher ? (
|
||||
<UserBadge
|
||||
user={{
|
||||
...owner,
|
||||
...(pkg.isOfficial ? { official: true as const } : {}),
|
||||
}}
|
||||
fallbackHandle={owner.handle ?? pkg.ownerHandle ?? null}
|
||||
user={heroCreatorPublisher}
|
||||
fallbackHandle={heroCreatorPublisher.handle ?? pkg.ownerHandle ?? null}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
stackMutedHandleBelowName
|
||||
disableTooltip
|
||||
/>
|
||||
) : null;
|
||||
const hasSourceMetadata = Boolean(
|
||||
sourceRepoLink || ownerMetadataValue || latestRelease || pkg.latestVersion,
|
||||
);
|
||||
const hasSourceMetadata = Boolean(sourceRepoLink || owner || latestRelease || pkg.latestVersion);
|
||||
const securitySummary = latestRelease ? (
|
||||
<DetailSecuritySummary
|
||||
auditHref={buildPluginSecurityAuditHref(name, { ownerHandle: owner?.handle })}
|
||||
@@ -1492,7 +1496,6 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
? [
|
||||
pluginDownloadsMetricBlock,
|
||||
{ label: "Repository", value: sourceRepoLink },
|
||||
...(ownerMetadataValue ? [{ label: "Creator", value: ownerMetadataValue }] : []),
|
||||
securitySummary
|
||||
? {
|
||||
key: "security-audit",
|
||||
@@ -1634,11 +1637,6 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
) : null}
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="skill-page-title">{pkg.displayName}</h1>
|
||||
{pkg.isOfficial ? (
|
||||
<div className="skill-title-badges">
|
||||
<OfficialTag />
|
||||
</div>
|
||||
) : null}
|
||||
{isDownloadBlocked ? (
|
||||
<div className="skill-title-actions">
|
||||
<Badge variant="destructive">Download blocked</Badge>
|
||||
@@ -1665,6 +1663,9 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{pluginHeroCreator ? (
|
||||
<div className="skill-hero-creator">{pluginHeroCreator}</div>
|
||||
) : null}
|
||||
|
||||
{rateLimited?.scope === "metadata" ? (
|
||||
<div className="skill-hero-badges">
|
||||
|
||||
+122
-29
@@ -5984,6 +5984,7 @@ code {
|
||||
.skill-hero-layout.has-sidebar {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
|
||||
column-gap: clamp(56px, 7vw, 96px);
|
||||
row-gap: 64px;
|
||||
}
|
||||
|
||||
.skill-hero-layout.has-sidebar > .skill-hero-main {
|
||||
@@ -6221,10 +6222,82 @@ code {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.skill-hero-mobile-creator {
|
||||
.skill-hero-creator {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.skill-hero-title > .skill-summary-block:has(+ .skill-hero-creator) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-badge,
|
||||
.skill-hero-creator .user-badge-link {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 10px;
|
||||
row-gap: 2px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-avatar {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-name-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-name {
|
||||
color: var(--ink);
|
||||
font-weight: 680;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.skill-hero-creator .official-badge-icon-only {
|
||||
flex: 0 0 auto;
|
||||
color: var(--official-fg);
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-name-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-handle {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 560;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-badge-link:hover,
|
||||
.skill-hero-creator .user-badge-link:focus-visible {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skill-hero-creator .user-badge-link:hover .user-name,
|
||||
.skill-hero-creator .user-badge-link:focus-visible .user-name,
|
||||
.skill-hero-creator .user-badge-link:hover .user-handle,
|
||||
.skill-hero-creator .user-badge-link:focus-visible .user-handle {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.plugin-catalog-empty-alert {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
@@ -6876,7 +6949,7 @@ code {
|
||||
|
||||
.detail-mobile-master-tab-list[hidden],
|
||||
.detail-mobile-master-panel[hidden],
|
||||
.skill-hero-mobile-creator[hidden] {
|
||||
.skill-hero-creator[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -7407,11 +7480,44 @@ code {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
gap: 8px 14px;
|
||||
min-width: 0;
|
||||
margin-top: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.skill-hero-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.skill-hero-meta-label {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 520;
|
||||
}
|
||||
|
||||
.skill-hero-meta-link {
|
||||
color: var(--accent);
|
||||
font-weight: 560;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skill-hero-meta-link:hover,
|
||||
.skill-hero-meta-link:focus-visible {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.skill-hero-meta-version {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 520;
|
||||
}
|
||||
|
||||
.skill-hero-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -8246,33 +8352,20 @@ code {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skill-hero-mobile-creator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
:is(.skill-detail-page, .plugin-detail-page) .skill-summary-block {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:is(.skill-detail-page, .plugin-detail-page) .skill-hero-creator {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.skill-detail-page .skill-hero-lower.has-sidebar {
|
||||
order: 3;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.skill-hero-mobile-creator .user-badge {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-hero-mobile-creator .user-badge-md .user-name,
|
||||
.skill-hero-mobile-creator .user-badge-md .user-name-sep,
|
||||
.skill-hero-mobile-creator .user-badge-md .user-handle {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.skill-hero-mobile-creator .user-badge-md .user-name {
|
||||
color: var(--ink);
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.skill-hero-mobile-creator .user-badge-md .user-name-sep,
|
||||
.skill-hero-mobile-creator .user-badge-md .user-handle {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.detail-mobile-master-tabs {
|
||||
|
||||
Reference in New Issue
Block a user