Skip to content

Commit

Permalink
UI Plugin API (#4256)
Browse files Browse the repository at this point in the history
* Add page registration
* Add example plugin
* First version of proper react plugins
* Make reference react plugin
* Add patching functions
* Add tools link poc
* NavItem poc
* Add loading hook for lazily loaded components
* Add documentation
  • Loading branch information
WithoutPants authored Nov 28, 2023
1 parent 11be56c commit b915428
Show file tree
Hide file tree
Showing 17 changed files with 2,401 additions and 367 deletions.
7 changes: 7 additions & 0 deletions pkg/plugin/examples/react-component/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
This is a reference React component plugin. It replaces the `details` part of scene cards with a list of performers and tags.

To build:
- run `yarn install --frozen-lockfile`
- run `yarn run build`

This will copy the plugin files into the `dist` directory. These files can be copied to a `plugins` directory.
21 changes: 21 additions & 0 deletions pkg/plugin/examples/react-component/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "react-component",
"version": "1.0.0",
"main": "index.js",
"author": "WithoutPants",
"license": "AGPL-3.0",
"scripts": {
"compile:ts": "yarn tsc",
"compile:sass": "yarn sass src/testReact.scss dist/testReact.css",
"copy:yml": "cpx \"src/testReact.yml\" \"dist\"",
"compile": "yarn run compile:ts && yarn run compile:sass",
"build": "yarn run compile && yarn run copy:yml"
},
"devDependencies": {
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"cpx": "^1.5.0",
"sass": "^1.69.4",
"typescript": "^5.2.2"
}
}
36 changes: 36 additions & 0 deletions pkg/plugin/examples/react-component/src/testReact.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.scene-card__date {
color: #bfccd6;;
font-size: 0.85em;
}

.scene-card__performer {
display: inline-block;
font-weight: 500;
margin-right: 0.5em;

a {
color: #137cbd;
}
}

.scene-card__performers,
.scene-card__tags {
-webkit-box-orient: vertical;
display: -webkit-box;
-webkit-line-clamp: 1;
overflow: hidden;

&:hover {
-webkit-line-clamp: unset;
overflow: visible;
}
}

.scene-card__tags .tag-item {
margin-left: 0;
}

.scene-performer-popover .image-thumbnail {
margin: 1em;
}

220 changes: 220 additions & 0 deletions pkg/plugin/examples/react-component/src/testReact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
interface IPluginApi {
React: typeof React;
GQL: any;
libraries: {
ReactRouterDOM: {
Link: React.FC<any>;
Route: React.FC<any>;
NavLink: React.FC<any>;
},
Bootstrap: {
Button: React.FC<any>;
Nav: React.FC<any> & {
Link: React.FC<any>;
};
},
FontAwesomeSolid: {
faEthernet: any;
},
Intl: {
FormattedMessage: React.FC<any>;
}
},
loadableComponents: any;
components: Record<string, React.FC<any>>;
utils: {
NavUtils: any;
loadComponents: any;
},
hooks: any;
patch: {
before: (target: string, fn: Function) => void;
instead: (target: string, fn: Function) => void;
after: (target: string, fn: Function) => void;
},
register: {
route: (path: string, component: React.FC<any>) => void;
}
}

(function () {
const PluginApi = (window as any).PluginApi as IPluginApi;
const React = PluginApi.React;
const GQL = PluginApi.GQL;

const { Button } = PluginApi.libraries.Bootstrap;
const { faEthernet } = PluginApi.libraries.FontAwesomeSolid;
const {
Link,
NavLink,
} = PluginApi.libraries.ReactRouterDOM;

const {
NavUtils
} = PluginApi.utils;

const ScenePerformer: React.FC<{
performer: any;
}> = ({ performer }) => {
// PluginApi.components may not be registered when the outside function is run
// need to initialise these inside the function component
const {
HoverPopover,
} = PluginApi.components;

const popoverContent = React.useMemo(
() => (
<div className="scene-performer-popover">
<Link to={`/performers/${performer.id}`}>
<img
className="image-thumbnail"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</Link>
</div>
),
[performer]
);

return (
<HoverPopover
className="scene-card__performer"
placement="top"
content={popoverContent}
leaveDelay={100}
>
<a href={NavUtils.makePerformerScenesUrl(performer)}>{performer.name}</a>
</HoverPopover>
);
};

function SceneDetails(props: any) {
const {
TagLink,
} = PluginApi.components;

function maybeRenderPerformers() {
if (props.scene.performers.length <= 0) return;

return (
<div className="scene-card__performers">
{props.scene.performers.map((performer: any) => (
<ScenePerformer performer={performer} key={performer.id} />
))}
</div>
);
}

function maybeRenderTags() {
if (props.scene.tags.length <= 0) return;

return (
<div className="scene-card__tags">
{props.scene.tags.map((tag: any) => (
<TagLink key={tag.id} tag={tag} />
))}
</div>
);
}

return (
<div className="scene-card__details">
<span className="scene-card__date">{props.scene.date}</span>
{maybeRenderPerformers()}
{maybeRenderTags()}
</div>
);
}

PluginApi.patch.instead("SceneCard.Details", function (props: any, _: any, original: any) {
return <SceneDetails {...props} />;
});

const TestPage: React.FC = () => {
const componentsLoading = PluginApi.hooks.useLoadComponents([PluginApi.loadableComponents.SceneCard]);

const {
SceneCard,
LoadingIndicator,
} = PluginApi.components;

// read a random scene and show a scene card for it
const { data } = GQL.useFindScenesQuery({
variables: {
filter: {
per_page: 1,
sort: "random",
},
},
});

const scene = data?.findScenes.scenes[0];

if (componentsLoading) return (
<LoadingIndicator />
);

return (
<div>
<div>This is a test page.</div>
{!!scene && <SceneCard scene={data.findScenes.scenes[0]} />}
</div>
);
};

PluginApi.register.route("/plugin/test-react", TestPage);

PluginApi.patch.before("SettingsToolsSection", function (props: any) {
const {
Setting,
} = PluginApi.components;

return [
{
children: (
<>
{props.children}
<Setting
heading={
<Link to="/plugin/test-react">
<Button>
Test page
</Button>
</Link>
}
/>
</>
),
},
];
});

PluginApi.patch.before("MainNavBar.UtilityItems", function (props: any) {
const {
Icon,
} = PluginApi.components;

return [
{
children: (
<>
{props.children}
<NavLink
className="nav-utility"
exact
to="/plugin/test-react"
>
<Button
className="minimal d-flex align-items-center h-100"
title="Test page"
>
<Icon icon={faEthernet} />
</Button>
</NavLink>
</>
)
}
]
})
})();
11 changes: 11 additions & 0 deletions pkg/plugin/examples/react-component/src/testReact.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: Test React
description: Adds a React component
url: https://github.com/stashapp/CommunityScripts
version: 1.0
ui:
javascript:
- testReact.js
css:
- testReact.css


28 changes: 28 additions & 0 deletions pkg/plugin/examples/react-component/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2019",
"outDir": "dist",
// "lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
// "module": "es2020",
"module": "None",
"moduleResolution": "node",
// "resolveJsonModule": true,
// "noEmit": true,
"jsx": "react",
"experimentalDecorators": true,
"baseUrl": ".",
"sourceMap": true,
"allowJs": true,
"isolatedModules": true,
"noFallthroughCasesInSwitch": true,
"useDefineForClassFields": true,
// "types": ["React"]
},
"include": ["src"]
}

Loading

0 comments on commit b915428

Please sign in to comment.