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:
WithoutPants 2023-11-28 13:06:44 +11:00 committed by GitHub
parent 11be56cc42
commit b915428f06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 2418 additions and 384 deletions

View 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.

View 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"
}
}

View 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;
}

View 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>
</>
)
}
]
})
})();

View 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

View 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"]
}

File diff suppressed because it is too large Load diff

View file

@ -45,6 +45,11 @@ import useScript, { useCSS } from "./hooks/useScript";
import { useMemoOnce } from "./hooks/state"; import { useMemoOnce } from "./hooks/state";
import { uniq } from "lodash-es"; import { uniq } from "lodash-es";
import { PluginRoutes } from "./plugins";
// import plugin_api to run code
import "./pluginApi";
const Performers = lazyComponent( const Performers = lazyComponent(
() => import("./components/Performers/Performers") () => import("./components/Performers/Performers")
); );
@ -306,6 +311,7 @@ export const App: React.FC = () => {
/> />
<Route path="/setup" component={Setup} /> <Route path="/setup" component={Setup} />
<Route path="/migrate" component={Migrate} /> <Route path="/migrate" component={Migrate} />
<PluginRoutes />
<Route component={PageNotFound} /> <Route component={PageNotFound} />
</Switch> </Switch>
</Suspense> </Suspense>

View file

@ -12,6 +12,7 @@ import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md";
import Plugins from "src/docs/en/Manual/Plugins.md"; import Plugins from "src/docs/en/Manual/Plugins.md";
import ExternalPlugins from "src/docs/en/Manual/ExternalPlugins.md"; import ExternalPlugins from "src/docs/en/Manual/ExternalPlugins.md";
import EmbeddedPlugins from "src/docs/en/Manual/EmbeddedPlugins.md"; import EmbeddedPlugins from "src/docs/en/Manual/EmbeddedPlugins.md";
import UIPluginApi from "src/docs/en/Manual/UIPluginApi.md";
import Tagger from "src/docs/en/Manual/Tagger.md"; import Tagger from "src/docs/en/Manual/Tagger.md";
import Contributing from "src/docs/en/Manual/Contributing.md"; import Contributing from "src/docs/en/Manual/Contributing.md";
import SceneFilenameParser from "src/docs/en/Manual/SceneFilenameParser.md"; import SceneFilenameParser from "src/docs/en/Manual/SceneFilenameParser.md";
@ -120,6 +121,12 @@ export const Manual: React.FC<IManualProps> = ({
content: EmbeddedPlugins, content: EmbeddedPlugins,
className: "indent-1", className: "indent-1",
}, },
{
key: "UIPluginApi.md",
title: "UI Plugin API",
content: UIPluginApi,
className: "indent-1",
},
{ {
key: "Tagger.md", key: "Tagger.md",
title: "Scene Tagger", title: "Scene Tagger",

View file

@ -33,6 +33,7 @@ import {
faVideo, faVideo,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { baseURL } from "src/core/createClient"; import { baseURL } from "src/core/createClient";
import { PatchComponent } from "src/pluginApi";
interface IMenuItem { interface IMenuItem {
name: string; name: string;
@ -158,6 +159,20 @@ const newPathsList = allMenuItems
.filter((item) => item.userCreatable) .filter((item) => item.userCreatable)
.map((item) => item.href); .map((item) => item.href);
const MainNavbarMenuItems = PatchComponent(
"MainNavBar.MenuItems",
(props: React.PropsWithChildren<{}>) => {
return <Nav>{props.children}</Nav>;
}
);
const MainNavbarUtilityItems = PatchComponent(
"MainNavBar.UtilityItems",
(props: React.PropsWithChildren<{}>) => {
return <Nav>{props.children}</Nav>;
}
);
export const MainNavbar: React.FC = () => { export const MainNavbar: React.FC = () => {
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
@ -335,7 +350,7 @@ export const MainNavbar: React.FC = () => {
<Navbar.Collapse className="bg-dark order-sm-1"> <Navbar.Collapse className="bg-dark order-sm-1">
<Fade in={!loading}> <Fade in={!loading}>
<> <>
<Nav> <MainNavbarMenuItems>
{menuItems.map(({ href, icon, message }) => ( {menuItems.map(({ href, icon, message }) => (
<Nav.Link <Nav.Link
eventKey={href} eventKey={href}
@ -354,8 +369,10 @@ export const MainNavbar: React.FC = () => {
</LinkContainer> </LinkContainer>
</Nav.Link> </Nav.Link>
))} ))}
</Nav> </MainNavbarMenuItems>
<Nav>{renderUtilityButtons()}</Nav> <MainNavbarUtilityItems>
{renderUtilityButtons()}
</MainNavbarUtilityItems>
</> </>
</Fade> </Fade>
</Navbar.Collapse> </Navbar.Collapse>
@ -376,7 +393,9 @@ export const MainNavbar: React.FC = () => {
</Link> </Link>
</div> </div>
)} )}
{renderUtilityButtons()} <MainNavbarUtilityItems>
{renderUtilityButtons()}
</MainNavbarUtilityItems>
<Navbar.Toggle className="nav-menu-toggle ml-sm-2"> <Navbar.Toggle className="nav-menu-toggle ml-sm-2">
<Icon icon={expanded ? faTimes : faBars} /> <Icon icon={expanded ? faTimes : faBars} />
</Navbar.Toggle> </Navbar.Toggle>

View file

@ -31,6 +31,7 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { objectPath, objectTitle } from "src/core/files"; import { objectPath, objectTitle } from "src/core/files";
import { PreviewScrubber } from "./PreviewScrubber"; import { PreviewScrubber } from "./PreviewScrubber";
import { PatchComponent } from "src/pluginApi";
interface IScenePreviewProps { interface IScenePreviewProps {
isPortrait: boolean; isPortrait: boolean;
@ -103,372 +104,416 @@ interface ISceneCardProps {
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
} }
export const SceneCard: React.FC<ISceneCardProps> = ( const SceneCardPopovers = PatchComponent(
props: ISceneCardProps "SceneCard.Popovers",
) => { (props: ISceneCardProps) => {
const history = useHistory(); const file = useMemo(
const { configuration } = React.useContext(ConfigurationContext); () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
[props.scene]
const file = useMemo(
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
[props.scene]
);
function maybeRenderSceneSpecsOverlay() {
let sizeObj = null;
if (file?.size) {
sizeObj = TextUtils.fileSize(file.size);
}
return (
<div className="scene-specs-overlay">
{sizeObj != null ? (
<span className="overlay-filesize extra-scene-info">
<FormattedNumber
value={sizeObj.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
sizeObj.unit
)}
/>
{TextUtils.formatFileSizeUnit(sizeObj.unit)}
</span>
) : (
""
)}
{file?.width && file?.height ? (
<span className="overlay-resolution">
{" "}
{TextUtils.resolution(file?.width, file?.height)}
</span>
) : (
""
)}
{(file?.duration ?? 0) >= 1
? TextUtils.secondsToTimestamp(file?.duration ?? 0)
: ""}
</div>
); );
}
function maybeRenderInteractiveSpeedOverlay() { function maybeRenderTagPopoverButton() {
return ( if (props.scene.tags.length <= 0) return;
<div className="scene-interactive-speed-overlay">
{props.scene.interactive_speed ?? ""}
</div>
);
}
function renderStudioThumbnail() { const popoverContent = props.scene.tags.map((tag) => (
const studioImage = props.scene.studio?.image_path; <TagLink key={tag.id} tag={tag} />
const studioName = props.scene.studio?.name; ));
if (configuration?.interface.showStudioAsText || !studioImage) {
return studioName;
}
const studioImageURL = new URL(studioImage);
if (studioImageURL.searchParams.get("default") === "true") {
return studioName;
}
return (
<img
className="image-thumbnail"
loading="lazy"
alt={studioName}
src={studioImage}
/>
);
}
function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) return;
return (
<div className="scene-studio-overlay">
<Link to={`/studios/${props.scene.studio.id}`}>
{renderStudioThumbnail()}
</Link>
</div>
);
}
function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) return;
const popoverContent = props.scene.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} />
));
return (
<HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faTag} />
<span>{props.scene.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) return;
return <PerformerPopoverButton performers={props.scene.performers} />;
}
function maybeRenderMoviePopoverButton() {
if (props.scene.movies.length <= 0) return;
const popoverContent = props.scene.movies.map((sceneMovie) => (
<div className="movie-tag-container row" key="movie">
<Link
to={`/movies/${sceneMovie.movie.id}`}
className="movie-tag col m-auto zoom-2"
>
<img
className="image-thumbnail"
alt={sceneMovie.movie.name ?? ""}
src={sceneMovie.movie.front_image_path ?? ""}
/>
</Link>
<MovieLink
key={sceneMovie.movie.id}
movie={sceneMovie.movie}
className="d-block"
/>
</div>
));
return (
<HoverPopover
placement="bottom"
content={popoverContent}
className="movie-count tag-tooltip"
>
<Button className="minimal">
<Icon icon={faFilm} />
<span>{props.scene.movies.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) return;
const popoverContent = props.scene.scene_markers.map((marker) => {
const markerWithScene = { ...marker, scene: { id: props.scene.id } };
return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;
});
return (
<HoverPopover
className="marker-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faMapMarkerAlt} />
<span>{props.scene.scene_markers.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOCounter() {
if (props.scene.o_counter) {
return ( return (
<div className="o-count"> <HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal"> <Button className="minimal">
<span className="fa-icon"> <Icon icon={faTag} />
<SweatDrops /> <span>{props.scene.tags.length}</span>
</span>
<span>{props.scene.o_counter}</span>
</Button> </Button>
</div> </HoverPopover>
); );
} }
}
function maybeRenderGallery() { function maybeRenderPerformerPopoverButton() {
if (props.scene.galleries.length <= 0) return; if (props.scene.performers.length <= 0) return;
const popoverContent = props.scene.galleries.map((gallery) => ( return <PerformerPopoverButton performers={props.scene.performers} />;
<GalleryLink key={gallery.id} gallery={gallery} /> }
));
return ( function maybeRenderMoviePopoverButton() {
<HoverPopover if (props.scene.movies.length <= 0) return;
className="gallery-count"
placement="bottom" const popoverContent = props.scene.movies.map((sceneMovie) => (
content={popoverContent} <div className="movie-tag-container row" key="movie">
> <Link
<Button className="minimal"> to={`/movies/${sceneMovie.movie.id}`}
<Icon icon={faImages} /> className="movie-tag col m-auto zoom-2"
<span>{props.scene.galleries.length}</span> >
</Button> <img
</HoverPopover> className="image-thumbnail"
); alt={sceneMovie.movie.name ?? ""}
} src={sceneMovie.movie.front_image_path ?? ""}
/>
</Link>
<MovieLink
key={sceneMovie.movie.id}
movie={sceneMovie.movie}
className="d-block"
/>
</div>
));
function maybeRenderOrganized() {
if (props.scene.organized) {
return ( return (
<OverlayTrigger <HoverPopover
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
placement="bottom" placement="bottom"
content={popoverContent}
className="movie-count tag-tooltip"
> >
<div className="organized"> <Button className="minimal">
<Icon icon={faFilm} />
<span>{props.scene.movies.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) return;
const popoverContent = props.scene.scene_markers.map((marker) => {
const markerWithScene = { ...marker, scene: { id: props.scene.id } };
return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;
});
return (
<HoverPopover
className="marker-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faMapMarkerAlt} />
<span>{props.scene.scene_markers.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOCounter() {
if (props.scene.o_counter) {
return (
<div className="o-count">
<Button className="minimal"> <Button className="minimal">
<Icon icon={faBox} /> <span className="fa-icon">
<SweatDrops />
</span>
<span>{props.scene.o_counter}</span>
</Button> </Button>
</div> </div>
</OverlayTrigger> );
}
}
function maybeRenderGallery() {
if (props.scene.galleries.length <= 0) return;
const popoverContent = props.scene.galleries.map((gallery) => (
<GalleryLink key={gallery.id} gallery={gallery} />
));
return (
<HoverPopover
className="gallery-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faImages} />
<span>{props.scene.galleries.length}</span>
</Button>
</HoverPopover>
); );
} }
}
function maybeRenderDupeCopies() { function maybeRenderOrganized() {
const phash = file if (props.scene.organized) {
? file.fingerprints.find((fp) => fp.type === "phash") return (
: undefined; <OverlayTrigger
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
if (phash) { placement="bottom"
return (
<div className="other-copies extra-scene-info">
<Button
href={NavUtils.makeScenesPHashMatchUrl(phash.value)}
className="minimal"
> >
<Icon icon={faCopy} /> <div className="organized">
</Button> <Button className="minimal">
<Icon icon={faBox} />
</Button>
</div>
</OverlayTrigger>
);
}
}
function maybeRenderDupeCopies() {
const phash = file
? file.fingerprints.find((fp) => fp.type === "phash")
: undefined;
if (phash) {
return (
<div className="other-copies extra-scene-info">
<Button
href={NavUtils.makeScenesPHashMatchUrl(phash.value)}
className="minimal"
>
<Icon icon={faCopy} />
</Button>
</div>
);
}
}
function maybeRenderPopoverButtonGroup() {
if (
!props.compact &&
(props.scene.tags.length > 0 ||
props.scene.performers.length > 0 ||
props.scene.movies.length > 0 ||
props.scene.scene_markers.length > 0 ||
props.scene?.o_counter ||
props.scene.galleries.length > 0 ||
props.scene.organized)
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderMoviePopoverButton()}
{maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
{maybeRenderDupeCopies()}
</ButtonGroup>
</>
);
}
}
return <>{maybeRenderPopoverButtonGroup()}</>;
}
);
const SceneCardDetails = PatchComponent(
"SceneCard.Details",
(props: ISceneCardProps) => {
return (
<div className="scene-card__details">
<span className="scene-card__date">{props.scene.date}</span>
<span className="file-path extra-scene-info">
{objectPath(props.scene)}
</span>
<TruncatedText
className="scene-card__description"
text={props.scene.details}
lineCount={3}
/>
</div>
);
}
);
const SceneCardOverlays = PatchComponent(
"SceneCard.Overlays",
(props: ISceneCardProps) => {
const { configuration } = React.useContext(ConfigurationContext);
function renderStudioThumbnail() {
const studioImage = props.scene.studio?.image_path;
const studioName = props.scene.studio?.name;
if (configuration?.interface.showStudioAsText || !studioImage) {
return studioName;
}
const studioImageURL = new URL(studioImage);
if (studioImageURL.searchParams.get("default") === "true") {
return studioName;
}
return (
<img
className="image-thumbnail"
loading="lazy"
alt={studioName}
src={studioImage}
/>
);
}
function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) return;
return (
<div className="scene-studio-overlay">
<Link to={`/studios/${props.scene.studio.id}`}>
{renderStudioThumbnail()}
</Link>
</div> </div>
); );
} }
}
function maybeRenderPopoverButtonGroup() { return <>{maybeRenderSceneStudioOverlay()}</>;
if ( }
!props.compact && );
(props.scene.tags.length > 0 ||
props.scene.performers.length > 0 || const SceneCardImage = PatchComponent(
props.scene.movies.length > 0 || "SceneCard.Image",
props.scene.scene_markers.length > 0 || (props: ISceneCardProps) => {
props.scene?.o_counter || const history = useHistory();
props.scene.galleries.length > 0 || const { configuration } = React.useContext(ConfigurationContext);
props.scene.organized) const cont = configuration?.interface.continuePlaylistDefault ?? false;
) {
const file = useMemo(
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
[props.scene]
);
function maybeRenderSceneSpecsOverlay() {
let sizeObj = null;
if (file?.size) {
sizeObj = TextUtils.fileSize(file.size);
}
return ( return (
<> <div className="scene-specs-overlay">
<hr /> {sizeObj != null ? (
<ButtonGroup className="card-popovers"> <span className="overlay-filesize extra-scene-info">
{maybeRenderTagPopoverButton()} <FormattedNumber
{maybeRenderPerformerPopoverButton()} value={sizeObj.size}
{maybeRenderMoviePopoverButton()} maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
{maybeRenderSceneMarkerPopoverButton()} sizeObj.unit
{maybeRenderOCounter()} )}
{maybeRenderGallery()} />
{maybeRenderOrganized()} {TextUtils.formatFileSizeUnit(sizeObj.unit)}
{maybeRenderDupeCopies()} </span>
</ButtonGroup> ) : (
</> ""
)}
{file?.width && file?.height ? (
<span className="overlay-resolution">
{" "}
{TextUtils.resolution(file?.width, file?.height)}
</span>
) : (
""
)}
{(file?.duration ?? 0) >= 1
? TextUtils.secondsToTimestamp(file?.duration ?? 0)
: ""}
</div>
); );
} }
}
function isPortrait() { function maybeRenderInteractiveSpeedOverlay() {
const width = file?.width ? file.width : 0; return (
const height = file?.height ? file.height : 0; <div className="scene-interactive-speed-overlay">
return height > width; {props.scene.interactive_speed ?? ""}
} </div>
);
function zoomIndex() {
if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
} }
return ""; function onScrubberClick(timestamp: number) {
} const link = props.queue
? props.queue.makeLink(props.scene.id, {
sceneIndex: props.index,
continue: cont,
start: timestamp,
})
: `/scenes/${props.scene.id}?t=${timestamp}`;
function filelessClass() { history.push(link);
if (!props.scene.files.length) {
return "fileless";
} }
return ""; function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
return height > width;
}
return (
<>
<ScenePreview
image={props.scene.paths.screenshot ?? undefined}
video={props.scene.paths.preview ?? undefined}
isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false}
vttPath={props.scene.paths.vtt ?? undefined}
onScrubberClick={onScrubberClick}
/>
<RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()}
{maybeRenderInteractiveSpeedOverlay()}
</>
);
} }
);
const cont = configuration?.interface.continuePlaylistDefault ?? false; export const SceneCard = PatchComponent(
"SceneCard",
(props: ISceneCardProps) => {
const { configuration } = React.useContext(ConfigurationContext);
const sceneLink = props.queue const file = useMemo(
? props.queue.makeLink(props.scene.id, { () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
sceneIndex: props.index, [props.scene]
continue: cont, );
})
: `/scenes/${props.scene.id}`;
function onScrubberClick(timestamp: number) { function zoomIndex() {
const link = props.queue if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
}
return "";
}
function filelessClass() {
if (!props.scene.files.length) {
return "fileless";
}
return "";
}
const cont = configuration?.interface.continuePlaylistDefault ?? false;
const sceneLink = props.queue
? props.queue.makeLink(props.scene.id, { ? props.queue.makeLink(props.scene.id, {
sceneIndex: props.index, sceneIndex: props.index,
continue: cont, continue: cont,
start: timestamp,
}) })
: `/scenes/${props.scene.id}?t=${timestamp}`; : `/scenes/${props.scene.id}`;
history.push(link); return (
<GridCard
className={`scene-card ${zoomIndex()} ${filelessClass()}`}
url={sceneLink}
title={objectTitle(props.scene)}
linkClassName="scene-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.scene.resume_time ?? undefined}
duration={file?.duration ?? undefined}
interactiveHeatmap={
props.scene.interactive_speed
? props.scene.paths.interactive_heatmap ?? undefined
: undefined
}
image={<SceneCardImage {...props} />}
overlays={<SceneCardOverlays {...props} />}
details={<SceneCardDetails {...props} />}
popovers={<SceneCardPopovers {...props} />}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
} }
);
return (
<GridCard
className={`scene-card ${zoomIndex()} ${filelessClass()}`}
url={sceneLink}
title={objectTitle(props.scene)}
linkClassName="scene-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.scene.resume_time ?? undefined}
duration={file?.duration ?? undefined}
interactiveHeatmap={
props.scene.interactive_speed
? props.scene.paths.interactive_heatmap ?? undefined
: undefined
}
image={
<>
<ScenePreview
image={props.scene.paths.screenshot ?? undefined}
video={props.scene.paths.preview ?? undefined}
isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false}
vttPath={props.scene.paths.vtt ?? undefined}
onScrubberClick={onScrubberClick}
/>
<RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()}
{maybeRenderInteractiveSpeedOverlay()}
</>
}
overlays={maybeRenderSceneStudioOverlay()}
details={
<div className="scene-card__details">
<span className="scene-card__date">{props.scene.date}</span>
<span className="file-path extra-scene-info">
{objectPath(props.scene)}
</span>
<TruncatedText
className="scene-card__description"
text={props.scene.details}
lineCount={3}
/>
</div>
}
popovers={maybeRenderPopoverButtonGroup()}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
};

