mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +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 { sortByRelevance } from "src/utils/query";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
export type Gallery = Pick<GQL.Gallery, "id" | "title"> & {
|
||||
files: Pick<GQL.GalleryFile, "path">[];
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
faVideo,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { baseURL } from "src/core/createClient";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IMenuItem {
|
||||
name: string;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { useCompare } from "src/hooks/state";
|
||||
import { Link } from "react-router-dom";
|
||||
import { sortByRelevance } from "src/utils/query";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
export type SelectObject = {
|
||||
id: string;
|
||||
|
||||
@@ -31,7 +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";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
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 { Icon } from "../Shared/Icon";
|
||||
import { StringListInput } from "../Shared/StringListInput";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { useSettings, useSettingsOptional } from "./context";
|
||||
|
||||
interface ISetting {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Setting } from "./Inputs";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { PatchContainerComponent } from "src/pluginApi";
|
||||
import { PatchContainerComponent } from "src/patch";
|
||||
|
||||
const SettingsToolsSection = PatchContainerComponent("SettingsToolsSection");
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import Creatable from "react-select/creatable";
|
||||
import { useIntl } from "react-intl";
|
||||
import { getCountries } from "src/utils/country";
|
||||
import { CountryLabel } from "./CountryLabel";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IProps {
|
||||
value?: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Icon } from "./Icon";
|
||||
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { useIntl } from "react-intl";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IProps {
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { useDirectoryPaths } from "./useDirectoryPaths";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IProps {
|
||||
currentDirectory: string;
|
||||
|
||||
@@ -1,7 +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";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IIcon {
|
||||
icon: IconDefinition;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { useCompare } from "src/hooks/state";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
import { sortByRelevance } from "src/utils/query";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
export type SelectObject = {
|
||||
id: string;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useCompare } from "src/hooks/state";
|
||||
import { TagPopover } from "./TagPopover";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
import { sortByRelevance } from "src/utils/query";
|
||||
import { PatchComponent } from "src/pluginApi";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
export type SelectObject = {
|
||||
id: string;
|
||||
|
||||
@@ -1,57 +1,119 @@
|
||||
import React, {
|
||||
useState,
|
||||
useContext,
|
||||
createContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Toast } from "react-bootstrap";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faTriangleExclamation,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useState, useContext, createContext, useMemo } from "react";
|
||||
import { Button, Toast } from "react-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import { errorToString } from "src/utils";
|
||||
import cx from "classnames";
|
||||
|
||||
export interface IToast {
|
||||
header?: string;
|
||||
content: React.ReactNode | string;
|
||||
content: JSX.Element | string;
|
||||
delay?: number;
|
||||
variant?: "success" | "danger" | "warning";
|
||||
priority?: number; // higher is more important
|
||||
}
|
||||
|
||||
interface IActiveToast extends IToast {
|
||||
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;
|
||||
|
||||
const ToastContext = createContext<(item: IToast) => void>(() => {});
|
||||
|
||||
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) =>
|
||||
setToasts((prev) => prev.filter((item) => item.id !== id));
|
||||
function expand() {
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
const toastItems = toasts.map((toast) => (
|
||||
<Toast
|
||||
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 toastItem = useMemo(() => {
|
||||
if (!toast || expanded) return null;
|
||||
|
||||
const addToast = useCallback((toast: IToast) => {
|
||||
setToasts((prev) => [...prev, { ...toast, id: toastID++ }]);
|
||||
}, []);
|
||||
return (
|
||||
<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 (
|
||||
<ToastContext.Provider value={addToast}>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -62,7 +124,7 @@ export const useToast = () => {
|
||||
return useMemo(
|
||||
() => ({
|
||||
toast: addToast,
|
||||
success(message: React.ReactNode | string) {
|
||||
success(message: JSX.Element | string) {
|
||||
addToast({
|
||||
content: message,
|
||||
});
|
||||
@@ -73,8 +135,9 @@ export const useToast = () => {
|
||||
console.error(error);
|
||||
addToast({
|
||||
variant: "danger",
|
||||
header: "Error",
|
||||
content: message,
|
||||
priority: errorPriority,
|
||||
delay: errorDelay,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -854,12 +854,18 @@ div.dropdown-menu {
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
left: 45%;
|
||||
max-width: 350px;
|
||||
opacity: 0.9;
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
top: 4rem;
|
||||
transition: right 0.5s;
|
||||
z-index: 1051;
|
||||
|
||||
&.hidden {
|
||||
right: -350px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: $success;
|
||||
}
|
||||
@@ -880,15 +886,56 @@ div.dropdown-menu {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: $text-color;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
|
||||
.close {
|
||||
.close,
|
||||
.expand-error-button {
|
||||
color: $text-color;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.expand-error-button {
|
||||
opacity: 0.5;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
white-space: pre-wrap;
|
||||
.toast.danger .toast-header > span {
|
||||
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",
|
||||
"confirm": "Confirm",
|
||||
"continue": "Continue",
|
||||
"copy_to_clipboard": "Copy to clipboard",
|
||||
"create": "Create",
|
||||
"create_chapters": "Create Chapter",
|
||||
"create_entity": "Create {entityType}",
|
||||
@@ -974,6 +975,7 @@
|
||||
},
|
||||
"empty_server": "Add some scenes to your server to view recommendations on this page.",
|
||||
"errors": {
|
||||
"header": "Error",
|
||||
"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.",
|
||||
"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 MousetrapPause from "mousetrap-pause";
|
||||
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";
|
||||
@@ -16,6 +13,7 @@ import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons";
|
||||
import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons";
|
||||
import { useSpriteInfo } from "./hooks/sprite";
|
||||
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
|
||||
// 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.
|
||||
// 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 [componentList] = React.useState(components);
|
||||
const [componentList] = React.useState(toLoad);
|
||||
|
||||
async function load(c: (() => Promise<unknown>)[]) {
|
||||
await loadComponents(c);
|
||||
@@ -44,40 +42,6 @@ function useLoadComponents(components: (() => Promise<unknown>)[]) {
|
||||
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 [
|
||||
@@ -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 = {
|
||||
React,
|
||||
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;
|
||||
|
||||
interface IWindow {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { PatchFunction } from "./pluginApi";
|
||||
import { PatchFunction } from "./patch";
|
||||
|
||||
export const PluginRoutes: React.FC<React.PropsWithChildren<{}>> =
|
||||
PatchFunction("PluginRoutes", (props: React.PropsWithChildren<{}>) => {
|
||||
|
||||
Reference in New Issue
Block a user