diff --git a/ui/v2.5/src/components/Galleries/GallerySelect.tsx b/ui/v2.5/src/components/Galleries/GallerySelect.tsx index 627daab3c..6fbd4ec62 100644 --- a/ui/v2.5/src/components/Galleries/GallerySelect.tsx +++ b/ui/v2.5/src/components/Galleries/GallerySelect.tsx @@ -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 & { files: Pick[]; diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index b6f7a123b..89e3563e8 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -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; diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index af3d611d4..f733769ce 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -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; diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 37c814bbe..b648ec437 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -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"; diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index 32a2a3cd2..fc23782e7 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -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 { diff --git a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx index 7b027830f..8168ffb99 100644 --- a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx @@ -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"); diff --git a/ui/v2.5/src/components/Shared/CountrySelect.tsx b/ui/v2.5/src/components/Shared/CountrySelect.tsx index 1f1bdb4f2..af1d9c867 100644 --- a/ui/v2.5/src/components/Shared/CountrySelect.tsx +++ b/ui/v2.5/src/components/Shared/CountrySelect.tsx @@ -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; diff --git a/ui/v2.5/src/components/Shared/DateInput.tsx b/ui/v2.5/src/components/Shared/DateInput.tsx index b63f0e7b6..925f98c49 100644 --- a/ui/v2.5/src/components/Shared/DateInput.tsx +++ b/ui/v2.5/src/components/Shared/DateInput.tsx @@ -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; diff --git a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx index ba7279c4d..ac9d293a9 100644 --- a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx +++ b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx @@ -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; diff --git a/ui/v2.5/src/components/Shared/Icon.tsx b/ui/v2.5/src/components/Shared/Icon.tsx index da7880dfb..adbc3dcfd 100644 --- a/ui/v2.5/src/components/Shared/Icon.tsx +++ b/ui/v2.5/src/components/Shared/Icon.tsx @@ -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; diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index 81603b08b..39ebccc70 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -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; diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index c19f75e71..82d30c54d 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -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; diff --git a/ui/v2.5/src/hooks/Toast.tsx b/ui/v2.5/src/hooks/Toast.tsx index 46e4c608f..ef5c049e8 100644 --- a/ui/v2.5/src/hooks/Toast.tsx +++ b/ui/v2.5/src/hooks/Toast.tsx @@ -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([]); + const [toast, setToast] = useState(); + 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) => ( - removeToast(toast.id)} - className={toast.variant ?? "success"} - delay={toast.delay ?? 3000} - > - - {toast.header} - - {toast.content} - - )); + const toastItem = useMemo(() => { + if (!toast || expanded) return null; - const addToast = useCallback((toast: IToast) => { - setToasts((prev) => [...prev, { ...toast, id: toastID++ }]); - }, []); + return ( + setHiding(true)} + className={toast.variant ?? "success"} + delay={toast.delay ?? 3000} + > + + expand()}> + {toast.content} + + {toast.variant === "danger" && ( + + )} + + + ); + }, [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 ( {children} -
{toastItems}
+ {expanded && ( + { + setToast(undefined); + setExpanded(false); + }, + }} + header={} + icon={faTriangleExclamation} + footerButtons={ + <> + {!!navigator.clipboard && ( + + )} + + } + > + {toast?.content} + + )} +
+ {toastItem} +
); }; @@ -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, }); }, }), diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 0aff6234d..9f49d76a4 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -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; + } } } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b58d3d703..8310c474f 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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}", diff --git a/ui/v2.5/src/patch.tsx b/ui/v2.5/src/patch.tsx new file mode 100644 index 000000000..4b35e79a4 --- /dev/null +++ b/ui/v2.5/src/patch.tsx @@ -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 = { + HoverPopover, + TagLink, + LoadingIndicator, +}; + +const beforeFns: Record = {}; +const insteadFns: Record = {}; +const afterFns: Record = {}; + +// 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( + component: string, + fn: React.FC +): React.FC { + const ret = PatchFunction(component, fn); + + // register with the plugin api + RegisterComponent(component, ret); + return ret as React.FC; +} + +// patches a component and registers it in the pluginapi components object +export function PatchContainerComponent( + component: string +): React.FC> { + const fn = (props: React.PropsWithChildren<{}>) => { + return <>{props.children}; + }; + + return PatchComponent(component, fn); +} diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index 111281169..0c66f9b09 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -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)[]) { // 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)[]) { +function useLoadComponents(toLoad: (() => Promise)[]) { const [loading, setLoading] = React.useState(true); - const [componentList] = React.useState(components); + const [componentList] = React.useState(toLoad); async function load(c: (() => Promise)[]) { await loadComponents(c); @@ -44,40 +42,6 @@ function useLoadComponents(components: (() => Promise)[]) { return loading; } -const components: Record = { - HoverPopover, - TagLink, - LoadingIndicator, -}; - -const beforeFns: Record = {}; -const insteadFns: Record = {}; -const afterFns: Record = {}; - -// 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( - component: string, - fn: React.FC -): React.FC { - const ret = PatchFunction(component, fn); - - // register with the plugin api - RegisterComponent(component, ret); - return ret as React.FC; -} - -// patches a component and registers it in the pluginapi components object -export function PatchContainerComponent( - component: string -): React.FC> { - const fn = (props: React.PropsWithChildren<{}>) => { - return <>{props.children}; - }; - - return PatchComponent(component, fn); -} - export default PluginApi; interface IWindow { diff --git a/ui/v2.5/src/plugins.tsx b/ui/v2.5/src/plugins.tsx index 1baa9656d..8289a9e8e 100644 --- a/ui/v2.5/src/plugins.tsx +++ b/ui/v2.5/src/plugins.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { PatchFunction } from "./pluginApi"; +import { PatchFunction } from "./patch"; export const PluginRoutes: React.FC> = PatchFunction("PluginRoutes", (props: React.PropsWithChildren<{}>) => {