Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f4ca6bd
feat(admin): add a plugin directory to browse and search published pl…
mobeenabdullah Aug 12, 2026
5900991
fix(admin): stop the plugin directory listing featured entries twice
mobeenabdullah Aug 12, 2026
6a6887c
refactor(admin): drop the install-command helper until a page calls it
mobeenabdullah Aug 13, 2026
abd925c
feat(admin): give an uninstalled catalogue plugin its own page
mobeenabdullah Aug 13, 2026
fcce38e
fix(admin): complete the plugin setup recipe and gate absence on load…
mobeenabdullah Aug 13, 2026
d46db16
fix(admin): pin plugin installs to the running admin release
mobeenabdullah Aug 13, 2026
84f69e7
fix(admin): make the setup lines appendable and move the directory of…
mobeenabdullah Aug 13, 2026
f7bcbb8
test(admin): cover the branding readiness states
mobeenabdullah Aug 13, 2026
a4a9c2a
test(admin): cover branding readiness and state the refetch gap
mobeenabdullah Aug 13, 2026
38f7d8a
fix(admin): give the plugin detail page a loading boundary and report…
mobeenabdullah Aug 13, 2026
477b2ef
fix(admin): classify the plugin directory into the Plugins sidebar se…
mobeenabdullah Aug 13, 2026
ef4ee0c
test(admin): size the search debounce wait to the delay it waits on
mobeenabdullah Aug 13, 2026
0c9b264
docs(admin): note that source-mode consumers get an unpinned install …
mobeenabdullah Aug 13, 2026
77d0a32
fix(admin): include the admin-module import and announce copy success
mobeenabdullah Aug 13, 2026
326d7ef
test(admin): restore the original clipboard descriptor between tests
mobeenabdullah Aug 13, 2026
6683d7d
fix(admin): include the editor stylesheet step and raise the card hov…
mobeenabdullah Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/plugin-directory-browse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
"@nextlyhq/module-specifiers": patch
---

The admin now has a plugin directory, at Plugins then Browse plugins.

It lists the plugins Nextly publishes with a description, category and author, marks the ones already installed, and searches by name, description and tags. A curated row sits above the grid while there is more in the grid than in the row.

It is discovery only. Installing a plugin means adding a dependency and a line to `nextly.config.ts`, so the directory never writes to your source or changes plugin state. Where a listed plugin is already installed, its own icon and description are shown rather than the directory's copy of them.
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,34 @@ vi.mock("@admin/hooks/queries", () => ({
}));
import { DynamicPluginNav } from "@admin/components/features/dashboard/DynamicPluginNav";
import { SidebarProvider } from "@admin/components/layout/sidebar";
import { useSidebarNavigation } from "@admin/hooks/useSidebarNavigation";

const noop = () => false;

/**
* Renders the panel with the REAL `isActive`, for the one pathname given.
*
* The sidebar's matcher rather than one written here: a copy would agree on
* the day it was written and then answer for itself, and what is under test is
* how the overview composes with that matcher, not whether the composition can
* be restated.
*
* Empty navigation items because `isActive` reads only the pathname; the items
* feed accordion state, which nothing here asserts on.
*/
function ActiveStateHarness({ pathname }: { pathname: string }) {
const { isActive } = useSidebarNavigation([], pathname);
return <DynamicPluginNav isActive={isActive} />;
}

function renderAt(pathname: string) {
return render(
<SidebarProvider defaultOpen>
<ActiveStateHarness pathname={pathname} />
</SidebarProvider>
);
}

// The real provider rather than a mocked `useSidebar`: the component reads
// collapsed state from it, so a stub would let a change to that contract pass
// unnoticed here.
Expand Down Expand Up @@ -192,6 +217,41 @@ describe("DynamicPluginNav", () => {
});
});

/**
* Which sidebar entry the overview claims.
*
* A plugin's own pages are descendants of `/admin/plugins`, so an exact match
* would leave the secondary navigation with nothing selected while reading a
* plugin's detail page. The directory is the case pulling the other way, and
* it is answered by living under a different prefix rather than by a rule
* here — which is what the last case pins.
*/
describe("DynamicPluginNav overview active state", () => {
function overviewActive() {
return screen
.getByRole("link", { name: /installed plugins/i })
.getAttribute("data-active");
}

it.each([
["/admin/plugins", "true"],
["/admin/plugins/acme-forms", "true"],
["/admin/plugins/acme-forms/settings", "true"],
// A plugin that happens to be called "browse" is still a plugin, and its
// page belongs to this entry like any other.
["/admin/plugins/browse", "true"],
// The directory, which Browse owns. It is outside the prefix, so this is
// false without the overview having to exclude anything.
["/admin/plugin-directory", "false"],
])("at %s the overview is active=%s", (pathname, expected) => {
mockBranding = { plugins: [] } as unknown as AdminBranding;

renderAt(pathname);

expect(overviewActive()).toBe(expected);
});
});

