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:
WithoutPants
2024-02-19 10:22:34 +11:00
committed by GitHub
parent 6fb1c41ae9
commit 6e9718a600
18 changed files with 260 additions and 146 deletions

View File

@@ -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">[];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
});
},
}),

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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<{}>) => {