adad46fab1
* feat(plugins): richer frontend SDK — page chrome, nav icons, more host UI
Give native UI plugins the primitives to render kandev-native pages while
keeping everything configurable:
- registerRoute gains an options arg; plugin routes get a first-party
PageTopbar title bar by default (title/icon derived from the nav item for
the same path, or set via { topbar: { title, subtitle, icon, backHref,
backLabel, actions } }). Pass { topbar: false } to render full-bleed and
own the chrome.
- NavItem.icon now resolves against a curated name map (lib/plugins/icons.ts);
unknown/missing names fall back to the puzzle glyph.
- host.ui expands to the primitives a native-feeling page needs (Dialog,
DropdownMenu, Pagination, Sheet, Select, Tabs, Table, Tooltip, ...) plus
first-party PageTopbar, Combobox, and TaskCreateDialog. Plugins must use
these host instances — a bundled second copy of any Radix/portal component
splits React context.
- host.navigate(href, { replace? }) for soft SPA navigation into native
routes, and host.api.baseUrl for reaching first-party REST without
re-deriving the split-origin backend URL.
The registry records each plugin's display name for derived titles and
getRoutes() entries now carry pluginId + options. Contract doc
(PLUGIN-API.md) updated to match.
* fix(plugins): expose full Sheet subcomponent family on host.ui
host.ui advertised Sheet but only exposed Sheet/Content/Header/Title, so
plugins (which must use host instances for portal/context components)
couldn't build an uncontrolled sheet — no Trigger/Close/Footer/Description.
Add the missing four for parity with the Dialog family.
* fix(plugins): clear plugin display name on unregisterPlugin
unregisterPlugin bulk-revoked every other registration but left the entry
in pluginNames, so getPluginName returned a stale name after disable and
names leaked across enable/disable cycles. Delete the name too, with a
regression test.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
81 lines
2.7 KiB
TypeScript
81 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import type { ComponentType, ReactNode } from "react";
|
|
import { PageTopbar } from "@/components/page-topbar";
|
|
import { lookupPluginIcon } from "@/lib/plugins/icons";
|
|
import { pluginRegistry } from "@/lib/plugins/registry";
|
|
import type { PluginRouteRegistration } from "@/lib/plugins/registry";
|
|
import type { NavItem, PluginPageChrome } from "@/lib/plugins/types";
|
|
|
|
export interface ResolvedPageChrome {
|
|
title: string;
|
|
subtitle?: string;
|
|
icon?: string;
|
|
backHref?: string;
|
|
backLabel?: string;
|
|
Actions?: ComponentType;
|
|
}
|
|
|
|
/**
|
|
* Resolves the topbar chrome for a plugin route registration
|
|
* (`registerRoute(path, Component, { topbar })`). Returns null when the
|
|
* plugin opted out (`topbar: false`). Title fallback order: explicit chrome
|
|
* title → the plugin's nav-item label registered for this path → the
|
|
* plugin's display name → the route path. The icon similarly falls back to
|
|
* the matching nav item's icon.
|
|
*/
|
|
export function resolvePluginPageChrome(
|
|
registration: PluginRouteRegistration,
|
|
navItems: NavItem[],
|
|
pluginName: string | undefined,
|
|
): ResolvedPageChrome | null {
|
|
const topbar = registration.options?.topbar ?? true;
|
|
if (topbar === false) return null;
|
|
const chrome: PluginPageChrome = topbar === true ? {} : topbar;
|
|
const navItem = navItems.find((item) => item.path === registration.path);
|
|
return {
|
|
title: chrome.title ?? navItem?.label ?? pluginName ?? registration.path,
|
|
subtitle: chrome.subtitle,
|
|
icon: chrome.icon ?? navItem?.icon,
|
|
backHref: chrome.backHref,
|
|
backLabel: chrome.backLabel,
|
|
Actions: chrome.actions,
|
|
};
|
|
}
|
|
|
|
type PluginPageFrameProps = {
|
|
registration: PluginRouteRegistration;
|
|
children: ReactNode;
|
|
};
|
|
|
|
/**
|
|
* Wraps a plugin-registered route in the same page shell first-party pages
|
|
* use: a `PageTopbar` title bar above a scrollable content area. Renders the
|
|
* children bare (full-bleed) when the registration opted out via
|
|
* `topbar: false`.
|
|
*/
|
|
export function PluginPageFrame({ registration, children }: PluginPageFrameProps) {
|
|
const chrome = resolvePluginPageChrome(
|
|
registration,
|
|
pluginRegistry.getNavItems(),
|
|
pluginRegistry.getPluginName(registration.pluginId),
|
|
);
|
|
if (!chrome) return <>{children}</>;
|
|
|
|
const Icon = lookupPluginIcon(chrome.icon);
|
|
const Actions = chrome.Actions;
|
|
return (
|
|
<div className="flex h-screen w-full flex-col bg-background">
|
|
<PageTopbar
|
|
title={chrome.title}
|
|
subtitle={chrome.subtitle}
|
|
icon={Icon ? <Icon className="h-4 w-4" /> : undefined}
|
|
backHref={chrome.backHref}
|
|
backLabel={chrome.backLabel}
|
|
actions={Actions ? <Actions /> : undefined}
|
|
/>
|
|
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
|
</div>
|
|
);
|
|
}
|