describe("DynamicPluginNav expanded", () => {
/**
* A group is expandable when it retains a collection, and only then.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ function PluginOverviewLink({
}: {
isActive: (href?: string) => boolean;
}) {
const active = isActive("/admin/plugins");
// The whole subtree, so a plugin's own pages — `/admin/plugins/<slug>` and
// its settings — keep the overview selected. Nothing has to be subtracted
// here: the directory lives outside this prefix, so it cannot be caught by
// a descendant match and light both entries at once.
const active = isActive(ROUTES.PLUGINS);
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={active}>
Expand Down
3 changes: 3 additions & 0 deletions packages/admin/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export {
Italic, // Rich text editor
Key,
Laptop,
Layout, // Plugin appearance: the icon @nextlyhq/plugin-page-builder declares
LayoutGrid, // Collection Builder: blocks field
Layers,
LayoutDashboard,
Expand Down Expand Up @@ -219,3 +220,5 @@ export const Discord = ({ size = 24, ...props }: LucideProps) => {
})
);
};

export type { AdminIconName } from "./names";
25 changes: 25 additions & 0 deletions packages/admin/src/components/icons/names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* The set of icon names the barrel can render.
*
* Its own module rather than a line in the barrel, because the type has to
* refer to the barrel's own exports and a module cannot describe itself
* without the inline `import()` form that this package's lint rules forbid.
*
* @module components/icons/names
*/
import type * as Icons from "./index";

/**
* Every icon name this admin can render, derived from the exports rather than
* listed beside them, so a name and the export it refers to cannot disagree.
*
* Curated data that picks an icon — the plugin catalogue — types its icon
* field as this, and a name the barrel does not carry stops compiling instead
* of resolving to nothing and silently rendering the caller's generic
* fallback.
*
* Not for icon names that arrive at runtime: a third-party plugin declares
* `appearance.icon` as a free string this admin cannot constrain, so that path
* keeps its fallback.
*/
export type AdminIconName = keyof typeof Icons;
8 changes: 7 additions & 1 deletion packages/admin/src/components/layout/sidebar/DualSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { resolveCollectionPlacement } from "@admin/lib/plugins/collection-placement";
import { pluginSlug } from "@admin/lib/plugins/plugin-slug";
import { resolvePluginIcon } from "@admin/lib/plugins/resolve-plugin-icon";
import { isUnder } from "@admin/lib/routing";
import { cn } from "@admin/lib/utils";
import type { ApiCollection } from "@admin/types/entities";

Expand Down Expand Up @@ -338,8 +339,13 @@ export function DualSidebar({ isMobile }: DualSidebarProps = {}) {
});
};

