diff --git a/packages/dev/inspector-v2/src/extensibility/defaultInspectorExtensionFeed.ts b/packages/dev/inspector-v2/src/extensibility/defaultInspectorExtensionFeed.ts index 6ee3b3a1efd..be4e3daee2c 100644 --- a/packages/dev/inspector-v2/src/extensibility/defaultInspectorExtensionFeed.ts +++ b/packages/dev/inspector-v2/src/extensibility/defaultInspectorExtensionFeed.ts @@ -1,5 +1,13 @@ +import type { ExtensionMetadata } from "./extensionFeed"; + import { BuiltInsExtensionFeed } from "./builtInsExtensionFeed"; +const BabylonWebResources = { + homepage: "https://www.babylonjs.com", + repository: "https://github.com/BabylonJS/Babylon.js", + bugs: "https://github.com/BabylonJS/Babylon.js/issues", +} as const satisfies Partial; + /** * Well-known default built in extensions for the Inspector. */ @@ -14,18 +22,24 @@ export const DefaultInspectorExtensionFeed = new BuiltInsExtensionFeed("Inspecto name: "Export Tools", description: "Adds new features to enable exporting Babylon assets such as .gltf, .glb, .babylon, and more.", keywords: ["export", "gltf", "glb", "babylon", "exporter", "tools"], + ...BabylonWebResources, + author: { name: "Alex Chuber", forumUserName: "alexchuber" }, getExtensionModuleAsync: async () => await import("../services/panes/tools/exportService"), }, { name: "Capture Tools", description: "Adds new features to enable capturing screenshots, GIFs, videos, and more.", keywords: ["capture", "screenshot", "gif", "video", "tools"], + ...BabylonWebResources, + author: { name: "Alex Chuber", forumUserName: "alexchuber" }, getExtensionModuleAsync: async () => await import("../services/panes/tools/captureService"), }, { name: "Import Tools", description: "Adds new features related to importing Babylon assets.", keywords: ["import", "tools"], + ...BabylonWebResources, + author: { name: "Alex Chuber", forumUserName: "alexchuber" }, getExtensionModuleAsync: async () => await import("../services/panes/tools/importService"), }, ]); diff --git a/packages/dev/inspector-v2/src/extensibility/extensionFeed.ts b/packages/dev/inspector-v2/src/extensibility/extensionFeed.ts index 0fe4c702494..b0c6f9e7f2c 100644 --- a/packages/dev/inspector-v2/src/extensibility/extensionFeed.ts +++ b/packages/dev/inspector-v2/src/extensibility/extensionFeed.ts @@ -1,11 +1,38 @@ import type { WeaklyTypedServiceDefinition } from "../modularity/serviceContainer"; +export type PersonMetadata = { + /** + * The name of the person. + */ + readonly name: string; + + /** + * The email address of the person. + */ + readonly email?: string; + + /** + * The URL to the person's website. + */ + readonly url?: string; + + /** + * The Babylon forum username of the person. + */ + readonly forumUserName?: string; +}; + export type ExtensionMetadata = { /** * The name of the extension. */ readonly name: string; + /** + * The version of the extension (as valid semver). + */ + readonly version?: string; + /** * The description of the extension. */ @@ -14,7 +41,37 @@ export type ExtensionMetadata = { /** * The keywords of the extension. */ - readonly keywords: readonly string[]; + readonly keywords?: readonly string[]; + + /** + * The URL to the extension homepage. + */ + readonly homepage?: string; + + /** + * Specify the place where your code lives. This is helpful for people who want to contribute. + */ + readonly repository?: string; + + /** + * The URL to your extension's issue tracker and / or the email address to which issues should be reported. These are helpful for people who encounter issues with your extension. + */ + readonly bugs?: string; + + /** + * A license for your package so that people know how they are permitted to use it, and any restrictions you're placing on it. + */ + readonly license?: string; + + /** + * The primary author of the extension. + */ + readonly author?: string | PersonMetadata; + + /** + * The contributors to the extension. + */ + readonly contributors?: readonly (string | PersonMetadata)[]; }; export type ExtensionModule = { diff --git a/packages/dev/inspector-v2/src/modularTool.tsx b/packages/dev/inspector-v2/src/modularTool.tsx index ef1c51f5c75..f0224048264 100644 --- a/packages/dev/inspector-v2/src/modularTool.tsx +++ b/packages/dev/inspector-v2/src/modularTool.tsx @@ -33,7 +33,6 @@ import { ExtensionManagerContext } from "./contexts/extensionManagerContext"; import { ExtensionManager } from "./extensibility/extensionManager"; import { SetThemeMode } from "./hooks/themeHooks"; import { ServiceContainer } from "./modularity/serviceContainer"; -import { ExtensionListServiceDefinition } from "./services/extensionsListService"; import { MakeShellServiceDefinition, RootComponentServiceIdentity } from "./services/shellService"; import { ThemeSelectorServiceDefinition } from "./services/themeSelectorService"; @@ -134,6 +133,7 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable { // Register the extension list service (for browsing/installing extensions) if extension feeds are provided. if (extensionFeeds.length > 0) { + const { ExtensionListServiceDefinition } = await import("./services/extensionsListService"); await serviceContainer.addServiceAsync(ExtensionListServiceDefinition); } diff --git a/packages/dev/inspector-v2/src/services/extensionsListService.tsx b/packages/dev/inspector-v2/src/services/extensionsListService.tsx index 93039245d88..89d53b49613 100644 --- a/packages/dev/inspector-v2/src/services/extensionsListService.tsx +++ b/packages/dev/inspector-v2/src/services/extensionsListService.tsx @@ -1,5 +1,7 @@ import type { SelectTabData, SelectTabEvent } from "@fluentui/react-components"; +import type { TriggerProps } from "@fluentui/react-utilities"; import type { FunctionComponent } from "react"; +import type { PersonMetadata } from "../extensibility/extensionFeed"; import type { IExtension } from "../extensibility/extensionManager"; import type { ServiceDefinition } from "../modularity/serviceDefinition"; import type { IShellService } from "./shellService"; @@ -9,9 +11,16 @@ import { AccordionHeader, AccordionItem, AccordionPanel, + AvatarGroup, + AvatarGroupItem, Body1, Body1Strong, Button, + Caption1, + Card, + CardFooter, + CardHeader, + CardPreview, Dialog, DialogBody, DialogContent, @@ -19,17 +28,34 @@ import { DialogTitle, DialogTrigger, makeStyles, + Persona, + Popover, + PopoverSurface, + PopoverTrigger, + PresenceBadge, Spinner, Tab, TabList, tokens, Tooltip, } from "@fluentui/react-components"; -import { AppsAddInRegular, DismissRegular } from "@fluentui/react-icons"; -import { memo, useCallback, useEffect, useState } from "react"; +import { + AppsAddInRegular, + ArrowDownloadRegular, + BranchForkRegular, + BugRegular, + DeleteRegular, + DismissRegular, + LinkRegular, + MailRegular, + PeopleCommunityRegular, +} from "@fluentui/react-icons"; +import { Fade } from "@fluentui/react-motion-components-preview"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { Logger } from "core/Misc/logger"; +import { Link } from "shared-ui-components/fluent/primitives/link"; import { TeachingMoment } from "../components/teachingMoment"; import { useExtensionManager } from "../contexts/extensionManagerContext"; import { MakePopoverTeachingMoment } from "../hooks/teachingMomentHooks"; @@ -43,6 +69,7 @@ const useStyles = makeStyles({ width: "70vw", maxWidth: "600px", maxHeight: "70vh", + backgroundColor: tokens.colorNeutralBackground2, }, extensionDialogBody: { maxWidth: "100%", @@ -54,9 +81,11 @@ const useStyles = makeStyles({ }, extensionHeader: {}, extensionItem: {}, - extensionPanel: { - padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`, - backgroundColor: tokens.colorNeutralBackground2, + extensionCardPreview: { + padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`, + display: "flex", + flexDirection: "column", + rowGap: tokens.spacingVerticalL, }, extensionIntro: { display: "flex", @@ -64,12 +93,14 @@ const useStyles = makeStyles({ columnGap: tokens.spacingHorizontalM, }, extensionDescription: { - padding: `${tokens.spacingVerticalM} 0`, - }, - extensionButtonContainer: { display: "flex", + flexDirection: "row", columnGap: tokens.spacingHorizontalS, }, + extensionButtonContainer: { + marginLeft: "auto", + alignSelf: "flex-start", + }, spinner: { animationDuration: "1s", animationName: { @@ -77,12 +108,134 @@ const useStyles = makeStyles({ to: { opacity: 1 }, }, }, + webResourceDiv: { + display: "flex", + flexDirection: "column", + }, + webResourceLink: { + display: "flex", + flexDirection: "row", + columnGap: tokens.spacingHorizontalS, + alignItems: "center", + }, + personPopoverSurfaceDiv: { + display: "flex", + flexDirection: "column", + rowGap: tokens.spacingVerticalS, + }, + accordionHeaderDiv: { + display: "flex", + flexDirection: "row", + columnGap: tokens.spacingHorizontalS, + alignItems: "center", + }, + resourceDetailsDiv: { + display: "flex", + flexDirection: "column", + rowGap: tokens.spacingVerticalS, + }, + peopleDetailsDiv: { + display: "flex", + flexDirection: "row", + columnGap: tokens.spacingHorizontalXL, + }, + avatarGroupItem: { + cursor: "pointer", + }, }); +function AsPersonMetadata(person: string | PersonMetadata): PersonMetadata { + if (typeof person === "string") { + return { name: person } satisfies PersonMetadata; + } + return person; +} + +function usePeopleMetadata(people?: readonly (string | PersonMetadata | undefined)[]) { + const definedPeople = useMemo(() => (people ? people.filter((person): person is string | PersonMetadata => !!person) : []), [people]); + + //const [peopleMetadataEx, setPeopleMetadataEx] = useState<(PersonMetadata & { avatarUrl?: string })[]>(definedPeople.map(AsPersonMetadata)); + const [peopleMetadataEx] = useState(definedPeople.map(AsPersonMetadata)); + + // TODO: Would be nice if we could pull author/contributor profile pictures from the forum, but need to see if this is ok and whether we want to adjust CORS to allow it. + // useEffect(() => { + // definedPeople.forEach(async (person, index) => { + // const personMetadata = AsPersonMetadata(person); + // if (personMetadata.forumUserName) { + // try { + // const json = await (await fetch(`https://forum.babylonjs.com/u/${personMetadata.forumUserName}.json`)).json(); + // const avatarRelativeUrl = json.user?.avatar_template?.replace("{size}", "96"); + // if (avatarRelativeUrl) { + // const avatarUrl = `https://forum.babylonjs.com${avatarRelativeUrl}`; + // setPeopleMetadataEx((prev) => { + // const newMetadata = [...prev]; + // newMetadata[index] = { ...personMetadata, avatarUrl }; + // return newMetadata; + // }); + // } + // } catch { + // // Ignore, non-fatal + // } + // } + // }); + // }, [definedPeople]); + + return peopleMetadataEx.filter(Boolean); +} + // eslint-disable-next-line @typescript-eslint/naming-convention const useTeachingMoment = MakePopoverTeachingMoment("Extensions"); +const WebResource: FunctionComponent<{ url: string; urlDisplay?: string; icon: JSX.Element; label: string }> = (props) => { + const { url, urlDisplay, icon, label } = props; + const classes = useStyles(); + + return ( +
+ +
+ {icon} + +
+
+
+ ); +}; + +const PersonDetailsPopover: FunctionComponent = (props) => { + const { person, title, disabled, children } = props; + const classes = useStyles(); + + if (disabled) { + return <>{children}; + } + + return ( + + {children} + +
+ + {person.email && } label="Email" />} + {person.url && } label="Website" />} + {person.forumUserName && ( + } + label="Forum" + /> + )} +
+
+
+ ); +}; + const ExtensionDetails: FunctionComponent<{ extension: IExtension }> = memo((props) => { + const { extension } = props; + const { metadata } = extension; + const classes = useStyles(); const [canInstall, setCanInstall] = useState(false); @@ -90,7 +243,6 @@ const ExtensionDetails: FunctionComponent<{ extension: IExtension }> = memo((pro const [isStateChanging, setIsStateChanging] = useState(false); useEffect(() => { - const extension = props.extension; const updateState = () => { setCanInstall(!extension.isInstalled && !extension.isStateChanging); setCanUninstall(extension.isInstalled && !extension.isStateChanging); @@ -101,44 +253,93 @@ const ExtensionDetails: FunctionComponent<{ extension: IExtension }> = memo((pro updateState(); return stateChangedHandlerRegistration.dispose; - }, [props.extension]); + }, [extension]); + + const [author] = usePeopleMetadata(useMemo(() => [metadata.author], [metadata.author])); + const contributors = usePeopleMetadata(metadata.contributors); + + const hasResourceDetails = metadata.homepage || metadata.repository || metadata.bugs; + const hasPeopleDetails = author || contributors.length > 0; + const hasPreviewDetails = hasResourceDetails || hasPeopleDetails; + const hasAuthorDetails = author?.email || author?.url || author?.forumUserName; + const subHeader = [metadata.version ? `${metadata.version}` : null, metadata.license ? `${metadata.license}` : null].filter(Boolean).join(" | "); const install = useCallback(async () => { try { - await props.extension.installAsync(); + await extension.installAsync(); } catch { // Ignore errors. Other parts of the infrastructure handle them and communicate them to the user. } - }, [props.extension]); + }, [extension]); const uninstall = useCallback(async () => { try { - await props.extension.uninstallAsync(); + await extension.uninstallAsync(); } catch { // Ignore errors. Other parts of the infrastructure handle them and communicate them to the user. } - }, [props.extension]); + }, [extension]); return ( - <> -
- {props.extension.metadata.description} -
- -
- {canInstall && ( - - )} - {canUninstall && ( - - )} - {isStateChanging && } -
- + + +
+ {extension.metadata.name} + + + +
+
+ + + {metadata.description}} description={{subHeader}} /> + {hasPreviewDetails && ( + + {hasResourceDetails && ( +
+ {metadata.homepage && } label="Website" />} + {metadata.repository && } label="Repository" />} + {metadata.bugs && } label="Report Issues" />} +
+ )} + {hasPeopleDetails && ( +
+ {author && ( + + + + )} + {contributors.length > 0 && ( + + {contributors.map((contributor) => { + return ( + + + + ); + })} + + )} +
+ )} +
+ )} + + {canInstall && ( + + )} + {canUninstall && ( + + )} + {isStateChanging && } + +
+
+
); }); @@ -215,17 +416,9 @@ export const ExtensionListServiceDefinition: ServiceDefinition<[], [IShellServic - {/* */} {extensions.map((extension) => ( - - - {extension.metadata.name} - - - - - + ))}