View file

@ -4,6 +4,7 @@ import { Button, Collapse, Form, Modal, ModalProps } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { StringListInput } from "../Shared/StringListInput"; import { StringListInput } from "../Shared/StringListInput";
import { PatchComponent } from "src/pluginApi";
interface ISetting { interface ISetting {
id?: string; id?: string;
@ -17,57 +18,64 @@ interface ISetting {
disabled?: boolean; disabled?: boolean;
} }
export const Setting: React.FC<PropsWithChildren<ISetting>> = ({ export const Setting: React.FC<PropsWithChildren<ISetting>> = PatchComponent(
id, "Setting",
className, (props: PropsWithChildren<ISetting>) => {
heading, const {
headingID, id,
subHeadingID, className,
subHeading, heading,
children, headingID,
tooltipID, subHeadingID,
onClick, subHeading,
disabled, children,
}) => { tooltipID,
const intl = useIntl(); onClick,
disabled,
} = props;
function renderHeading() { const intl = useIntl();
if (headingID) {
return intl.formatMessage({ id: headingID }); function renderHeading() {
if (headingID) {
return intl.formatMessage({ id: headingID });
}
return heading;
} }
return heading;
}
function renderSubHeading() { function renderSubHeading() {
if (subHeadingID) { if (subHeadingID) {
return ( return (
<div className="sub-heading"> <div className="sub-heading">
{intl.formatMessage({ id: subHeadingID })} {intl.formatMessage({ id: subHeadingID })}
</div>
);
}
if (subHeading) {
return <div className="sub-heading">{subHeading}</div>;
}
}
const tooltip = tooltipID
? intl.formatMessage({ id: tooltipID })
: undefined;
const disabledClassName = disabled ? "disabled" : "";
return (
<div
className={`setting ${className ?? ""} ${disabledClassName}`}
id={id}
onClick={onClick}
>
<div>
<h3 title={tooltip}>{renderHeading()}</h3>
{renderSubHeading()}
</div> </div>
); <div>{children}</div>
}
if (subHeading) {
return <div className="sub-heading">{subHeading}</div>;
}
}
const tooltip = tooltipID ? intl.formatMessage({ id: tooltipID }) : undefined;
const disabledClassName = disabled ? "disabled" : "";
return (
<div
className={`setting ${className ?? ""} ${disabledClassName}`}
id={id}
onClick={onClick}
>
<div>
<h3 title={tooltip}>{renderHeading()}</h3>
{renderSubHeading()}
</div> </div>
<div>{children}</div> );
</div> }
); ) as React.FC<PropsWithChildren<ISetting>>;
};
interface ISettingGroup { interface ISettingGroup {
settingProps?: ISetting; settingProps?: ISetting;

View file

@ -4,11 +4,14 @@ import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Setting } from "./Inputs"; import { Setting } from "./Inputs";
import { SettingSection } from "./SettingSection"; import { SettingSection } from "./SettingSection";
import { PatchContainerComponent } from "src/pluginApi";
const SettingsToolsSection = PatchContainerComponent("SettingsToolsSection");
export const SettingsToolsPanel: React.FC = () => { export const SettingsToolsPanel: React.FC = () => {
return ( return (
<> <SettingSection headingID="config.tools.scene_tools">
<SettingSection headingID="config.tools.scene_tools"> <SettingsToolsSection>
<Setting <Setting
heading={ heading={
<Link to="/sceneFilenameParser"> <Link to="/sceneFilenameParser">
@ -28,7 +31,7 @@ export const SettingsToolsPanel: React.FC = () => {
</Link> </Link>
} }
/> />
</SettingSection> </SettingsToolsSection>
</> </SettingSection>
); );
}; };

View file

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition, SizeProp } from "@fortawesome/fontawesome-svg-core"; import { IconDefinition, SizeProp } from "@fortawesome/fontawesome-svg-core";
import { PatchComponent } from "src/pluginApi";
interface IIcon { interface IIcon {
icon: IconDefinition; icon: IconDefinition;
@ -9,11 +10,14 @@ interface IIcon {
size?: SizeProp; size?: SizeProp;
} }
export const Icon: React.FC<IIcon> = ({ icon, className, color, size }) => ( export const Icon: React.FC<IIcon> = PatchComponent(
<FontAwesomeIcon "Icon",
icon={icon} ({ icon, className, color, size }) => (
className={`fa-icon ${className ?? ""}`} <FontAwesomeIcon
color={color} icon={icon}
size={size} className={`fa-icon ${className ?? ""}`}
/> color={color}
size={size}
/>
)
); );

View file

@ -0,0 +1,131 @@
# UI Plugin API
The `PluginApi` object is a global object in the `window` object.
`PluginApi` is considered experimental and is subject to change without notice. This documentation covers only the plugin-specific API. It does not necessarily cover the core UI API. Information on these methods should be referenced in the UI source code.
An example using various aspects of `PluginApi` may be found in the source code under `pkg/plugin/examples/react-component`.
## Properties
### `React`
An instance of the React library.
### `GQL`
This namespace contains the generated graphql client interface. This is a low-level interface. In many cases, `StashService` should be used instead.
### `libraries`
`libraries` provides access to the following UI libraries:
- `ReactRouterDOM`
- `Bootstrap`
- `Apollo`
- `Intl`
- `FontAwesomeRegular`
- `FontAwesomeSolid`
### `register`
This namespace contains methods used to register page routes and components.
#### `PluginApi.register.route`
Registers a route in the React Router.
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | `string` | The path to register. This should generally use the `/plugin/` prefix. |
| `component` | `React.FC` | A React function component that will be rendered when the route is loaded. |
Returns `void`.
#### `PluginApi.register.component`
Registers a component to be used by plugins. The component will be available in the `components` namespace.
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `string` | The name of the component to register. This should be unique and should ideally be prefixed with `plugin-`. |
| `component` | `React.FC` | A React function component. |
Returns `void`.
### `components`
This namespace contains all of the components available to plugins. These include a selection of core components and components registered using `PluginApi.register.component`.
### `utils`
This namespace provides access to the `NavUtils` and `StashService` namespaces. It also provides access to the `loadComponents` method.
#### `PluginApi.utils.loadComponents`
Due to code splitting, some components may not be loaded and available when a plugin page is rendered. `loadComponents` loads all of the components that a plugin page may require.
In general, `PluginApi.hooks.useLoadComponents` hook should be used instead.
| Parameter | Type | Description |
|-----------|------|-------------|
| `components` | `Promise[]` | The list of components to load. These values should come from the `PluginApi.loadableComponents` namespace. |
Returns a `Promise<void>` that resolves when all of the components have been loaded.
### `hooks`
This namespace provides access to the following core utility hooks:
- `useSpriteInfo`
It also provides plugin-specific hooks.
#### `PluginApi.hooks.useLoadComponents`
This is a hook used to load components, using the `PluginApi.utils.loadComponents` method.
| Parameter | Type | Description |
|-----------|------|-------------|
| `components` | `Promise[]` | The list of components to load. These values should come from the `PluginApi.loadableComponents` namespace. |
Returns a `boolean` which will be `true` if the components are loading.
### `loadableComponents`
This namespace contains all of the components that may need to be loaded using the `loadComponents` method. Components are added to this namespace as needed. Please make a development request if a required component is not in this namespace.
### `patch`
This namespace provides methods to patch components to change their behaviour.
#### `PluginApi.patch.before`
Registers a before function. A before function is called prior to calling a component's render function. It accepts the same parameters as the component's render function, and is expected to return a list of new arguments that will be passed to the render.
| Parameter | Type | Description |
|-----------|------|-------------|
| `component` | `string` | The name of the component to patch. |
| `fn` | `Function` | The before function. It accepts the same arguments as the component render function and is expected to return a list of arguments to pass to the render function. |
Returns `void`.
#### `PluginApi.patch.instead`
Registers a replacement function for a component. The provided function will be called with the arguments passed to the original render function, plus the original render function as the last argument. An error will be thrown if the component already has a replacement function registered.
| Parameter | Type | Description |
|-----------|------|-------------|
| `component` | `string` | The name of the component to patch. |
| `fn` | `Function` | The replacement function. It accepts the same arguments as the original render function, plus the original render function, and is expected to return the replacement component. |
Returns `void`.
#### `PluginApi.patch.after`
Registers an after function. An after function is called after the render function of the component. It accepts the arguments passed to the original render function, plus the result of the original render function. It is expected to return the rendered component.
| Parameter | Type | Description |
|-----------|------|-------------|
| `component` | `string` | The name of the component to patch. |
| `fn` | `Function` | The after function. It accepts the same arguments as the original render function, plus the result of the original render function, and is expected to return the rendered component. |
Returns `void`.

199
ui/v2.5/src/pluginApi.tsx Normal file
View file

@ -0,0 +1,199 @@
import React from "react";
import * as ReactRouterDOM from "react-router-dom";
import NavUtils from "./utils/navigation";
import { HoverPopover } from "./components/Shared/HoverPopover";
import { TagLink } from "./components/Shared/TagLink";
import { LoadingIndicator } from "./components/Shared/LoadingIndicator";
import * as GQL from "src/core/generated-graphql";
import * as StashService from "src/core/StashService";
import * as Apollo from "@apollo/client";
import * as Bootstrap from "react-bootstrap";
import * as Intl from "react-intl";
import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons";
import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons";
import { useSpriteInfo } from "./hooks/sprite";
// due to code splitting, some components may not have been loaded when a plugin
// page is loaded. This function will load all components passed to it.
// The components need to be imported here. Any required imports will be added
// to the loadableComponents object in the plugin api.
async function loadComponents(c: (() => Promise<unknown>)[]) {
await Promise.all(c.map((fn) => fn()));
}
// useLoadComponents is a hook that loads all components passed to it.
// It returns a boolean indicating whether the components are still loading.
function useLoadComponents(components: (() => Promise<unknown>)[]) {
const [loading, setLoading] = React.useState(true);
const [componentList] = React.useState(components);
async function load(c: (() => Promise<unknown>)[]) {
await loadComponents(c);
setLoading(false);
}
React.useEffect(() => {
setLoading(true);
load(componentList);
}, [componentList]);
return loading;
}
const components: Record<string, Function> = {
HoverPopover,
TagLink,
LoadingIndicator,
};
const beforeFns: Record<string, Function[]> = {};
const insteadFns: Record<string, Function> = {};
const afterFns: Record<string, Function[]> = {};
// patch functions
// registers a patch to a function. Before functions are expected to return the
// new arguments to be passed to the function.
function before(component: string, fn: Function) {
if (!beforeFns[component]) {
beforeFns[component] = [];
}
beforeFns[component].push(fn);
}
function instead(component: string, fn: Function) {
if (insteadFns[component]) {
throw new Error("instead has already been called for " + component);
}
insteadFns[component] = fn;
}
function after(component: string, fn: Function) {
if (!afterFns[component]) {
afterFns[component] = [];
}
afterFns[component].push(fn);
}
function registerRoute(path: string, component: React.FC) {
before("PluginRoutes", function (props: React.PropsWithChildren<{}>) {
return [
{
children: (
<>
{props.children}
<ReactRouterDOM.Route path={path} component={component} />
</>
),
},
];
});
}
export function RegisterComponent(component: string, fn: Function) {
// register with the plugin api
components[component] = fn;
return fn;
}
export const PluginApi = {
React,
GQL,
libraries: {
ReactRouterDOM,
Bootstrap,
Apollo,
Intl,
FontAwesomeRegular,
FontAwesomeSolid,
},
register: {
// register a route to be added to the main router
route: registerRoute,
// register a component to be added to the components object
component: RegisterComponent,
},
loadableComponents: {
// add components as needed for plugins that provide pages
SceneCard: () => import("./components/Scenes/SceneCard"),
},
components,
utils: {
NavUtils,
StashService,
loadComponents,
},
hooks: {
useLoadComponents,
useSpriteInfo,
},
patch: {
// intercept the arguments of supported functions
// the provided function should accept the arguments and return the new arguments
before,
// replace a function with a new one implementation
// the provided function will be called with the arguments passed to the original function
// and the original function as the last argument
// only one instead function can be registered per component
instead,
// intercept the result of supported functions
// the provided function will be called with the arguments passed to the original function
// and the result of the original function
after,
},
};
// patches a function to implement the before/instead/after functionality
export function PatchFunction(name: string, fn: Function) {
return new Proxy(fn, {
apply(target, ctx, args) {
let result;
for (const beforeFn of beforeFns[name] || []) {
args = beforeFn.apply(ctx, args);
}
if (insteadFns[name]) {
result = insteadFns[name].apply(ctx, args.concat(target));
} else {
result = target.apply(ctx, args);
}
for (const afterFn of afterFns[name] || []) {
result = afterFn.apply(ctx, args.concat(result));
}
return result;
},
});
}
// patches a component and registers it in the pluginapi components object
export function PatchComponent<T>(
component: string,
fn: React.FC<T>
): React.FC<T> {
const ret = PatchFunction(component, fn);
// register with the plugin api
RegisterComponent(component, ret);
return ret as React.FC<T>;
}
// patches a component and registers it in the pluginapi components object
export function PatchContainerComponent(
component: string
): React.FC<React.PropsWithChildren<{}>> {
const fn = (props: React.PropsWithChildren<{}>) => {
return <>{props.children}</>;
};
return PatchComponent(component, fn);
}
export default PluginApi;
interface IWindow {
PluginApi: typeof PluginApi;
}
const localWindow = window as unknown as IWindow;
// export the plugin api to the window object
localWindow.PluginApi = PluginApi;

7
ui/v2.5/src/plugins.tsx Normal file
View file

@ -0,0 +1,7 @@
import React from "react";
import { PatchFunction } from "./pluginApi";
export const PluginRoutes: React.FC<React.PropsWithChildren<{}>> =
PatchFunction("PluginRoutes", (props: React.PropsWithChildren<{}>) => {
return <>{props.children}</>;
}) as React.FC;