mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01: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:
parent
11be56cc42
commit
b915428f06
17 changed files with 2418 additions and 384 deletions
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
|
|
@ -45,6 +45,11 @@ import useScript, { useCSS } from "./hooks/useScript";
|
|||
import { useMemoOnce } from "./hooks/state";
|
||||
import { uniq } from "lodash-es";
|
||||
|
||||
import { PluginRoutes } from "./plugins";
|
||||
|
||||
// import plugin_api to run code
|
||||
import "./pluginApi";
|
||||
|
||||
const Performers = lazyComponent(
|
||||
() => import("./components/Performers/Performers")
|
||||
);
|
||||
|
|
@ -306,6 +311,7 @@ export const App: React.FC = () => {
|
|||
/>
|
||||
<Route path="/setup" component={Setup} />
|
||||
<Route path="/migrate" component={Migrate} />
|
||||
<PluginRoutes />
|
||||
<Route component={PageNotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md";
|
|||
import Plugins from "src/docs/en/Manual/Plugins.md";
|
||||
import ExternalPlugins from "src/docs/en/Manual/ExternalPlugins.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 Contributing from "src/docs/en/Manual/Contributing.md";
|
||||
import SceneFilenameParser from "src/docs/en/Manual/SceneFilenameParser.md";
|
||||
|
|
@ -120,6 +121,12 @@ export const Manual: React.FC<IManualProps> = ({
|
|||
content: EmbeddedPlugins,
|
||||
className: "indent-1",
|
||||
},
|
||||
{
|
||||
key: "UIPluginApi.md",
|
||||
title: "UI Plugin API",
|
||||
content: UIPluginApi,
|
||||
className: "indent-1",
|
||||
},
|
||||
{
|
||||
key: "Tagger.md",
|
||||
title: "Scene Tagger",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
faVideo,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { baseURL } from "src/core/createClient";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
|
||||
interface IMenuItem {
|
||||
name: string;
|
||||
|
|
@ -158,6 +159,20 @@ const newPathsList = allMenuItems
|
|||
.filter((item) => item.userCreatable)
|
||||
.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 = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
|
@ -335,7 +350,7 @@ export const MainNavbar: React.FC = () => {
|
|||
<Navbar.Collapse className="bg-dark order-sm-1">
|
||||
<Fade in={!loading}>
|
||||
<>
|
||||
<Nav>
|
||||
<MainNavbarMenuItems>
|
||||
{menuItems.map(({ href, icon, message }) => (
|
||||
<Nav.Link
|
||||
eventKey={href}
|
||||
|
|
@ -354,8 +369,10 @@ export const MainNavbar: React.FC = () => {
|
|||
</LinkContainer>
|
||||
</Nav.Link>
|
||||
))}
|
||||
</Nav>
|
||||
<Nav>{renderUtilityButtons()}</Nav>
|
||||
</MainNavbarMenuItems>
|
||||
<MainNavbarUtilityItems>
|
||||
{renderUtilityButtons()}
|
||||
</MainNavbarUtilityItems>
|
||||
</>
|
||||
</Fade>
|
||||
</Navbar.Collapse>
|
||||
|
|
@ -376,7 +393,9 @@ export const MainNavbar: React.FC = () => {
|
|||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{renderUtilityButtons()}
|
||||
<MainNavbarUtilityItems>
|
||||
{renderUtilityButtons()}
|
||||
</MainNavbarUtilityItems>
|
||||
<Navbar.Toggle className="nav-menu-toggle ml-sm-2">
|
||||
<Icon icon={expanded ? faTimes : faBars} />
|
||||
</Navbar.Toggle>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { objectPath, objectTitle } from "src/core/files";
|
||||
import { PreviewScrubber } from "./PreviewScrubber";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
|
||||
interface IScenePreviewProps {
|
||||
isPortrait: boolean;
|
||||
|
|
@ -103,372 +104,416 @@ interface ISceneCardProps {
|
|||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
export const SceneCard: React.FC<ISceneCardProps> = (
|
||||
props: ISceneCardProps
|
||||
) => {
|
||||
const history = useHistory();
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
|
||||
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>
|
||||
const SceneCardPopovers = PatchComponent(
|
||||
"SceneCard.Popovers",
|
||||
(props: ISceneCardProps) => {
|
||||
const file = useMemo(
|
||||
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
|
||||
[props.scene]
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderInteractiveSpeedOverlay() {
|
||||
return (
|
||||
<div className="scene-interactive-speed-overlay">
|
||||
{props.scene.interactive_speed ?? ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.scene.tags.length <= 0) return;
|
||||
|
||||
function renderStudioThumbnail() {
|
||||
const studioImage = props.scene.studio?.image_path;
|
||||
const studioName = props.scene.studio?.name;
|
||||
const popoverContent = props.scene.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} />
|
||||
));
|
||||
|
||||
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 (
|
||||
<div className="o-count">
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<span className="fa-icon">
|
||||
<SweatDrops />
|
||||
</span>
|
||||
<span>{props.scene.o_counter}</span>
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.scene.tags.length}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderGallery() {
|
||||
if (props.scene.galleries.length <= 0) return;
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.scene.performers.length <= 0) return;
|
||||
|
||||
const popoverContent = props.scene.galleries.map((gallery) => (
|
||||
<GalleryLink key={gallery.id} gallery={gallery} />
|
||||
));
|
||||
return <PerformerPopoverButton performers={props.scene.performers} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="gallery-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faImages} />
|
||||
<span>{props.scene.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
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>
|
||||
));
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.scene.organized) {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
|
||||
<HoverPopover
|
||||
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">
|
||||
<Icon icon={faBox} />
|
||||
<span className="fa-icon">
|
||||
<SweatDrops />
|
||||
</span>
|
||||
<span>{props.scene.o_counter}</span>
|
||||
</Button>
|
||||
</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() {
|
||||
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"
|
||||
function maybeRenderOrganized() {
|
||||
if (props.scene.organized) {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
|
||||
placement="bottom"
|
||||
>
|
||||
<Icon icon={faCopy} />
|
||||
</Button>
|
||||
<div className="organized">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 <>{maybeRenderSceneStudioOverlay()}</>;
|
||||
}
|
||||
);
|
||||
|
||||
const SceneCardImage = PatchComponent(
|
||||
"SceneCard.Image",
|
||||
(props: ISceneCardProps) => {
|
||||
const history = useHistory();
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
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 (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderMoviePopoverButton()}
|
||||
{maybeRenderSceneMarkerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
{maybeRenderGallery()}
|
||||
{maybeRenderOrganized()}
|
||||
{maybeRenderDupeCopies()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
<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 isPortrait() {
|
||||
const width = file?.width ? file.width : 0;
|
||||
const height = file?.height ? file.height : 0;
|
||||
return height > width;
|
||||
}
|
||||
|
||||
function zoomIndex() {
|
||||
if (!props.compact && props.zoomIndex !== undefined) {
|
||||
return `zoom-${props.zoomIndex}`;
|
||||
function maybeRenderInteractiveSpeedOverlay() {
|
||||
return (
|
||||
<div className="scene-interactive-speed-overlay">
|
||||
{props.scene.interactive_speed ?? ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!props.scene.files.length) {
|
||||
return "fileless";
|
||||
history.push(link);
|
||||
}
|
||||
|
||||
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
|
||||
? props.queue.makeLink(props.scene.id, {
|
||||
sceneIndex: props.index,
|
||||
continue: cont,
|
||||
})
|
||||
: `/scenes/${props.scene.id}`;
|
||||
const file = useMemo(
|
||||
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
|
||||
[props.scene]
|
||||
);
|
||||
|
||||
function onScrubberClick(timestamp: number) {
|
||||
const link = props.queue
|
||||
function zoomIndex() {
|
||||
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, {
|
||||
sceneIndex: props.index,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button, Collapse, Form, Modal, ModalProps } from "react-bootstrap";
|
|||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { StringListInput } from "../Shared/StringListInput";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
|
||||
interface ISetting {
|
||||
id?: string;
|
||||
|
|
@ -17,57 +18,64 @@ interface ISetting {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Setting: React.FC<PropsWithChildren<ISetting>> = ({
|
||||
id,
|
||||
className,
|
||||
heading,
|
||||
headingID,
|
||||
subHeadingID,
|
||||
subHeading,
|
||||
children,
|
||||
tooltipID,
|
||||
onClick,
|
||||
disabled,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
export const Setting: React.FC<PropsWithChildren<ISetting>> = PatchComponent(
|
||||
"Setting",
|
||||
(props: PropsWithChildren<ISetting>) => {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
heading,
|
||||
headingID,
|
||||
subHeadingID,
|
||||
subHeading,
|
||||
children,
|
||||
tooltipID,
|
||||
onClick,
|
||||
disabled,
|
||||
} = props;
|
||||
|
||||
function renderHeading() {
|
||||
if (headingID) {
|
||||
return intl.formatMessage({ id: headingID });
|
||||
const intl = useIntl();
|
||||
|
||||
function renderHeading() {
|
||||
if (headingID) {
|
||||
return intl.formatMessage({ id: headingID });
|
||||
}
|
||||
return heading;
|
||||
}
|
||||
return heading;
|
||||
}
|
||||
|
||||
function renderSubHeading() {
|
||||
if (subHeadingID) {
|
||||
return (
|
||||
<div className="sub-heading">
|
||||
{intl.formatMessage({ id: subHeadingID })}
|
||||
function renderSubHeading() {
|
||||
if (subHeadingID) {
|
||||
return (
|
||||
<div className="sub-heading">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
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>{children}</div>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
) as React.FC<PropsWithChildren<ISetting>>;
|
||||
|
||||
interface ISettingGroup {
|
||||
settingProps?: ISetting;
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { FormattedMessage } from "react-intl";
|
|||
import { Link } from "react-router-dom";
|
||||
import { Setting } from "./Inputs";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { PatchContainerComponent } from "src/pluginApi";
|
||||
|
||||
const SettingsToolsSection = PatchContainerComponent("SettingsToolsSection");
|
||||
|
||||
export const SettingsToolsPanel: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingSection headingID="config.tools.scene_tools">
|
||||
<SettingSection headingID="config.tools.scene_tools">
|
||||
<SettingsToolsSection>
|
||||
<Setting
|
||||
heading={
|
||||
<Link to="/sceneFilenameParser">
|
||||
|
|
@ -28,7 +31,7 @@ export const SettingsToolsPanel: React.FC = () => {
|
|||
</Link>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
</>
|
||||
</SettingsToolsSection>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconDefinition, SizeProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
|
||||
interface IIcon {
|
||||
icon: IconDefinition;
|
||||
|
|
@ -9,11 +10,14 @@ interface IIcon {
|
|||
size?: SizeProp;
|
||||
}
|
||||
|
||||
export const Icon: React.FC<IIcon> = ({ icon, className, color, size }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
className={`fa-icon ${className ?? ""}`}
|
||||
color={color}
|
||||
size={size}
|
||||
/>
|
||||
export const Icon: React.FC<IIcon> = PatchComponent(
|
||||
"Icon",
|
||||
({ icon, className, color, size }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
className={`fa-icon ${className ?? ""}`}
|
||||
color={color}
|
||||
size={size}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
131
ui/v2.5/src/docs/en/Manual/UIPluginApi.md
Normal file
131
ui/v2.5/src/docs/en/Manual/UIPluginApi.md
Normal 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
199
ui/v2.5/src/pluginApi.tsx
Normal 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
7
ui/v2.5/src/plugins.tsx
Normal 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;
|
||||
Loading…
Reference in a new issue