diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 0144f18643..718482bf1f 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -963,6 +963,7 @@ module.exports = { settingsEntries: [], autoCompleteExtensions: [], sectionTitleGenerators: [], + toastGenerators: [], }, moduleImportSequence: [ diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ddccaff26b..ce069bf772 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -14,6 +14,10 @@ "a_fatal_compile_error_that_completely_blocks_compilation": "", "a_file_with_that_name_already_exists_and_will_be_overriden": "", "a_more_comprehensive_list_of_keyboard_shortcuts": "", + "a_new_reference_was_added": "", + "a_new_reference_was_added_from_provider": "", + "a_new_reference_was_added_to_file": "", + "a_new_reference_was_added_to_file_from_provider": "", "about_to_archive_projects": "", "about_to_delete_cert": "", "about_to_delete_projects": "", diff --git a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx new file mode 100644 index 0000000000..021f5c5d90 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx @@ -0,0 +1,99 @@ +import { OLToast, OLToastProps } from '@/features/ui/components/ol/ol-toast' +import useEventListener from '@/shared/hooks/use-event-listener' +import { Fragment, ReactElement, useCallback, useState } from 'react' + +import { debugConsole } from '@/utils/debugging' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' +import { OLToastContainer } from '@/features/ui/components/ol/ol-toast-container' + +const moduleGeneratorsImport = importOverleafModules('toastGenerators') as { + import: { default: GlobalToastGeneratorEntry[] } +}[] + +const moduleGenerators = moduleGeneratorsImport.map( + ({ import: { default: listEntry } }) => listEntry +) + +export type GlobalToastGeneratorEntry = { + key: string + generator: GlobalToastGenerator +} + +type GlobalToastGenerator = ( + args: Record +) => Omit + +const GENERATOR_LIST: GlobalToastGeneratorEntry[] = moduleGenerators.flat() +const GENERATOR_MAP: Map = new Map( + GENERATOR_LIST.map(({ key, generator }) => [key, generator]) +) + +let toastCounter = 1 + +export const GlobalToasts = () => { + const [toasts, setToasts] = useState< + { component: ReactElement; id: string }[] + >([]) + + const removeToast = useCallback((id: string) => { + setToasts(current => current.filter(toast => toast.id !== id)) + }, []) + + const createToast = useCallback( + (id: string, key: string, data: any): ReactElement | null => { + const generator = GENERATOR_MAP.get(key) + if (!generator) { + debugConsole.error('No toast generator found for key:', key) + return null + } + + const props = generator(data) + + if (!props.autoHide && !props.isDismissible) { + // We don't want any toasts that are not dismissible and don't auto-hide + props.isDismissible = true + } + if (props.autoHide && !props.isDismissible && props.delay !== undefined) { + // If the toast is auto-hiding but not dismissible, we need to make sure the delay is not too long + props.delay = Math.min(props.delay, 60_000) + } + + return removeToast(id)} /> + }, + [removeToast] + ) + + const addToast = useCallback( + (key: string, data?: any) => { + const id = `toast-${toastCounter++}` + const component = createToast(id, key, data) + if (!component) { + return + } + setToasts(current => [...current, { id, component }]) + }, + [createToast] + ) + + const showToastListener = useCallback( + (event: CustomEvent) => { + if (!event.detail?.key) { + debugConsole.error('No key provided for toast') + return + } + const { key, ...rest } = event.detail + addToast(key, rest) + }, + [addToast] + ) + + useEventListener('ide:show-toast', showToastListener) + + return ( + + {toasts.map(({ component, id }) => ( + {component} + ))} + + ) +} diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx index 32afd847e2..160cd1e55c 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx @@ -9,6 +9,7 @@ import { useRegisterUserActivity } from '@/features/ide-react/hooks/use-register import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error' import { Modals } from '@/features/ide-react/components/modals/modals' import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context' +import { GlobalToasts } from '../global-toasts' export default function IdePage() { useLayoutEventTracking() // sent event when the layout changes @@ -24,6 +25,7 @@ export default function IdePage() { + ) } diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/toast-container.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/toast-container.tsx new file mode 100644 index 0000000000..314de8ba77 --- /dev/null +++ b/services/web/frontend/js/features/ui/components/bootstrap-3/toast-container.tsx @@ -0,0 +1,14 @@ +import classNames from 'classnames' +import { FC, HTMLProps } from 'react' + +export const ToastContainer: FC> = ({ + children, + className, + ...props +}) => { + return ( +
+ {children} +
+ ) +} diff --git a/services/web/frontend/js/features/ui/components/bootstrap-3/toast.tsx b/services/web/frontend/js/features/ui/components/bootstrap-3/toast.tsx new file mode 100644 index 0000000000..4c35ddf4b5 --- /dev/null +++ b/services/web/frontend/js/features/ui/components/bootstrap-3/toast.tsx @@ -0,0 +1,51 @@ +import classNames from 'classnames' +import { FC, useCallback, useEffect, useRef } from 'react' + +type ToastProps = { + onClose?: () => void + onExited?: () => void + autohide?: boolean + delay?: number + show: boolean + className?: string +} +export const Toast: FC = ({ + children, + delay = 5000, + onClose, + onExited, + autohide, + show, + className, +}) => { + const delayRef = useRef(delay) + const onCloseRef = useRef(onClose) + const onExitedRef = useRef(onExited) + const shouldAutoHide = Boolean(autohide && show) + + const handleTimeout = useCallback(() => { + if (shouldAutoHide) { + onCloseRef.current?.() + onExitedRef.current?.() + } + }, [shouldAutoHide]) + + useEffect(() => { + const timeout = window.setTimeout(handleTimeout, delayRef.current) + return () => window.clearTimeout(timeout) + }, [handleTimeout]) + + if (!show) { + return null + } + + return ( +
+ {children} +
+ ) +} diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx new file mode 100644 index 0000000000..2934ce6b3b --- /dev/null +++ b/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx @@ -0,0 +1,31 @@ +import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' + +import { CSSProperties, FC } from 'react' +import { ToastContainer as BS5ToastContainer } from 'react-bootstrap-5' +import { ToastContainer as BS3ToastContainer } from '../bootstrap-3/toast-container' + +type OLToastContainerProps = { + style?: CSSProperties + className?: string +} + +export const OLToastContainer: FC = ({ + children, + className, + style, +}) => { + return ( + + {children} + + } + bs3={ + + {children} + + } + /> + ) +} diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx new file mode 100644 index 0000000000..893d959707 --- /dev/null +++ b/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx @@ -0,0 +1,106 @@ +import classNames from 'classnames' +import { Toast as BS5Toast } from 'react-bootstrap-5' +import { + NotificationIcon, + NotificationType, +} from '../../../../shared/components/notification' +import { useTranslation } from 'react-i18next' +import MaterialIcon from '../../../../shared/components/material-icon' +import { ReactNode, useCallback, useState } from 'react' +import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' +import { Toast as BS3Toast } from '../bootstrap-3/toast' + +export type OLToastProps = { + type: NotificationType + className?: string + title?: string + content: string | ReactNode + isDismissible?: boolean + onDismiss?: () => void + autoHide?: boolean + delay?: number +} + +export const OLToast = ({ + type = 'info', + className = '', + content, + title, + isDismissible, + onDismiss, + autoHide, + delay, +}: OLToastProps) => { + const { t } = useTranslation() + const [show, setShow] = useState(true) + + const toastClassName = classNames( + 'notification', + `notification-type-${type}`, + className, + 'toast-content' + ) + + const handleClose = useCallback(() => { + setShow(false) + }, []) + + const handleOnHidden = useCallback(() => { + if (onDismiss) onDismiss() + }, [onDismiss]) + + const toastElement = ( +
+ + +
+
+ {title && ( +

+ {title} +

+ )} + {content} +
+
+ + {isDismissible && ( +
+ +
+ )} +
+ ) + return ( + + {toastElement} + + } + bs3={ + + {toastElement} + + } + /> + ) +} diff --git a/services/web/frontend/js/shared/components/notification.tsx b/services/web/frontend/js/shared/components/notification.tsx index b87ab245fb..e4be7d5cab 100644 --- a/services/web/frontend/js/shared/components/notification.tsx +++ b/services/web/frontend/js/shared/components/notification.tsx @@ -27,7 +27,7 @@ export type NotificationProps = { id?: string } -function NotificationIcon({ +export function NotificationIcon({ notificationType, customIcon, }: { diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less index d9bc462420..edcc3c2ae4 100644 --- a/services/web/frontend/stylesheets/app/editor.less +++ b/services/web/frontend/stylesheets/app/editor.less @@ -7,6 +7,7 @@ @import './editor/error-boundary.less'; @import './editor/share.less'; @import './editor/chat.less'; +@import './editor/toast.less'; @import './editor/file-view.less'; @import './editor/search.less'; @import './editor/publish-template.less'; diff --git a/services/web/frontend/stylesheets/app/editor/toast.less b/services/web/frontend/stylesheets/app/editor/toast.less new file mode 100644 index 0000000000..ef876b7c82 --- /dev/null +++ b/services/web/frontend/stylesheets/app/editor/toast.less @@ -0,0 +1,22 @@ +.toast-container { + pointer-events: none; + display: flex; + flex-direction: column; + gap: 12px; + justify-content: space-between; +} + +.toast-content { + pointer-events: auto; +} + +.global-toasts { + position: fixed; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + + [role='alert'] { + z-index: 20; + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index 0d33dafcba..16eb539b61 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -16,6 +16,7 @@ @import 'editor/figure-modal'; @import 'editor/review-panel'; @import 'editor/chat'; +@import 'editor/toast'; @import 'editor/history'; @import 'subscription'; @import 'editor/pdf'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toast.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toast.scss new file mode 100644 index 0000000000..9c044d11d8 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toast.scss @@ -0,0 +1,26 @@ +.toast-container { + pointer-events: none; + display: flex; + flex-direction: column; + gap: 12px; + justify-content: space-between; +} + +.toast-content { + pointer-events: auto; +} + +.toast.showing { + opacity: 0; +} + +.global-toasts { + position: fixed; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + + [role='alert'] { + z-index: 20; + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 89bf1dfc9c..13a0ed19c9 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -16,6 +16,10 @@ "a_fatal_compile_error_that_completely_blocks_compilation": "A <0>fatal compile error that completely blocks the compilation.", "a_file_with_that_name_already_exists_and_will_be_overriden": "A file with that name already exists. That file will be overwritten.", "a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template", + "a_new_reference_was_added": "A new reference was added", + "a_new_reference_was_added_from_provider": "A new reference was added from __provider__", + "a_new_reference_was_added_to_file": "A new reference was added to <0>__filePath__", + "a_new_reference_was_added_to_file_from_provider": "A new reference was added to <0>__filePath__ from __provider__", "about": "About", "about_to_archive_projects": "You are about to archive the following projects:", "about_to_delete_cert": "You are about to delete the following certificate:",