mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Toast improvements (#4584)
* Change default toast placement * Position at bottom on mobile * Show single toast message at a time * Optionally show dialog for error messages * Fix circular dependency * Animate toast
This commit is contained in:
@@ -27,7 +27,7 @@ import { useCompare } from "src/hooks/state";
|
|||||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||||
import { sortByRelevance } from "src/utils/query";
|
import { sortByRelevance } from "src/utils/query";
|
||||||
import { galleryTitle } from "src/core/galleries";
|
import { galleryTitle } from "src/core/galleries";
|
||||||
import { PatchComponent } from "src/pluginApi";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
export type Gallery = Pick<GQL.Gallery, "id" | "title"> & {
|
export type Gallery = Pick<GQL.Gallery, "id" | "title"> & {
|
||||||
files: Pick<GQL.GalleryFile, "path">[];
|
files: Pick<GQL.GalleryFile, "path">[];
|
||||||
|
|||||||
@@ -33,7 +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";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
interface IMenuItem {
|
interface IMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
import { useCompare } from "src/hooks/state";
|
import { useCompare } from "src/hooks/state";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { sortByRelevance } from "src/utils/query";
|
import { sortByRelevance } from "src/utils/query";
|
||||||
import { PatchComponent } from "src/pluginApi";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
export type SelectObject = {
|
export type SelectObject = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -31,7 +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";
|
import { PatchComponent } from "src/patch";
|
||||||
import ScreenUtils from "src/utils/screen";
|
import ScreenUtils from "src/utils/screen";
|
||||||
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
|
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +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";
|
import { PatchComponent } from "src/patch";
|
||||||
import { useSettings, useSettingsOptional } from "./context";
|
import { useSettings, useSettingsOptional } from "./context";
|
||||||
|
|
||||||
interface ISetting {
|
interface ISetting {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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";
|
import { PatchContainerComponent } from "src/patch";
|
||||||
|
|
||||||
const SettingsToolsSection = PatchContainerComponent("SettingsToolsSection");
|
const SettingsToolsSection = PatchContainerComponent("SettingsToolsSection");
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Creatable from "react-select/creatable";
|
|||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { getCountries } from "src/utils/country";
|
import { getCountries } from "src/utils/country";
|
||||||
import { CountryLabel } from "./CountryLabel";
|
import { CountryLabel } from "./CountryLabel";
|
||||||
import { PatchComponent } from "src/pluginApi";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Icon } from "./Icon";
|
|||||||
|
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { PatchComponent } from "src/pluginApi";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons";
|
|||||||
import { useDebounce } from "src/hooks/debounce";
|
import { useDebounce } from "src/hooks/debounce";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import { useDirectoryPaths } from "./useDirectoryPaths";
|
import { useDirectoryPaths } from "./useDirectoryPaths";
|
||||||
import { PatchComponent } from "src/pluginApi";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
currentDirectory: string;
|
currentDirectory: string;
|
||||||
|
|||||||
@@ -1,7 +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";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
interface IIcon {
|
interface IIcon {
|
||||||
icon: IconDefinition;
|
icon: IconDefinition;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
import { useCompare } from "src/hooks/state";
|
import { useCompare } from "src/hooks/state";
|
||||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||||
import { sortByRelevance } from "src/utils/query";
|
import { sortByRelevance } from "src/utils/query";
|
||||||
import { PatchComponent } from "src/pluginApi";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
export type SelectObject = {
|
export type SelectObject = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useCompare } from "src/hooks/state";
|
|||||||
import { TagPopover } from "./TagPopover";
|
import { TagPopover } from "./TagPopover";
|
||||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||||
import { sortByRelevance } from "src/utils/query";
|
import { sortByRelevance } from "src/utils/query";
|
||||||
import { PatchComponent } from "src/pluginApi";
|
import { PatchComponent } from "src/patch";
|
||||||
|
|
||||||
export type SelectObject = {
|
export type SelectObject = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,57 +1,119 @@
|
|||||||
import React, {
|
import {
|
||||||
useState,
|
faArrowUpRightFromSquare,
|
||||||
useContext,
|
faTriangleExclamation,
|
||||||
createContext,
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
useCallback,
|
import React, { useState, useContext, createContext, useMemo } from "react";
|
||||||
useMemo,
|
import { Button, Toast } from "react-bootstrap";
|
||||||
} from "react";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { Toast } from "react-bootstrap";
|
import { Icon } from "src/components/Shared/Icon";
|
||||||
|
import { ModalComponent } from "src/components/Shared/Modal";
|
||||||
import { errorToString } from "src/utils";
|
import { errorToString } from "src/utils";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
export interface IToast {
|
export interface IToast {
|
||||||
header?: string;
|
content: JSX.Element | string;
|
||||||
content: React.ReactNode | string;
|
|
||||||
delay?: number;
|
delay?: number;
|
||||||
variant?: "success" | "danger" | "warning";
|
variant?: "success" | "danger" | "warning";
|
||||||
|
priority?: number; // higher is more important
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IActiveToast extends IToast {
|
interface IActiveToast extends IToast {
|
||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errors are always more important than regular toasts
|
||||||
|
const errorPriority = 100;
|
||||||
|
// errors should stay on screen longer
|
||||||
|
const errorDelay = 5000;
|
||||||
|
|
||||||
let toastID = 0;
|
let toastID = 0;
|
||||||
|
|
||||||
const ToastContext = createContext<(item: IToast) => void>(() => {});
|
const ToastContext = createContext<(item: IToast) => void>(() => {});
|
||||||
|
|
||||||
export const ToastProvider: React.FC = ({ children }) => {
|
export const ToastProvider: React.FC = ({ children }) => {
|
||||||
const [toasts, setToasts] = useState<IActiveToast[]>([]);
|
const [toast, setToast] = useState<IActiveToast>();
|
||||||
|
const [hiding, setHiding] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const removeToast = (id: number) =>
|
function expand() {
|
||||||
setToasts((prev) => prev.filter((item) => item.id !== id));
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
|
||||||
const toastItems = toasts.map((toast) => (
|
const toastItem = useMemo(() => {
|
||||||
<Toast
|
if (!toast || expanded) return null;
|
||||||
autohide
|
|
||||||
key={toast.id}
|
|
||||||
onClose={() => removeToast(toast.id)}
|
|
||||||
className={toast.variant ?? "success"}
|
|
||||||
delay={toast.delay ?? 3000}
|
|
||||||
>
|
|
||||||
<Toast.Header>
|
|
||||||
<span className="mr-auto">{toast.header}</span>
|
|
||||||
</Toast.Header>
|
|
||||||
<Toast.Body>{toast.content}</Toast.Body>
|
|
||||||
</Toast>
|
|
||||||
));
|
|
||||||
|
|
||||||
const addToast = useCallback((toast: IToast) => {
|
return (
|
||||||
setToasts((prev) => [...prev, { ...toast, id: toastID++ }]);
|
<Toast
|
||||||
}, []);
|
autohide
|
||||||
|
key={toast.id}
|
||||||
|
onClose={() => setHiding(true)}
|
||||||
|
className={toast.variant ?? "success"}
|
||||||
|
delay={toast.delay ?? 3000}
|
||||||
|
>
|
||||||
|
<Toast.Header>
|
||||||
|
<span className="mr-auto" onClick={() => expand()}>
|
||||||
|
{toast.content}
|
||||||
|
</span>
|
||||||
|
{toast.variant === "danger" && (
|
||||||
|
<Button
|
||||||
|
variant="minimal"
|
||||||
|
className="expand-error-button"
|
||||||
|
onClick={() => expand()}
|
||||||
|
>
|
||||||
|
<Icon icon={faArrowUpRightFromSquare} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Toast.Header>
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
}, [toast, expanded]);
|
||||||
|
|
||||||
|
function addToast(item: IToast) {
|
||||||
|
if (hiding || !toast || (item.priority ?? 0) >= (toast.priority ?? 0)) {
|
||||||
|
setHiding(false);
|
||||||
|
setToast({ ...item, id: toastID++ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard() {
|
||||||
|
const { content } = toast ?? {};
|
||||||
|
|
||||||
|
if (!!content && typeof content === "string" && navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={addToast}>
|
<ToastContext.Provider value={addToast}>
|
||||||
{children}
|
{children}
|
||||||
<div className="toast-container row">{toastItems}</div>
|
{expanded && (
|
||||||
|
<ModalComponent
|
||||||
|
dialogClassName="toast-expanded-dialog"
|
||||||
|
show={expanded}
|
||||||
|
accept={{
|
||||||
|
onClick: () => {
|
||||||
|
setToast(undefined);
|
||||||
|
setExpanded(false);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
header={<FormattedMessage id="errors.header" />}
|
||||||
|
icon={faTriangleExclamation}
|
||||||
|
footerButtons={
|
||||||
|
<>
|
||||||
|
{!!navigator.clipboard && (
|
||||||
|
<Button variant="secondary" onClick={() => copyToClipboard()}>
|
||||||
|
<FormattedMessage id="actions.copy_to_clipboard" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toast?.content}
|
||||||
|
</ModalComponent>
|
||||||
|
)}
|
||||||
|
<div className={cx("toast-container row", { hidden: !toast || hiding })}>
|
||||||
|
{toastItem}
|
||||||
|
</div>
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -62,7 +124,7 @@ export const useToast = () => {
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
toast: addToast,
|
toast: addToast,
|
||||||
success(message: React.ReactNode | string) {
|
success(message: JSX.Element | string) {
|
||||||
addToast({
|
addToast({
|
||||||
content: message,
|
content: message,
|
||||||
});
|
});
|
||||||
@@ -73,8 +135,9 @@ export const useToast = () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
addToast({
|
addToast({
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
header: "Error",
|
|
||||||
content: message,
|
content: message,
|
||||||
|
priority: errorPriority,
|
||||||
|
delay: errorDelay,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -854,12 +854,18 @@ div.dropdown-menu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
left: 45%;
|
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
|
opacity: 0.9;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 2rem;
|
right: 2rem;
|
||||||
|
top: 4rem;
|
||||||
|
transition: right 0.5s;
|
||||||
z-index: 1051;
|
z-index: 1051;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
right: -350px;
|
||||||
|
}
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
background-color: $success;
|
background-color: $success;
|
||||||
}
|
}
|
||||||
@@ -880,15 +886,56 @@ div.dropdown-menu {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
|
padding: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
.close {
|
.close,
|
||||||
|
.expand-error-button {
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.expand-error-button {
|
||||||
|
opacity: 0.5;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-body {
|
.toast.danger .toast-header > span {
|
||||||
white-space: pre-wrap;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-expanded-dialog {
|
||||||
|
.modal-header {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
.fa-icon {
|
||||||
|
color: $danger;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
.toast-container {
|
||||||
|
bottom: 4rem;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -175px;
|
||||||
|
right: unset;
|
||||||
|
top: unset;
|
||||||
|
|
||||||
|
transition: left 0.5s;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
left: -350px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
"copy_to_clipboard": "Copy to clipboard",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"create_chapters": "Create Chapter",
|
"create_chapters": "Create Chapter",
|
||||||
"create_entity": "Create {entityType}",
|
"create_entity": "Create {entityType}",
|
||||||
@@ -974,6 +975,7 @@
|
|||||||
},
|
},
|
||||||
"empty_server": "Add some scenes to your server to view recommendations on this page.",
|
"empty_server": "Add some scenes to your server to view recommendations on this page.",
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"header": "Error",
|
||||||
"image_index_greater_than_zero": "Image index must be greater than 0",
|
"image_index_greater_than_zero": "Image index must be greater than 0",
|
||||||
"lazy_component_error_help": "If you recently upgraded Stash, please reload the page or clear your browser cache.",
|
"lazy_component_error_help": "If you recently upgraded Stash, please reload the page or clear your browser cache.",
|
||||||
"loading_type": "Error loading {type}",
|
"loading_type": "Error loading {type}",
|
||||||
|
|||||||
94
ui/v2.5/src/patch.tsx
Normal file
94
ui/v2.5/src/patch.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { HoverPopover } from "./components/Shared/HoverPopover";
|
||||||
|
import { TagLink } from "./components/Shared/TagLink";
|
||||||
|
import { LoadingIndicator } from "./components/Shared/LoadingIndicator";
|
||||||
|
|
||||||
|
export 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.
|
||||||
|
export function before(component: string, fn: Function) {
|
||||||
|
if (!beforeFns[component]) {
|
||||||
|
beforeFns[component] = [];
|
||||||
|
}
|
||||||
|
beforeFns[component].push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function instead(component: string, fn: Function) {
|
||||||
|
if (insteadFns[component]) {
|
||||||
|
throw new Error("instead has already been called for " + component);
|
||||||
|
}
|
||||||
|
insteadFns[component] = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function after(component: string, fn: Function) {
|
||||||
|
if (!afterFns[component]) {
|
||||||
|
afterFns[component] = [];
|
||||||
|
}
|
||||||
|
afterFns[component].push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterComponent(component: string, fn: Function) {
|
||||||
|
// register with the plugin api
|
||||||
|
if (components[component]) {
|
||||||
|
throw new Error("Component " + component + " has already been registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
components[component] = fn;
|
||||||
|
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@ import * as ReactRouterDOM from "react-router-dom";
|
|||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import MousetrapPause from "mousetrap-pause";
|
import MousetrapPause from "mousetrap-pause";
|
||||||
import NavUtils from "./utils/navigation";
|
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 GQL from "src/core/generated-graphql";
|
||||||
import * as StashService from "src/core/StashService";
|
import * as StashService from "src/core/StashService";
|
||||||
import * as Apollo from "@apollo/client";
|
import * as Apollo from "@apollo/client";
|
||||||
@@ -16,6 +13,7 @@ import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons";
|
|||||||
import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons";
|
import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons";
|
||||||
import { useSpriteInfo } from "./hooks/sprite";
|
import { useSpriteInfo } from "./hooks/sprite";
|
||||||
import { useToast } from "./hooks/Toast";
|
import { useToast } from "./hooks/Toast";
|
||||||
|
import { before, instead, after, components, RegisterComponent } from "./patch";
|
||||||
|
|
||||||
// due to code splitting, some components may not have been loaded when a plugin
|
// 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.
|
// page is loaded. This function will load all components passed to it.
|
||||||
@@ -27,9 +25,9 @@ async function loadComponents(c: (() => Promise<unknown>)[]) {
|
|||||||
|
|
||||||
// useLoadComponents is a hook that loads all components passed to it.
|
// useLoadComponents is a hook that loads all components passed to it.
|
||||||
// It returns a boolean indicating whether the components are still loading.
|
// It returns a boolean indicating whether the components are still loading.
|
||||||
function useLoadComponents(components: (() => Promise<unknown>)[]) {
|
function useLoadComponents(toLoad: (() => Promise<unknown>)[]) {
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [componentList] = React.useState(components);
|
const [componentList] = React.useState(toLoad);
|
||||||
|
|
||||||
async function load(c: (() => Promise<unknown>)[]) {
|
async function load(c: (() => Promise<unknown>)[]) {
|
||||||
await loadComponents(c);
|
await loadComponents(c);
|
||||||
@@ -44,40 +42,6 @@ function useLoadComponents(components: (() => Promise<unknown>)[]) {
|
|||||||
return loading;
|
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) {
|
function registerRoute(path: string, component: React.FC) {
|
||||||
before("PluginRoutes", function (props: React.PropsWithChildren<{}>) {
|
before("PluginRoutes", function (props: React.PropsWithChildren<{}>) {
|
||||||
return [
|
return [
|
||||||
@@ -93,17 +57,6 @@ function registerRoute(path: string, component: React.FC) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterComponent(component: string, fn: Function) {
|
|
||||||
// register with the plugin api
|
|
||||||
if (components[component]) {
|
|
||||||
throw new Error("Component " + component + " has already been registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
components[component] = fn;
|
|
||||||
|
|
||||||
return fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PluginApi = {
|
export const PluginApi = {
|
||||||
React,
|
React,
|
||||||
ReactDOM,
|
ReactDOM,
|
||||||
@@ -155,51 +108,6 @@ export const PluginApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
export default PluginApi;
|
||||||
|
|
||||||
interface IWindow {
|
interface IWindow {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { PatchFunction } from "./pluginApi";
|
import { PatchFunction } from "./patch";
|
||||||
|
|
||||||
export const PluginRoutes: React.FC<React.PropsWithChildren<{}>> =
|
export const PluginRoutes: React.FC<React.PropsWithChildren<{}>> =
|
||||||
PatchFunction("PluginRoutes", (props: React.PropsWithChildren<{}>) => {
|
PatchFunction("PluginRoutes", (props: React.PropsWithChildren<{}>) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user