// The route constants rather than their spellings. The plugin directory
// sits at its own top level so no plugin slug can shadow it, which means
// the Plugins category is not one URL prefix and a literal would silently
// stop covering it the next time either route moves.
if (
pathname.includes("/admin/plugins") ||
isUnder(pathname, ROUTES.PLUGINS) ||
isUnder(pathname, ROUTES.PLUGIN_BROWSE) ||
pathname.includes("/admin/forms") ||
isPluginPath(collectionsData)
) {
Expand Down
23 changes: 23 additions & 0 deletions packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,29 @@ export function SubSidebarContent({
</p>
<SidebarMenu>
<DynamicPluginNav isActive={isActive} search={pluginSearch} />
{/* Gated on the permission `PLUGIN_BROWSE` itself requires. The
panel stays open to a user who can only read a plugin-owned
collection, so without this they would be offered a destination
that redirects them the moment they choose it.

Below the installed plugins, not above: this panel is for
getting to what the project already has, and the directory is
the occasional trip. Not filtered by `pluginSearch` either —
that box searches installed plugins, and an entry that ignores
it while sitting among entries that obey it reads as a bug. */}
{hasPermission("manage-settings") && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={isActive(ROUTES.PLUGIN_BROWSE)}
>
<Link href={ROUTES.PLUGIN_BROWSE}>
<Icons.Search className="h-4 w-4 shrink-0" />
<span>Browse plugins</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</div>
</div>
Expand Down
26 changes: 26 additions & 0 deletions packages/admin/src/components/shared/plugin-icon/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import * as Icons from "@admin/components/icons";
import { PluginIcon } from "@admin/components/shared/plugin-icon";
import type { PluginMetadata } from "@admin/types/branding";

Expand All @@ -17,6 +18,31 @@ const withAsset = (src: string, icon?: string) =>
>;

describe("PluginIcon", () => {
/**
* The glyph `@nextlyhq/plugin-page-builder` declares. A name the barrel does
* not re-export resolves to nothing and falls through to the caller's
* fallback, so the plugin's own icon disappears everywhere in the admin
* while every surface still looks intact.
*/
it("renders a first-party plugin's declared glyph rather than the fallback", () => {
const { container } = render(
<PluginIcon
plugin={{ appearance: { icon: "Layout" } }}
fallback="Package"
/>
);

// Compared against what the barrel's own `Layout` draws rather than a
// pinned class name: lucide ships `Layout` as an alias, so the rendered
// class is the target's and hard-coding it would tie this to which icon
// lucide happens to alias it to.
const expected = render(<Icons.Layout />).container.querySelector("svg");
expect(container.querySelector("svg")?.getAttribute("class")).toBe(
expected?.getAttribute("class")
);
expect(container.querySelector(".lucide-package")).toBeNull();
});

it("renders a declared asset", () => {
render(<PluginIcon plugin={withAsset("/a.svg")} fallback="Package" />);
expect(screen.getByRole("presentation", { hidden: true })).toBeTruthy();
Expand Down
70 changes: 50 additions & 20 deletions packages/admin/src/components/shared/plugin-icon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import type React from "react";
import { useState } from "react";

import * as Icons from "@admin/components/icons";
import { resolvePluginIcon } from "@admin/lib/plugins/resolve-plugin-icon";
import { resolvePluginIconFrom } from "@admin/lib/plugins/resolve-plugin-icon";
import { cn } from "@admin/lib/utils";
import type { PluginMetadata } from "@admin/types/branding";

interface PluginIconProps {
/** The plugin whose icon to render. Only `appearance` is read. */
plugin: Pick<PluginMetadata, "appearance">;
type IconCandidate = Pick<PluginMetadata, "appearance"> | undefined;

interface PluginIconFromProps {
/**
* Appearance sources in precedence order; the first that declares an icon
* wins. A caller with one source passes one. Callers with two — an installed
* plugin and the catalogue entry describing it — must not decide the order
* here: ask the module that owns the precedence rule for the list.
*/
candidates: readonly IconCandidate[];
/**
* The lucide icon to use when the plugin declares none.
*
Expand All @@ -23,6 +30,11 @@ interface PluginIconProps {
alt?: string;
}

interface PluginIconProps extends Omit<PluginIconFromProps, "candidates"> {
/** The plugin whose icon to render. Only `appearance` is read. */
plugin: Pick<PluginMetadata, "appearance">;
}

/**
* Render a plugin's icon, whether it ships an image or names a lucide glyph.
*
Expand All @@ -33,28 +45,31 @@ interface PluginIconProps {
*
* @module components/shared/plugin-icon
*/
export function PluginIcon({
plugin,
export function PluginIconFrom({
candidates,
fallback,
className,
alt = "",
}: PluginIconProps): React.ReactElement {
}: PluginIconFromProps): React.ReactElement {
// A declared asset can still fail to arrive: a mistyped path, a deleted
// file, or a Content-Security-Policy that blocks the origin. Without this the
// surface keeps a broken-image glyph forever, which is worse than the plain
// icon it replaced. On failure the component re-resolves with assets
// disallowed, so it lands on whatever lucide name the plugin declared beside
// the asset before reaching the caller's fallback.
// The failed URL rather than a boolean. A boolean survives client-side
// navigation between two plugin detail pages, because the router renders the
// same component type without a key, so React keeps the state: one plugin's
// broken logo would suppress the next plugin's working one. Keying on the
// source means a different asset is always attempted.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const declaredAsset = plugin.appearance?.iconAsset;
const source = resolvePluginIcon(plugin, {
// icon it replaced.
//
// The URLs that have failed, not a boolean. A broken image on one candidate
// says nothing about a different image a later candidate ships, so each
// failure removes exactly one URL from consideration and the chain is
// re-resolved: the next asset is tried, and only when none load does it
// settle on a glyph. Keying on the URL also survives client-side navigation
// between two plugin detail pages, where the router renders the same
// component type without a key so React keeps this state — one plugin's
// broken logo must not suppress the next plugin's working one.
const [failedSrcs, setFailedSrcs] = useState<ReadonlySet<string>>(
() => new Set()
);
const source = resolvePluginIconFrom(candidates, {
fallback,
allowAsset: declaredAsset !== undefined && declaredAsset !== failedSrc,
skipAssets: failedSrcs,
});

if (source.kind === "asset") {
Expand All @@ -67,7 +82,14 @@ export function PluginIcon({
<img
src={source.src}
alt={alt}
onError={() => setFailedSrc(source.src)}
onError={() =>
setFailedSrcs(prev => {
if (prev.has(source.src)) return prev;
const next = new Set(prev);
next.add(source.src);
return next;
})
}
className={cn("object-contain", className)}
/>
);
Expand All @@ -82,3 +104,11 @@ export function PluginIcon({

return <Named className={className} />;
}

/** The single-source case, which is every surface but the catalogue. */
export function PluginIcon({
plugin,
...rest
}: PluginIconProps): React.ReactElement {
return <PluginIconFrom candidates={[plugin]} {...rest} />;
}
7 changes: 7 additions & 0 deletions packages/admin/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ export const ROUTES = {

// Plugin routes
PLUGINS: "/admin/plugins",
// Outside the `/admin/plugins/` namespace on purpose. A plugin's detail
// address is `/admin/plugins/<slug>`, and a slug is derived from a package
// name that may be any string, so a sibling static page there is a page a
// plugin can be named after — and whichever of the two wins, the other
// becomes unreachable. A different parent removes the collision instead of
// ranking it.
PLUGIN_BROWSE: "/admin/plugin-directory",
Comment thread
mobeenabdullah marked this conversation as resolved.
PLUGIN_DETAIL: "/admin/plugins/[slug]",
PLUGIN_SETTINGS: "/admin/plugins/[slug]/settings",
} as const;
Expand Down
Loading
Loading