mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +03:00
UI Plugin API (#4256)
* 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
This commit is contained in:
7
pkg/plugin/examples/react-component/README.md
Normal file
7
pkg/plugin/examples/react-component/README.md
Normal file
@@ -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
pkg/plugin/examples/react-component/package.json
Normal file
21
pkg/plugin/examples/react-component/package.json
Normal file
@@ -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
pkg/plugin/examples/react-component/src/testReact.scss
Normal file
36
pkg/plugin/examples/react-component/src/testReact.scss
Normal file
@@ -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
pkg/plugin/examples/react-component/src/testReact.tsx
Normal file
220
pkg/plugin/examples/react-component/src/testReact.tsx
Normal file
@@ -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
pkg/plugin/examples/react-component/src/testReact.yml
Normal file
11
pkg/plugin/examples/react-component/src/testReact.yml
Normal file
@@ -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
pkg/plugin/examples/react-component/tsconfig.json
Normal file
28
pkg/plugin/examples/react-component/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
|
||||
1282
pkg/plugin/examples/react-component/yarn.lock
Normal file
1282
pkg/plugin/examples/react-component/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user