diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index e23692b86e..49e9739ea1 100644 --- a/web/apps/accounts/src/pages/_app.tsx +++ b/web/apps/accounts/src/pages/_app.tsx @@ -1,11 +1,9 @@ import { staticAppTitle } from "@/base/app"; import { CustomHead } from "@/base/components/Head"; -import { - type MiniDialogAttributes, - MiniDialog, -} from "@/base/components/MiniDialog"; +import { AttributedMiniDialog } from "@/base/components/MiniDialog"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; import { AppNavbar } from "@/base/components/Navbar"; +import { useAttributedMiniDialog } from "@/base/components/utils/mini-dialog"; import { setupI18n } from "@/base/i18n"; import { disableDiskLogs } from "@/base/log"; import { logUnhandledErrorsAndRejections } from "@/base/log-web"; @@ -16,7 +14,7 @@ import { CssBaseline } from "@mui/material"; import { ThemeProvider } from "@mui/material/styles"; import { t } from "i18next"; import type { AppProps } from "next/app"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { AppContext } from "../types/context"; import "styles/global.css"; @@ -24,10 +22,7 @@ import "styles/global.css"; const App: React.FC = ({ Component, pageProps }) => { const [isI18nReady, setIsI18nReady] = useState(false); const [showNavbar, setShowNavbar] = useState(false); - const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState< - MiniDialogAttributes | undefined - >(); - const [dialogBoxV2View, setDialogBoxV2View] = useState(false); + const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog(); useEffect(() => { disableDiskLogs(); @@ -36,16 +31,13 @@ const App: React.FC = ({ Component, pageProps }) => { return () => logUnhandledErrorsAndRejections(false); }, []); - useEffect(() => { - setDialogBoxV2View(true); - }, [dialogBoxAttributeV2]); - - const closeDialogBoxV2 = () => setDialogBoxV2View(false); - - const appContext = { - showNavBar: setShowNavbar, - setDialogBoxAttributesV2, - }; + const appContext = useMemo( + () => ({ + showNavBar: setShowNavbar, + showMiniDialog, + }), + [showMiniDialog], + ); const title = isI18nReady ? t("title_accounts") : staticAppTitle; @@ -55,12 +47,7 @@ const App: React.FC = ({ Component, pageProps }) => { - + {!isI18nReady && ( diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index 56d09d0fbd..88bf77b80b 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -1,6 +1,7 @@ import { EnteDrawer } from "@/base/components/EnteDrawer"; import { MenuItemDivider, MenuItemGroup } from "@/base/components/Menu"; import { Titlebar } from "@/base/components/Titlebar"; +import { errorDialogAttributes } from "@/base/components/utils/mini-dialog"; import log from "@/base/log"; import { ensure } from "@/utils/ensure"; import { CenteredFlex } from "@ente/shared/components/Container"; @@ -34,7 +35,7 @@ import { import { useAppContext } from "../../types/context"; const Page: React.FC = () => { - const { showNavBar, setDialogBoxAttributesV2 } = useAppContext(); + const { showNavBar, showMiniDialog } = useAppContext(); const [token, setToken] = useState(); const [passkeys, setPasskeys] = useState([]); @@ -44,12 +45,8 @@ const Page: React.FC = () => { >(); const showPasskeyFetchFailedErrorDialog = useCallback(() => { - setDialogBoxAttributesV2({ - title: t("error"), - content: t("passkey_fetch_failed"), - close: {}, - }); - }, [setDialogBoxAttributesV2]); + showMiniDialog(errorDialogAttributes(t("passkey_fetch_failed"))); + }, [showMiniDialog]); useEffect(() => { showNavBar(true); @@ -263,34 +260,26 @@ const ManagePasskeyDrawer: React.FC = ({ passkey, onUpdateOrDeletePasskey, }) => { - const { setDialogBoxAttributesV2 } = useAppContext(); + const { showMiniDialog } = useAppContext(); const [showRenameDialog, setShowRenameDialog] = useState(false); - const showDeleteConfirmationDialog = useCallback(() => { - const handleDelete = async (setLoading: (value: boolean) => void) => { - setLoading(true); - try { - await deletePasskey(ensure(token), ensure(passkey).id); - onUpdateOrDeletePasskey(); - } catch (e) { - log.error("Failed to delete passkey", e); - } finally { - setLoading(false); - } - }; - - setDialogBoxAttributesV2({ - title: t("delete_passkey"), - content: t("delete_passkey_confirmation"), - proceed: { - text: t("delete"), - action: handleDelete, - variant: "critical", - }, - close: { text: t("cancel") }, - }); - }, [token, passkey, onUpdateOrDeletePasskey, setDialogBoxAttributesV2]); + const showDeleteConfirmationDialog = useCallback( + () => + showMiniDialog({ + title: t("delete_passkey"), + message: t("delete_passkey_confirmation"), + continue: { + text: t("delete"), + color: "critical", + action: async () => { + await deletePasskey(ensure(token), ensure(passkey).id); + onUpdateOrDeletePasskey(); + }, + }, + }), + [showMiniDialog, token, passkey, onUpdateOrDeletePasskey], + ); return ( <> diff --git a/web/apps/accounts/src/types/context.ts b/web/apps/accounts/src/types/context.ts index e095490524..dece58358d 100644 --- a/web/apps/accounts/src/types/context.ts +++ b/web/apps/accounts/src/types/context.ts @@ -1,15 +1,11 @@ -import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; +import type { AccountsContextT } from "@/accounts/types/context"; import { ensure } from "@/utils/ensure"; import { createContext, useContext } from "react"; /** * The type of the context for pages in the accounts app. */ -interface AppContextT { - /** Show or hide the app's navigation bar. */ - showNavBar: (show: boolean) => void; - setDialogBoxAttributesV2: (attrs: MiniDialogAttributes) => void; -} +type AppContextT = Omit; /** * The React {@link Context} available to all nodes in the React tree. diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index fc7ec04577..e18b0684ff 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -2,12 +2,13 @@ import { accountLogout } from "@/accounts/services/logout"; import type { AccountsContextT } from "@/accounts/types/context"; import { clientPackageName, staticAppTitle } from "@/base/app"; import { CustomHead } from "@/base/components/Head"; -import { - type MiniDialogAttributes, - MiniDialog, -} from "@/base/components/MiniDialog"; +import { AttributedMiniDialog } from "@/base/components/MiniDialog"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; import { AppNavbar } from "@/base/components/Navbar"; +import { + genericErrorDialogAttributes, + useAttributedMiniDialog, +} from "@/base/components/utils/mini-dialog"; import { setupI18n } from "@/base/i18n"; import { logStartupBanner, @@ -39,6 +40,7 @@ import React, { useState, } from "react"; import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar"; + import "../../public/css/global.css"; /** @@ -68,10 +70,8 @@ const App: React.FC = ({ Component, pageProps }) => { const [showNavbar, setShowNavBar] = useState(false); const isLoadingBarRunning = useRef(false); const loadingBar = useRef(null); - const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState< - MiniDialogAttributes | undefined - >(); - const [dialogBoxV2View, setDialogBoxV2View] = useState(false); + + const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog(); const [themeColor, setThemeColor] = useLocalState( LS_KEYS.THEME, THEME_COLOR.DARK, @@ -111,16 +111,13 @@ const App: React.FC = ({ Component, pageProps }) => { }; }, []); - useEffect(() => { - setDialogBoxV2View(true); - }, [dialogBoxAttributeV2]); - const showNavBar = (show: boolean) => setShowNavBar(show); const startLoading = () => { !isLoadingBarRunning.current && loadingBar.current?.continuousStart(); isLoadingBarRunning.current = true; }; + const finishLoading = () => { setTimeout(() => { isLoadingBarRunning.current && loadingBar.current?.complete(); @@ -128,14 +125,8 @@ const App: React.FC = ({ Component, pageProps }) => { }, 100); }; - const closeDialogBoxV2 = () => setDialogBoxV2View(false); - const somethingWentWrong = () => - setDialogBoxAttributesV2({ - title: t("error"), - close: { variant: "critical" }, - content: t("generic_error_retry"), - }); + showMiniDialog(genericErrorDialogAttributes()); const logout = () => { void accountLogout().then(() => router.push("/")); @@ -144,7 +135,7 @@ const App: React.FC = ({ Component, pageProps }) => { const appContext = { logout, showNavBar, - setDialogBoxAttributesV2, + showMiniDialog, startLoading, finishLoading, themeColor, @@ -167,12 +158,7 @@ const App: React.FC = ({ Component, pageProps }) => { - + {(loading || !isI18nReady) && ( diff --git a/web/apps/auth/src/pages/auth.tsx b/web/apps/auth/src/pages/auth.tsx index 917808bd83..6fb996cf3f 100644 --- a/web/apps/auth/src/pages/auth.tsx +++ b/web/apps/auth/src/pages/auth.tsx @@ -1,3 +1,4 @@ +import { sessionExpiredDialogAttributes } from "@/accounts/components/LoginComponents"; import { stashRedirect } from "@/accounts/services/redirect"; import { EnteLogo } from "@/base/components/EnteLogo"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; @@ -7,7 +8,6 @@ import { HorizontalFlex, VerticallyCentered, } from "@ente/shared/components/Container"; -import { sessionExpiredDialogAttributes } from "@ente/shared/components/LoginComponents"; import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option"; import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages"; @@ -23,7 +23,7 @@ import { getAuthCodes } from "services/remote"; import { AppContext } from "./_app"; const Page: React.FC = () => { - const { logout, showNavBar, setDialogBoxAttributesV2 } = ensure( + const { logout, showNavBar, showMiniDialog } = ensure( useContext(AppContext), ); const router = useRouter(); @@ -32,7 +32,7 @@ const Page: React.FC = () => { const [searchTerm, setSearchTerm] = useState(""); const showSessionExpiredDialog = () => - setDialogBoxAttributesV2(sessionExpiredDialogAttributes(logout)); + showMiniDialog(sessionExpiredDialogAttributes(logout)); useEffect(() => { const fetchCodes = async () => { diff --git a/web/apps/photos/src/components/AuthenticateUserModal.tsx b/web/apps/photos/src/components/AuthenticateUserModal.tsx index e8d3c76626..1fe92900e9 100644 --- a/web/apps/photos/src/components/AuthenticateUserModal.tsx +++ b/web/apps/photos/src/components/AuthenticateUserModal.tsx @@ -1,6 +1,6 @@ import { checkSessionValidity } from "@/accounts/services/session"; import { - DialogBoxV2, + TitledMiniDialog, type MiniDialogAttributes, } from "@/base/components/MiniDialog"; import log from "@/base/log"; @@ -24,8 +24,7 @@ export default function AuthenticateUserModal({ onClose, onAuthenticate, }: Iprops) { - const { setDialogMessage, setDialogBoxAttributesV2, logout } = - useContext(AppContext); + const { setDialogMessage, showMiniDialog, logout } = useContext(AppContext); const [user, setUser] = useState(); const [keyAttributes, setKeyAttributes] = useState(); @@ -46,7 +45,7 @@ export default function AuthenticateUserModal({ const session = await checkSessionValidity(); if (session.status != "valid") { onClose(); - setDialogBoxAttributesV2( + showMiniDialog( passwordChangedElsewhereDialogAttributes(logout), ); } @@ -55,7 +54,7 @@ export default function AuthenticateUserModal({ // potentially transient issues. log.warn("Ignoring error when determining session validity", e); } - }, [setDialogBoxAttributesV2, logout]); + }, [showMiniDialog, logout]); useEffect(() => { const main = async () => { @@ -98,13 +97,11 @@ export default function AuthenticateUserModal({ }; return ( - - + ); } @@ -128,11 +125,10 @@ const passwordChangedElsewhereDialogAttributes = ( onLogin: () => void, ): MiniDialogAttributes => ({ title: t("password_changed_elsewhere"), - content: t("password_changed_elsewhere_message"), - proceed: { + message: t("password_changed_elsewhere_message"), + continue: { text: t("login"), action: onLogin, - variant: "accent", }, - close: { text: t("cancel") }, + cancel: false, }); diff --git a/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx b/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx index 109620b880..455d7c200b 100644 --- a/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx +++ b/web/apps/photos/src/components/Collections/AlbumCastDialog.tsx @@ -1,8 +1,9 @@ -import { DialogBoxV2 } from "@/base/components/MiniDialog"; +import { TitledMiniDialog } from "@/base/components/MiniDialog"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; import { boxSeal } from "@/base/crypto/libsodium"; import log from "@/base/log"; import type { Collection } from "@/media/collection"; +import { photosDialogZIndex } from "@/new/photos/components/z-index"; import { loadCast } from "@/new/photos/utils/chromecast-sender"; import SingleInputForm, { type SingleInputFormProps, @@ -135,11 +136,11 @@ export const AlbumCastDialog: React.FC = ({ }, [open]); return ( - {view == "choose" && ( @@ -213,6 +214,6 @@ export const AlbumCastDialog: React.FC = ({ )} - + ); }; diff --git a/web/apps/photos/src/components/Collections/CollectionNamer.tsx b/web/apps/photos/src/components/Collections/CollectionNamer.tsx index 7294b44ed6..bf64267499 100644 --- a/web/apps/photos/src/components/Collections/CollectionNamer.tsx +++ b/web/apps/photos/src/components/Collections/CollectionNamer.tsx @@ -1,4 +1,4 @@ -import { DialogBoxV2 } from "@/base/components/MiniDialog"; +import { TitledMiniDialog } from "@/base/components/MiniDialog"; import SingleInputForm, { type SingleInputFormProps, } from "@ente/shared/components/SingleInputForm"; @@ -39,12 +39,10 @@ export default function CollectionNamer({ attributes, ...props }: Props) { }; return ( - - + ); } diff --git a/web/apps/photos/src/components/DeleteAccountModal.tsx b/web/apps/photos/src/components/DeleteAccountModal.tsx index e30180e8d6..795d96bfb3 100644 --- a/web/apps/photos/src/components/DeleteAccountModal.tsx +++ b/web/apps/photos/src/components/DeleteAccountModal.tsx @@ -1,7 +1,6 @@ -import { DialogBoxV2 } from "@/base/components/MiniDialog"; +import { TitledMiniDialog } from "@/base/components/MiniDialog"; import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton"; import { LoadingButton } from "@/base/components/mui/LoadingButton"; -import log from "@/base/log"; import { AppContext } from "@/new/photos/types/context"; import { initiateEmail } from "@/new/photos/utils/web"; import { Link, Stack, useMediaQuery } from "@mui/material"; @@ -28,7 +27,7 @@ interface FormValues { } const DeleteAccountModal = ({ open, onClose }: Iprops) => { - const { setDialogBoxAttributesV2, logout } = useContext(AppContext); + const { showMiniDialog, onGenericError, logout } = useContext(AppContext); const { authenticateUser } = useContext(GalleryContext); const [loading, setLoading] = useState(false); @@ -39,13 +38,6 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => { const isMobile = useMediaQuery("(max-width: 428px)"); - const somethingWentWrong = () => - setDialogBoxAttributesV2({ - title: t("error"), - close: { variant: "critical" }, - content: t("generic_error_retry"), - }); - const initiateDelete = async ( { reason, feedback }: FormValues, { setFieldError }: FormikHelpers, @@ -76,80 +68,61 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => { askToMailForDeletion(); } } catch (e) { - log.error("Error while initiating account deletion", e); - somethingWentWrong(); + onGenericError(e); } finally { setLoading(false); } }; const confirmAccountDeletion = () => { - setDialogBoxAttributesV2({ + showMiniDialog({ title: t("delete_account"), - content: , - proceed: { + message: , + continue: { text: t("delete"), + color: "critical", action: solveChallengeAndDeleteAccount, - variant: "critical", }, - close: { text: t("cancel") }, }); }; const askToMailForDeletion = () => { const emailID = "account-deletion@ente.io"; - setDialogBoxAttributesV2({ + showMiniDialog({ title: t("delete_account"), - content: ( + message: ( }} values={{ emailID }} /> ), - proceed: { + continue: { text: t("delete"), + color: "critical", action: () => initiateEmail(emailID), - variant: "critical", }, - close: { text: t("cancel") }, }); }; - const solveChallengeAndDeleteAccount = async ( - setLoading: (value: boolean) => void, - ) => { - try { - setLoading(true); - const decryptedChallenge = await decryptDeleteAccountChallenge( - deleteAccountChallenge.current, - ); - const { reason, feedback } = reasonAndFeedbackRef.current; - await deleteAccount(decryptedChallenge, reason, feedback); - logout(); - } catch (e) { - log.error("solveChallengeAndDeleteAccount failed", e); - somethingWentWrong(); - } finally { - setLoading(false); - } + const solveChallengeAndDeleteAccount = async () => { + const decryptedChallenge = await decryptDeleteAccountChallenge( + deleteAccountChallenge.current, + ); + const { reason, feedback } = reasonAndFeedbackRef.current; + await deleteAccount(decryptedChallenge, reason, feedback); + logout(); }; return ( <> - initialValues={{ @@ -222,7 +195,7 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => { )} - + ); }; diff --git a/web/apps/photos/src/components/ExportPendingList.tsx b/web/apps/photos/src/components/ExportPendingList.tsx index 2f8888b734..087f31d3d0 100644 --- a/web/apps/photos/src/components/ExportPendingList.tsx +++ b/web/apps/photos/src/components/ExportPendingList.tsx @@ -1,4 +1,5 @@ -import { DialogBoxV2 } from "@/base/components/MiniDialog"; +import { TitledMiniDialog } from "@/base/components/MiniDialog"; +import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton"; import { ItemCard, PreviewItemTile } from "@/new/photos/components/Tiles"; import { EnteFile } from "@/new/photos/types/file"; import { FlexWrapper } from "@ente/shared/components/Container"; @@ -54,20 +55,13 @@ const ExportPendingList = (props: Iprops) => { }; return ( - { getItemTitle={getItemTitle} generateItemKey={generateItemKey} /> - + + {t("close")} + + ); }; diff --git a/web/apps/photos/src/components/FilesDownloadProgress.tsx b/web/apps/photos/src/components/FilesDownloadProgress.tsx index 86e5e0508c..7b82b17ee4 100644 --- a/web/apps/photos/src/components/FilesDownloadProgress.tsx +++ b/web/apps/photos/src/components/FilesDownloadProgress.tsx @@ -1,3 +1,4 @@ +import { photosDialogZIndex } from "@/new/photos/components/z-index"; import { AppContext } from "@/new/photos/types/context"; import Notification from "components/Notification"; import { t } from "i18next"; @@ -123,7 +124,7 @@ export const FilesDownloadProgress: React.FC = ({ horizontal="left" sx={{ "&&": { bottom: `${index * 80 + 20}px` }, - zIndex: 1600, + zIndex: photosDialogZIndex, }} open={isFilesDownloadStarted(attributes)} onClose={handleClose(attributes)} diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/FileNameEditDialog.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/FileNameEditDialog.tsx index b69570c4d3..555b52ad0c 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/FileNameEditDialog.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/FileNameEditDialog.tsx @@ -1,4 +1,5 @@ -import { DialogBoxV2 } from "@/base/components/MiniDialog"; +import { TitledMiniDialog } from "@/base/components/MiniDialog"; +import { photosDialogZIndex } from "@/new/photos/components/z-index"; import SingleInputForm, { type SingleInputFormProps, } from "@ente/shared/components/SingleInputForm"; @@ -23,13 +24,11 @@ export const FileNameEditDialog = ({ } }; return ( - - + ); }; diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx index 83938ab273..9f3c87ca7b 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx @@ -1,4 +1,5 @@ import { EnteDrawer } from "@/base/components/EnteDrawer"; +import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; import { Titlebar } from "@/base/components/Titlebar"; import { EllipsizedTypography } from "@/base/components/Typography"; @@ -18,7 +19,7 @@ import { UnclusteredFaceList, } from "@/new/photos/components/PeopleList"; import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker"; -import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer"; +import { fileInfoDrawerZIndex } from "@/new/photos/components/z-index"; import { tagNumericValue, type RawExifTags } from "@/new/photos/services/exif"; import { AnnotatedFacesForFile, @@ -53,12 +54,9 @@ import LinkButton from "components/pages/gallery/LinkButton"; import { t } from "i18next"; import { GalleryContext } from "pages/gallery"; import React, { useContext, useEffect, useMemo, useState } from "react"; +import { Trans } from "react-i18next"; import { changeFileName, updateExistingFilePubMetadata } from "utils/file"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; -import { - getMapDisableConfirmationDialog, - getMapEnableConfirmationDialog, -} from "utils/ui"; import { FileNameEditDialog } from "./FileNameEditDialog"; import InfoItem from "./InfoItem"; import MapBox from "./MapBox"; @@ -101,7 +99,7 @@ export const FileInfo: React.FC = ({ closePhotoViewer, onSelectPerson, }) => { - const { mapEnabled, updateMapEnabled, setDialogBoxAttributesV2 } = + const { mapEnabled, updateMapEnabled, showMiniDialog } = useContext(AppContext); const galleryContext = useContext(GalleryContext); const publicCollectionGalleryContext = useContext( @@ -151,13 +149,13 @@ export const FileInfo: React.FC = ({ }; const openEnableMapConfirmationDialog = () => - setDialogBoxAttributesV2( - getMapEnableConfirmationDialog(() => updateMapEnabled(true)), + showMiniDialog( + confirmEnableMapsDialogAttributes(() => updateMapEnabled(true)), ); const openDisableMapConfirmationDialog = () => - setDialogBoxAttributesV2( - getMapDisableConfirmationDialog(() => updateMapEnabled(false)), + showMiniDialog( + confirmDisableMapsDialogAttributes(() => updateMapEnabled(false)), ); const handleSelectFace = (annotatedFaceID: AnnotatedFaceID) => { @@ -379,10 +377,39 @@ const parseExifInfo = ( return info; }; +const confirmEnableMapsDialogAttributes = ( + onConfirm: () => void, +): MiniDialogAttributes => ({ + title: t("ENABLE_MAPS"), + message: ( + + ), + }} + /> + ), + continue: { text: t("enable"), action: onConfirm }, +}); + +const confirmDisableMapsDialogAttributes = ( + onConfirm: () => void, +): MiniDialogAttributes => ({ + title: t("DISABLE_MAPS"), + message: , + continue: { text: t("disable"), color: "critical", action: onConfirm }, +}); + const FileInfoSidebar = styled((props: DialogProps) => ( ))({ - zIndex: photoSwipeZIndex + 1, + zIndex: fileInfoDrawerZIndex, "& .MuiPaper-root": { padding: 8, }, diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx index 7be07c08e4..5cdfe0b8a7 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx @@ -4,8 +4,10 @@ import { MenuItemGroup, MenuSectionTitle, } from "@/base/components/Menu"; +import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; import { nameAndExtension } from "@/base/file"; import log from "@/base/log"; +import { photosDialogZIndex } from "@/new/photos/components/z-index"; import downloadManager from "@/new/photos/services/download"; import { AppContext } from "@/new/photos/types/context"; import { EnteFile } from "@/new/photos/types/file"; @@ -38,7 +40,6 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { createContext, useContext, useEffect, useRef, useState } from "react"; import { getLocalCollections } from "services/collectionService"; import uploadManager from "services/upload/uploadManager"; -import { getEditorCloseConfirmationMessage } from "utils/ui"; import ColoursMenu from "./ColoursMenu"; import CropMenu, { cropRegionOfCanvas, getCropRegionArgs } from "./CropMenu"; import FreehandCropRegion from "./FreehandCropRegion"; @@ -83,7 +84,7 @@ export interface CropBoxProps { } const ImageEditorOverlay = (props: IProps) => { - const appContext = useContext(AppContext); + const { showMiniDialog } = useContext(AppContext); const canvasRef = useRef(null); const originalSizeCanvasRef = useRef(null); @@ -441,9 +442,7 @@ const ImageEditorOverlay = (props: IProps) => { const handleCloseWithConfirmation = () => { if (transformationPerformed || coloursAdjusted) { - appContext.setDialogBoxAttributesV2( - getEditorCloseConfirmationMessage(handleClose), - ); + showMiniDialog(confirmEditorCloseDialogAttributes(handleClose)); } else { handleClose(); } @@ -522,7 +521,7 @@ const ImageEditorOverlay = (props: IProps) => { { export default ImageEditorOverlay; +const confirmEditorCloseDialogAttributes = ( + onConfirm: () => void, +): MiniDialogAttributes => ({ + title: t("CONFIRM_EDITOR_CLOSE_MESSAGE"), + message: t("CONFIRM_EDITOR_CLOSE_DESCRIPTION"), + continue: { + text: t("close"), + color: "critical", + action: onConfirm, + }, +}); + /** * Create a new {@link File} with the contents of the given canvas. * diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index ca4fad9fa7..6f54c88582 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -7,7 +7,7 @@ import log from "@/base/log"; import { savedLogs } from "@/base/log-web"; import { customAPIHost } from "@/base/origins"; import type { CollectionSummaries } from "@/new/photos/services/collection/ui"; -import { AppContext } from "@/new/photos/types/context"; +import { AppContext, useAppContext } from "@/new/photos/types/context"; import { initiateEmail, openURL } from "@/new/photos/utils/web"; import { SpaceBetweenFlex } from "@ente/shared/components/Container"; import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; @@ -667,7 +667,7 @@ const ExitSection: React.FC = () => { }; const DebugSection: React.FC = () => { - const appContext = useContext(AppContext); + const { showMiniDialog } = useAppContext(); const [appVersion, setAppVersion] = useState(); const [host, setHost] = useState(); @@ -679,17 +679,13 @@ const DebugSection: React.FC = () => { }); const confirmLogDownload = () => - appContext.setDialogMessage({ + showMiniDialog({ title: t("DOWNLOAD_LOGS"), - content: , - proceed: { + message: , + continue: { text: t("download"), - variant: "accent", action: downloadLogs, }, - close: { - text: t("cancel"), - }, }); const downloadLogs = () => { diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 68f8732d8d..0c7b0f61c0 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -1,9 +1,12 @@ import { clientPackageName, staticAppTitle } from "@/base/app"; import { CustomHead } from "@/base/components/Head"; -import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; -import { MiniDialog } from "@/base/components/MiniDialog"; +import { AttributedMiniDialog } from "@/base/components/MiniDialog"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; import { AppNavbar } from "@/base/components/Navbar"; +import { + genericErrorDialogAttributes, + useAttributedMiniDialog, +} from "@/base/components/utils/mini-dialog"; import { setupI18n } from "@/base/i18n"; import log from "@/base/log"; import { @@ -11,6 +14,7 @@ import { logUnhandledErrorsAndRejections, } from "@/base/log-web"; import { AppUpdate } from "@/base/types/ipc"; +import { photosDialogZIndex } from "@/new/photos/components/z-index"; import DownloadManager from "@/new/photos/services/download"; import { runMigrations } from "@/new/photos/services/migrations"; import { initML, isMLSupported } from "@/new/photos/services/ml"; @@ -70,17 +74,15 @@ export default function App({ Component, pageProps }: AppProps) { const isLoadingBarRunning = useRef(false); const loadingBar = useRef(null); const [dialogMessage, setDialogMessage] = useState(); - const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState< - MiniDialogAttributes | undefined - >(); const [messageDialogView, setMessageDialogView] = useState(false); - const [dialogBoxV2View, setDialogBoxV2View] = useState(false); const [watchFolderView, setWatchFolderView] = useState(false); const [watchFolderFiles, setWatchFolderFiles] = useState(null); const [notificationView, setNotificationView] = useState(false); const closeNotification = () => setNotificationView(false); const [notificationAttributes, setNotificationAttributes] = useState(null); + + const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog(); const [themeColor, setThemeColor] = useLocalState( LS_KEYS.THEME, THEME_COLOR.DARK, @@ -199,10 +201,6 @@ export default function App({ Component, pageProps }: AppProps) { setMessageDialogView(true); }, [dialogMessage]); - useEffect(() => { - setDialogBoxV2View(true); - }, [dialogBoxAttributeV2]); - useEffect(() => { setNotificationView(true); }, [notificationAttributes]); @@ -210,13 +208,9 @@ export default function App({ Component, pageProps }: AppProps) { const showNavBar = (show: boolean) => setShowNavBar(show); const updateMapEnabled = async (enabled: boolean) => { - try { - await updateMapEnabledStatus(enabled); - setLocalMapEnabled(enabled); - setMapEnabled(enabled); - } catch (e) { - log.error("Error while updating mapEnabled", e); - } + await updateMapEnabledStatus(enabled); + setLocalMapEnabled(enabled); + setMapEnabled(enabled); }; const startLoading = () => { @@ -234,7 +228,6 @@ export default function App({ Component, pageProps }: AppProps) { () => setMessageDialogView(false), [], ); - const closeDialogBoxV2 = () => setDialogBoxV2View(false); // Use `onGenericError` instead. const somethingWentWrong = useCallback( @@ -244,20 +237,13 @@ export default function App({ Component, pageProps }: AppProps) { close: { variant: "critical" }, content: t("generic_error_retry"), }), - [setDialogMessage], + [], ); - const onGenericError = useCallback( - (e: unknown) => ( - log.error("Error", e), - setDialogBoxAttributesV2({ - title: t("error"), - content: t("generic_error"), - close: { variant: "critical" }, - }) - ), - [setDialogBoxAttributesV2], - ); + const onGenericError = useCallback((e: unknown) => { + log.error("Error", e); + showMiniDialog(genericErrorDialogAttributes()); + }, []); const logout = useCallback(() => { void photosLogout().then(() => router.push("/")); @@ -276,9 +262,9 @@ export default function App({ Component, pageProps }: AppProps) { setNotificationAttributes, themeColor, setThemeColor, + showMiniDialog, somethingWentWrong, onGenericError, - setDialogBoxAttributesV2, mapEnabled, updateMapEnabled, // <- changes on each render isCFProxyDisabled, @@ -301,17 +287,15 @@ export default function App({ Component, pageProps }: AppProps) { - ({ - title: t("ENABLE_MAPS"), - content: ( - - ), - }} - /> - ), - proceed: { - action: enableMapHelper, - text: t("enable"), - variant: "accent", - }, - close: { text: t("cancel") }, -}); - -export const getMapDisableConfirmationDialog = ( - disableMapHelper, -): DialogBoxAttributes => ({ - title: t("DISABLE_MAPS"), - content: , - proceed: { - action: disableMapHelper, - text: t("disable"), - variant: "accent", - }, - close: { text: t("cancel") }, -}); - -export const getEditorCloseConfirmationMessage = ( - doClose: () => void, -): DialogBoxAttributes => ({ - title: t("CONFIRM_EDITOR_CLOSE_MESSAGE"), - content: t("CONFIRM_EDITOR_CLOSE_DESCRIPTION"), - proceed: { - action: doClose, - text: t("close"), - variant: "critical", - }, - close: { text: t("cancel") }, -}); diff --git a/web/packages/shared/components/LoginComponents.tsx b/web/packages/accounts/components/LoginComponents.tsx similarity index 87% rename from web/packages/shared/components/LoginComponents.tsx rename to web/packages/accounts/components/LoginComponents.tsx index 4f0366a834..cece699417 100644 --- a/web/packages/shared/components/LoginComponents.tsx +++ b/web/packages/accounts/components/LoginComponents.tsx @@ -5,16 +5,17 @@ import { } from "@/accounts/services/passkey"; import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton"; +import { genericErrorDialogAttributes } from "@/base/components/utils/mini-dialog"; import log from "@/base/log"; import { customAPIHost } from "@/base/origins"; +import { VerticallyCentered } from "@ente/shared/components/Container"; +import FormPaper from "@ente/shared/components/Form/FormPaper"; +import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; +import LinkButton from "@ente/shared/components/LinkButton"; import { CircularProgress, Stack, Typography, styled } from "@mui/material"; import { t } from "i18next"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; -import { VerticallyCentered } from "./Container"; -import FormPaper from "./Form/FormPaper"; -import FormPaperFooter from "./Form/FormPaper/Footer"; -import LinkButton from "./LinkButton"; export const PasswordHeader: React.FC = ({ children, @@ -74,7 +75,7 @@ interface VerifyingPasskeyProps { onRetry: () => void; /** Perform the (possibly app specific) logout sequence. */ logout: () => void; - setDialogBoxAttributesV2: (attrs: MiniDialogAttributes) => void; + showMiniDialog: (attrs: MiniDialogAttributes) => void; } export const VerifyingPasskey: React.FC = ({ @@ -82,7 +83,7 @@ export const VerifyingPasskey: React.FC = ({ email, onRetry, logout, - setDialogBoxAttributesV2, + showMiniDialog, }) => { type VerificationStatus = "waiting" | "checking" | "pending"; const [verificationStatus, setVerificationStatus] = @@ -104,11 +105,11 @@ export const VerifyingPasskey: React.FC = ({ else router.push(await saveCredentialsAndNavigateTo(response)); } catch (e) { log.error("Passkey verification status check failed", e); - setDialogBoxAttributesV2( + showMiniDialog( e instanceof Error && e.message == passkeySessionExpiredErrorMessage ? sessionExpiredDialogAttributes(logout) - : genericErrorAttributes(), + : genericErrorDialogAttributes(), ); setVerificationStatus("waiting"); } @@ -194,11 +195,10 @@ const ButtonStack = styled("div")` `; /** - * {@link DialogBoxAttributesV2} for showing the error when the user's session - * has expired. + * {@link MiniDialogAttributes} for showing asking the user to login again when + * their session has expired. * - * It asks them to login again. There is one button, which allows them to - * logout. + * There is one button, which allows them to logout. * * @param onLogin Called when the user presses the "Login" button on the error * dialog. @@ -207,20 +207,11 @@ export const sessionExpiredDialogAttributes = ( onLogin: () => void, ): MiniDialogAttributes => ({ title: t("SESSION_EXPIRED"), - content: t("SESSION_EXPIRED_MESSAGE"), + message: t("SESSION_EXPIRED_MESSAGE"), nonClosable: true, - proceed: { + continue: { text: t("login"), action: onLogin, - variant: "accent", }, -}); - -/** - * {@link DialogBoxAttributesV2} for showing a generic error. - */ -const genericErrorAttributes = (): MiniDialogAttributes => ({ - title: t("error"), - close: { variant: "critical" }, - content: t("generic_error_retry"), + cancel: false, }); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 5ffb93b73b..5287a7fbcf 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -7,12 +7,6 @@ import { ensure } from "@/utils/ensure"; import { VerticallyCentered } from "@ente/shared/components/Container"; import FormPaper from "@ente/shared/components/Form/FormPaper"; import LinkButton from "@ente/shared/components/LinkButton"; -import { - LoginFlowFormFooter, - PasswordHeader, - VerifyingPasskey, - sessionExpiredDialogAttributes, -} from "@ente/shared/components/LoginComponents"; import VerifyMasterPasswordForm, { type VerifyMasterPasswordFormProps, } from "@ente/shared/components/VerifyMasterPasswordForm"; @@ -46,6 +40,12 @@ import { t } from "i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useState } from "react"; import { getSRPAttributes } from "../api/srp"; +import { + LoginFlowFormFooter, + PasswordHeader, + VerifyingPasskey, + sessionExpiredDialogAttributes, +} from "../components/LoginComponents"; import { PAGES } from "../constants/pages"; import { openPasskeyVerificationURL, @@ -66,7 +66,7 @@ import type { PageProps } from "../types/page"; import type { SRPAttributes } from "../types/srp"; const Page: React.FC = ({ appContext }) => { - const { logout, showNavBar, setDialogBoxAttributesV2 } = appContext; + const { logout, showNavBar, showMiniDialog } = appContext; const [srpAttributes, setSrpAttributes] = useState(); const [keyAttributes, setKeyAttributes] = useState(); @@ -82,7 +82,7 @@ const Page: React.FC = ({ appContext }) => { const validateSession = useCallback(async () => { const showSessionExpiredDialog = () => - setDialogBoxAttributesV2(sessionExpiredDialogAttributes(logout)); + showMiniDialog(sessionExpiredDialogAttributes(logout)); try { const session = await checkSessionValidity(); @@ -114,7 +114,7 @@ const Page: React.FC = ({ appContext }) => { // potentially transient issues. log.warn("Ignoring error when determining session validity", e); } - }, [setDialogBoxAttributesV2, logout]); + }, [showMiniDialog, logout]); useEffect(() => { const main = async () => { @@ -354,7 +354,7 @@ const Page: React.FC = ({ appContext }) => { onRetry={() => openPasskeyVerificationURL(passkeyVerificationData) } - {...{ logout, setDialogBoxAttributesV2 }} + {...{ logout, showMiniDialog }} /> ); } diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index 531f37763f..c4ad1ba20f 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -29,7 +29,7 @@ const bip39 = require("bip39"); bip39.setDefaultWordlist("english"); const Page: React.FC = ({ appContext }) => { - const { showNavBar, setDialogBoxAttributesV2 } = appContext; + const { showNavBar, showMiniDialog } = appContext; const [keyAttributes, setKeyAttributes] = useState< KeyAttributes | undefined @@ -98,10 +98,11 @@ const Page: React.FC = ({ appContext }) => { }; const showNoRecoveryKeyMessage = () => - setDialogBoxAttributesV2({ + showMiniDialog({ title: t("sorry"), - close: {}, - content: t("NO_RECOVERY_KEY_MESSAGE"), + message: t("NO_RECOVERY_KEY_MESSAGE"), + continue: { color: "secondary" }, + cancel: false, }); return ( diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index 820a0707e4..d090160d11 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -42,7 +42,7 @@ export interface RecoverPageProps { } const Page: React.FC = ({ appContext, twoFactorType }) => { - const { logout } = appContext; + const { showMiniDialog, logout } = appContext; const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] = useState | null>(null); @@ -70,10 +70,7 @@ const Page: React.FC = ({ appContext, twoFactorType }) => { const resp = await recoverTwoFactor(sid, twoFactorType); setDoesHaveEncryptedRecoveryKey(!!resp.encryptedSecret); if (!resp.encryptedSecret) { - showContactSupportDialog({ - text: t("GO_BACK"), - action: router.back, - }); + showContactSupportDialog({ action: router.back }); } else { setEncryptedTwoFactorSecret({ encryptedData: resp.encryptedSecret, @@ -89,10 +86,7 @@ const Page: React.FC = ({ appContext, twoFactorType }) => { } else { log.error("two factor recovery page setup failed", e); setDoesHaveEncryptedRecoveryKey(false); - showContactSupportDialog({ - text: t("GO_BACK"), - action: router.back, - }); + showContactSupportDialog({ action: router.back }); } } }; @@ -146,12 +140,11 @@ const Page: React.FC = ({ appContext, twoFactorType }) => { }; const showContactSupportDialog = ( - dialogClose?: MiniDialogAttributes["close"], + dialogContinue?: MiniDialogAttributes["continue"], ) => { - appContext.setDialogBoxAttributesV2({ + showMiniDialog({ title: t("contact_support"), - close: dialogClose ?? {}, - content: ( + message: ( = ({ appContext, twoFactorType }) => { values={{ emailID: "support@ente.io" }} /> ), + continue: { color: "secondary", ...(dialogContinue ?? {}) }, + cancel: false, }); }; diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 860459d3c9..f11e77f2f8 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -6,10 +6,6 @@ import { VerticallyCentered } from "@ente/shared/components/Container"; import FormPaper from "@ente/shared/components/Form/FormPaper"; import FormPaperTitle from "@ente/shared/components/Form/FormPaper/Title"; import LinkButton from "@ente/shared/components/LinkButton"; -import { - LoginFlowFormFooter, - VerifyingPasskey, -} from "@ente/shared/components/LoginComponents"; import SingleInputForm, { type SingleInputFormProps, } from "@ente/shared/components/SingleInputForm"; @@ -35,6 +31,10 @@ import { useEffect, useState } from "react"; import { Trans } from "react-i18next"; import { getSRPAttributes } from "../api/srp"; import { putAttributes, sendOtt, verifyOtt } from "../api/user"; +import { + LoginFlowFormFooter, + VerifyingPasskey, +} from "../components/LoginComponents"; import { PAGES } from "../constants/pages"; import { openPasskeyVerificationURL, @@ -46,7 +46,7 @@ import type { PageProps } from "../types/page"; import type { SRPAttributes, SRPSetupAttributes } from "../types/srp"; const Page: React.FC = ({ appContext }) => { - const { logout, showNavBar, setDialogBoxAttributesV2 } = appContext; + const { logout, showNavBar, showMiniDialog } = appContext; const [email, setEmail] = useState(""); const [resend, setResend] = useState(0); @@ -202,7 +202,7 @@ const Page: React.FC = ({ appContext }) => { onRetry={() => openPasskeyVerificationURL(passkeyVerificationData) } - {...{ logout, setDialogBoxAttributesV2 }} + {...{ logout, showMiniDialog }} /> ); } diff --git a/web/packages/accounts/types/context.ts b/web/packages/accounts/types/context.ts index ae9fd7b9b6..d625db096c 100644 --- a/web/packages/accounts/types/context.ts +++ b/web/packages/accounts/types/context.ts @@ -5,9 +5,20 @@ import type { MiniDialogAttributes } from "@/base/components/MiniDialog"; * defer to the pages provided by the accounts package. */ export interface AccountsContextT { - /** Perform the (possibly app specific) logout sequence. */ + /** + * Perform the (possibly app specific) logout sequence. + */ logout: () => void; - /** Show or hide the app's navigation bar. */ + /** + * Show or hide the app's navigation bar. + */ showNavBar: (show: boolean) => void; - setDialogBoxAttributesV2: (attrs: MiniDialogAttributes) => void; + /** + * Show a "mini dialog" with the given attributes. + * + * Mini dialogs (see {@link AttributedMiniDialog}) are meant for simple + * confirmation or notications. Their appearance and functionality can be + * customized by providing appropriate {@link MiniDialogAttributes}. + */ + showMiniDialog: (attributes: MiniDialogAttributes) => void; } diff --git a/web/packages/base/components/MiniDialog.tsx b/web/packages/base/components/MiniDialog.tsx index 550ea4c231..ff124a035e 100644 --- a/web/packages/base/components/MiniDialog.tsx +++ b/web/packages/base/components/MiniDialog.tsx @@ -1,381 +1,290 @@ -// TODO: -/* eslint-disable @typescript-eslint/prefer-optional-chain */ -/* eslint-disable @typescript-eslint/no-unnecessary-condition */ -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton"; import { LoadingButton } from "@/base/components/mui/LoadingButton"; -import { dialogCloseHandler } from "@ente/shared/components/DialogBox/TitleWithCloseButton"; import type { ButtonProps } from "@mui/material"; import { Box, Dialog, + DialogContent, + DialogTitle, Stack, Typography, type DialogProps, } from "@mui/material"; import { t } from "i18next"; import React, { useState } from "react"; +import log from "../log"; /** - * Customize the properties of the dialog box. - * - * Our custom dialog box helpers are meant for small message boxes, usually - * meant to confirm some user action. If more customization is needed, it might - * be a better idea to reach out for a bespoke MUI {@link DialogBox} instead. + * Customize the contents of an {@link AttributedMiniDialog}. */ export interface MiniDialogAttributes { - icon?: React.ReactNode; /** - * The dialog's title + * The dialog's title. * - * Usually this will be a string, but it can be any {@link ReactNode}. Note - * that it always gets wrapped in a Typography element to set the font - * style, so if your ReactNode wants to do its own thing, it'll need to - * reset or override these customizations. + * This will be usually be a string, but the prop accepts any React node to + * allow passing a i18next component. */ title?: React.ReactNode; + /** + * An optional component shown next to the title. + */ + icon?: React.ReactNode; + /** + * The dialog's message. + * + * This will be usually be a string, but the prop accepts any React node to + * allow passing a i18next component. + */ + message?: React.ReactNode; + /** + * If `true`, then clicks in the backdrop are ignored. The default behaviour + * is to close the dialog when the background is clicked. + */ staticBackdrop?: boolean; + /** + * If `true`, then the dialog cannot be closed (e.g. with the ESC key, or + * clicking on the backdrop) except through one of the explicitly provided + * actions. + */ nonClosable?: boolean; /** - * The dialog's content. - */ - content?: React.ReactNode; - /** - * Customize the cancel (dismiss) action button offered by the dialog box. + * Customize the primary action button shown in the dialog. * - * Usually dialog boxes should have a cancel action, but this can be skipped - * to only show one of the other types of buttons. + * This is provided by boxes which serve as some sort of confirmation. For + * dialogs which are informational notifications, this is usually skipped, + * only the {@link close} action button is configured. */ - close?: { - /** The string to use as the label for the cancel button. */ - text?: string; - /** The color of the button. */ - variant?: ButtonProps["color"]; + continue?: { /** - * The function to call when the user cancels. + * The string to use as the label for the primary action button. * - * If provided, this callback is invoked before closing the dialog. + * Default is `t("ok")`. */ - action?: () => void; + text?: string; + /** + * The color of the button. + * + * Default is "accent". + */ + color?: ButtonProps["color"]; + /** + * If `true`, the primary action button is auto focused when the dialog + * is opened, allowing the user to confirm just by pressing ENTER. + */ + autoFocus?: ButtonProps["autoFocus"]; + /** + * The function to call when the user activates the button. + * + * If this function returns a promise, then an activity indicator will + * be shown on the button until the promise settles. + * + * If this function is not provided, or if the function completes / + * fullfills, then then the dialog is automatically closed. + * + * Otherwise (that is, if the provided function throws), the dialog + * remains open, showing a generic error. + * + * That's quite a mouthful, here's a flowchart: + * + * - Not provided: Close + * - Provided sync: + * - Success: Close + * - Failure: Remain open, showing generic error + * - Provided async: + * - Success: Close + * - Failure: Remain open, showing generic error + */ + action?: () => void | Promise; }; /** - * Customize the primary action button offered by the dialog box. + * The string to use as the label for the cancel button. + * + * Default is `t("cancel")`. + * + * Set this to `false` to omit the cancel button altogether. */ - proceed?: { - /** The string to use as the label for the primary action. */ - text: string; - /** - * The function to call when the user presses the primary action button. - * - * It is passed a {@link setLoading} function that can be used to show - * or hide loading indicator or the primary action button. - */ - action: - | (() => void | Promise) - | ((setLoading: (value: boolean) => void) => void | Promise); - variant?: ButtonProps["color"]; - disabled?: boolean; - }; - secondary?: { - text: string; - action: () => void; - variant?: ButtonProps["color"]; - disabled?: boolean; - }; - buttons?: { - text: string; - action: () => void; - variant: ButtonProps["color"]; - disabled?: boolean; - }[]; + cancel?: string | false; + /** The direction in which the buttons are stacked. Default is "column". */ buttonDirection?: "row" | "column"; } -type MiniDialogProps = React.PropsWithChildren< - Omit & { - onClose: () => void; - attributes?: MiniDialogAttributes; - } ->; +type MiniDialogProps = Omit & { + onClose: () => void; + attributes?: MiniDialogAttributes; +}; /** * A small, mostly predefined, MUI {@link Dialog} that can be used to notify the * user, or ask for confirmation before actions. * * The rendered dialog can be customized by modifying the {@link attributes} - * prop. If you find yourself wanting to customize it further, consider just - * creating a new bespoke instantiation of a {@link Dialog}. + * prop. If you find yourself wanting to customize it further, consider either + * using a {@link TitledMiniDialog} or {@link Dialog}. */ -export function MiniDialog({ - attributes, - children, - open, - onClose, - ...props -}: MiniDialogProps) { - const [loading, setLoading] = useState(false); +export const AttributedMiniDialog: React.FC< + React.PropsWithChildren +> = ({ open, onClose, attributes, children, ...props }) => { + const [phase, setPhase] = useState<"loading" | "failed" | undefined>(); + if (!attributes) { return <>; } - const handleClose = dialogCloseHandler({ - staticBackdrop: attributes.staticBackdrop, - nonClosable: attributes.nonClosable, - onClose: onClose, - }); + const resetPhaseAndClose = () => { + setPhase(undefined); + onClose(); + }; + + const handleClose = () => { + if (attributes.nonClosable) return; + resetPhaseAndClose(); + }; const { PaperProps, ...rest } = props; return ( - - - {attributes.icon && ( - svg": { - fontSize: "32px", - }, - }} - > - {attributes.icon} - - )} + {(attributes.icon ?? attributes.title) && ( + svg": { + fontSize: "32px", + color: "text.faint", + }, + padding: "24px 16px 16px 16px", + }} + > {attributes.title && ( - + {attributes.title} + + )} + {attributes.icon} + + )} + + {attributes.message && ( + + {attributes.message} + + )} + {children} + + {phase == "failed" && ( + + {t("generic_error")} )} - {children || - (attributes?.content && ( - - {attributes.content} - - ))} + {attributes.continue && ( + { + setPhase("loading"); + try { + await attributes.continue?.action?.(); + resetPhaseAndClose(); + } catch (e) { + log.error("Error", e); + setPhase("failed"); + } + }} + > + {attributes.continue.text ?? t("ok")} + + )} + {attributes.cancel !== false && ( + + {attributes.cancel ?? t("cancel")} + + )} - {(attributes.proceed || - attributes.close || - attributes.buttons?.length) && ( - - {attributes.proceed && ( - { - await attributes.proceed?.action( - setLoading, - ); - - onClose(); - }} - disabled={attributes.proceed.disabled} - > - {attributes.proceed.text} - - )} - {attributes.close && ( - { - attributes.close?.action && - attributes.close?.action(); - onClose(); - }} - > - {attributes.close?.text ?? t("ok")} - - )} - {attributes.buttons && - attributes.buttons.map((b) => ( - { - b.action(); - onClose(); - }} - disabled={b.disabled} - > - {b.text} - - ))} - - )} - + ); -} +}; -// TODO: Sketch of a possible approach to using this. Haven't throught this -// through, just noting down the outline inspired by an API I saw. -// /** -// * A React hook for simplifying use of MiniDialog within the photos app context. -// * -// * It relies on the presence of the {@link setDialogBoxAttributesV2} function -// * provided by the Photos app's {@link AppContext}. -// */ -// export const useConfirm = (attr) => { -// const {setDialogBoxAttributesV2} = useAppContext(); -// return () => { -// new Promise((resolve) => { -// setDialogBoxAttributesV2( -// proceed: { -// action: attr.action.then(resolve) -// } -// ) -// } -// } +type TitledMiniDialogProps = Omit & { + onClose: () => void; + /** + * The dialog's title. + */ + title?: React.ReactNode; +}; /** - * TODO This is a duplicate of MiniDialog. This is for use by call sites that - * were using the MiniDialog not as a dialog but as a base container. Such use - * cases are better served by directly using the MUI {@link Dialog}, so these - * are considered deprecated. Splitting these here so that we can streamline the - * API for the notify/confirm case separately. + * MiniDialog in a "shell" form. + * + * This is a {@link Dialog} for use at places which need more customization than + * what {@link AttributedMiniDialog} provides, but wish to retain a similar look + * and feel without duplicating code. + * + * It does three things: + * + * - Sets a fixed size and padding similar to {@link AttributedMiniDialog}. + * - Takes the title as a prop, and wraps it in a {@link DialogTitle}. + * - Wraps children in a scrollable {@link DialogContent}. */ -export function DialogBoxV2({ - attributes, - children, - open, - onClose, - ...props -}: MiniDialogProps) { - const [loading, setLoading] = useState(false); - if (!attributes) { - return <>; - } - - const handleClose = dialogCloseHandler({ - staticBackdrop: attributes.staticBackdrop, - nonClosable: attributes.nonClosable, - onClose: onClose, - }); - +export const TitledMiniDialog: React.FC< + React.PropsWithChildren +> = ({ open, onClose, title, children, ...props }) => { const { PaperProps, ...rest } = props; return ( - - - {attributes.icon && ( - svg": { - fontSize: "32px", - }, - }} - > - {attributes.icon} - - )} - {attributes.title && ( - - {attributes.title} - - )} - {children || - (attributes?.content && ( - - {attributes.content} - - ))} - - {(attributes.proceed || - attributes.close || - attributes.buttons?.length) && ( - - {attributes.proceed && ( - { - await attributes.proceed?.action( - setLoading, - ); - - onClose(); - }} - disabled={attributes.proceed.disabled} - > - {attributes.proceed.text} - - )} - {attributes.close && ( - { - attributes.close?.action && - attributes.close?.action(); - onClose(); - }} - > - {attributes.close?.text ?? t("ok")} - - )} - {attributes.buttons && - attributes.buttons.map((b) => ( - { - b.action(); - onClose(); - }} - disabled={b.disabled} - > - {b.text} - - ))} - - )} - + + {title} + + {children} ); -} +}; diff --git a/web/packages/base/components/utils/mini-dialog.tsx b/web/packages/base/components/utils/mini-dialog.tsx new file mode 100644 index 0000000000..5118e52a62 --- /dev/null +++ b/web/packages/base/components/utils/mini-dialog.tsx @@ -0,0 +1,63 @@ +import ErrorOutline from "@mui/icons-material/ErrorOutline"; +import { t } from "i18next"; +import { useCallback, useState } from "react"; +import type { MiniDialogAttributes } from "../MiniDialog"; + +/** + * A React hook for simplifying the provisioning of a {@link showMiniDialog} + * function to inject in app contexts, and the other props that are needed for + * to pass on to the {@link AttributedMiniDialog}. + */ +export const useAttributedMiniDialog = () => { + const [miniDialogAttributes, setMiniDialogAttributes] = useState< + MiniDialogAttributes | undefined + >(); + + const [openMiniDialog, setOpenMiniDialog] = useState(false); + + const showMiniDialog = useCallback((attributes: MiniDialogAttributes) => { + setMiniDialogAttributes(attributes); + setOpenMiniDialog(true); + }, []); + + const onCloseMiniDialog = useCallback(() => setOpenMiniDialog(false), []); + + return { + showMiniDialog, + miniDialogProps: { + open: openMiniDialog, + onClose: onCloseMiniDialog, + attributes: miniDialogAttributes, + }, + }; +}; + +/** + * A convenience function to construct {@link MiniDialogAttributes} for showing + * error dialogs. + * + * It takes one or two arguments. + * + * - If both are provided, then the first one is taken as the title and the + * second one as the message. + * + * - Otherwise it sets a default title and use the only argument as the message. + */ +export const errorDialogAttributes = ( + messageOrTitle: string, + optionalMessage?: string, +): MiniDialogAttributes => { + const title = optionalMessage ? messageOrTitle : t("error"); + const message = optionalMessage ? optionalMessage : messageOrTitle; + + return { + title, + icon: , + message, + continue: { color: "critical" }, + cancel: false, + }; +}; + +export const genericErrorDialogAttributes = () => + errorDialogAttributes(t("generic_error")); diff --git a/web/packages/build-config/eslintrc-react.js b/web/packages/build-config/eslintrc-react.js index 571d37e622..098403b08b 100644 --- a/web/packages/build-config/eslintrc-react.js +++ b/web/packages/build-config/eslintrc-react.js @@ -13,9 +13,14 @@ module.exports = { "react/jsx-no-target-blank": ["warn", { allowReferrer: true }], /* Otherwise we need to do unnecessary boilerplating when using memo. */ "react/display-name": "off", + /* Apparently Fast refresh only works if a file only exports components, + and this rule warns about that. Constants are okay though (otherwise + we'll need to create unnecessary helper files). */ "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], + /* Next.js supports the JSX transform introduced in React 17 */ + "react/react-in-jsx-scope": "off", }, }; diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx index cf55625bf3..15480eddf4 100644 --- a/web/packages/new/photos/components/MLSettings.tsx +++ b/web/packages/new/photos/components/MLSettings.tsx @@ -27,7 +27,7 @@ import { import { t } from "i18next"; import React, { useEffect, useState, useSyncExternalStore } from "react"; import { Trans } from "react-i18next"; -import { useAppContext, type AppContextT } from "../types/context"; +import { useAppContext } from "../types/context"; import { openURL } from "../utils/web"; import { useWrapAsyncOperation } from "./use-wrap-async"; @@ -36,8 +36,6 @@ export const MLSettings: React.FC = ({ onClose, onRootClose, }) => { - const { setDialogBoxAttributesV2 } = useAppContext(); - const mlStatus = useSyncExternalStore(mlStatusSubscribe, mlStatusSnapshot); const [openFaceConsent, setOpenFaceConsent] = useState(false); @@ -68,10 +66,7 @@ export const MLSettings: React.FC = ({ component = ; } else { component = ( - + ); } @@ -252,15 +247,11 @@ interface ManageMLProps { mlStatus: Exclude; /** Called when the user wants to disable ML. */ onDisableML: () => void; - /** Subset of appContext. */ - setDialogBoxAttributesV2: AppContextT["setDialogBoxAttributesV2"]; } -const ManageML: React.FC = ({ - mlStatus, - onDisableML, - setDialogBoxAttributesV2, -}) => { +const ManageML: React.FC = ({ mlStatus, onDisableML }) => { + const { showMiniDialog } = useAppContext(); + const { phase, nSyncedFiles, nTotalFiles } = mlStatus; let status: string; @@ -289,19 +280,17 @@ const ManageML: React.FC = ({ ? `${Math.round((100 * nSyncedFiles) / nTotalFiles)}%` : `${nSyncedFiles} / ${nTotalFiles}`; - const confirmDisableML = () => { - setDialogBoxAttributesV2({ + const confirmDisableML = () => + showMiniDialog({ title: t("ml_search_disable"), - content: t("ml_search_disable_confirm"), - close: { text: t("cancel") }, - proceed: { - variant: "critical", + message: t("ml_search_disable_confirm"), + continue: { text: t("disable"), + color: "critical", action: onDisableML, }, buttonDirection: "row", }); - }; return ( diff --git a/web/packages/new/photos/components/PhotoDateTimePicker.tsx b/web/packages/new/photos/components/PhotoDateTimePicker.tsx index 86859a3672..93a19bb165 100644 --- a/web/packages/new/photos/components/PhotoDateTimePicker.tsx +++ b/web/packages/new/photos/components/PhotoDateTimePicker.tsx @@ -1,5 +1,4 @@ import type { ParsedMetadataDate } from "@/media/file-metadata"; -import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer"; import { LocalizationProvider, MobileDateTimePicker, @@ -7,6 +6,7 @@ import { import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import dayjs, { Dayjs } from "dayjs"; import React, { useState } from "react"; +import { fileInfoDrawerZIndex } from "./z-index"; interface PhotoDateTimePickerProps { /** @@ -105,7 +105,7 @@ export const PhotoDateTimePicker: React.FC = ({ photo viewer and the info drawer */ dialog: { sx: { - zIndex: photoSwipeZIndex + 2, + zIndex: fileInfoDrawerZIndex + 1, }, }, }} diff --git a/web/packages/new/photos/components/PhotoViewer.tsx b/web/packages/new/photos/components/PhotoViewer.tsx deleted file mode 100644 index c320a73cf5..0000000000 --- a/web/packages/new/photos/components/PhotoViewer.tsx +++ /dev/null @@ -1,5 +0,0 @@ -/** - * PhotoSwipe sets the zIndex of its "pswp" class to 1500. We need to go higher - * than that for our drawers and dialogs to show above it. - */ -export const photoSwipeZIndex = 1500; diff --git a/web/packages/new/photos/components/gallery/PeopleHeader.tsx b/web/packages/new/photos/components/gallery/PeopleHeader.tsx index debe3cf9f6..2b59b96967 100644 --- a/web/packages/new/photos/components/gallery/PeopleHeader.tsx +++ b/web/packages/new/photos/components/gallery/PeopleHeader.tsx @@ -92,7 +92,7 @@ const CGroupPersonOptions: React.FC = ({ cgroup, onSelectPerson, }) => { - const { setDialogBoxAttributesV2 } = useAppContext(); + const { showMiniDialog } = useAppContext(); const [openAddNameInput, setOpenAddNameInput] = useState(false); @@ -103,17 +103,16 @@ const CGroupPersonOptions: React.FC = ({ ); const handleDeletePerson = () => - setDialogBoxAttributesV2({ + showMiniDialog({ title: pt("Reset person?"), - content: pt( + message: pt( "The name, face groupings and suggestions for this person will be reset", ), - close: { text: t("cancel") }, - proceed: { + continue: { text: t("reset"), + color: "primary", action: deletePerson, }, - buttonDirection: "row", }); const deletePerson = useWrapAsyncOperation(async () => { diff --git a/web/packages/new/photos/components/z-index.tsx b/web/packages/new/photos/components/z-index.tsx new file mode 100644 index 0000000000..a988a362b6 --- /dev/null +++ b/web/packages/new/photos/components/z-index.tsx @@ -0,0 +1,17 @@ +/** + * PhotoSwipe sets the zIndex of its "pswp" class to 1500. We need to go higher + * than that for our drawers and dialogs to get them to show above it. + */ +export const photoSwipeZIndex = 1500; + +/** + * The file info drawer needs to be higher than the photo viewer. + */ +export const fileInfoDrawerZIndex = photoSwipeZIndex + 1; + +/** + * Dialogs (not necessarily always) need to be higher still so to ensure they + * are visible above the drawer in case they are shown in response to some + * action taken in the file info drawer. + */ +export const photosDialogZIndex = 1600; diff --git a/web/packages/new/photos/types/context.ts b/web/packages/new/photos/types/context.ts index 2235c35c11..993bab29e5 100644 --- a/web/packages/new/photos/types/context.ts +++ b/web/packages/new/photos/types/context.ts @@ -17,10 +17,10 @@ export type AppContextT = AccountsContextT & { * Hide the global activity indicator. */ finishLoading: () => void; + onGenericError: (error: unknown) => void; somethingWentWrong: () => void; setDialogMessage: SetDialogBoxAttributes; setNotificationAttributes: SetNotificationAttributes; - onGenericError: (error: unknown) => void; closeMessageDialog: () => void; mapEnabled: boolean; updateMapEnabled: (enabled: boolean) => Promise; diff --git a/web/packages/shared/themes/components.ts b/web/packages/shared/themes/components.ts index d117d9043a..d807f02f45 100644 --- a/web/packages/shared/themes/components.ts +++ b/web/packages/shared/themes/components.ts @@ -52,7 +52,7 @@ export const getComponents = ( // This is not a great choice either, usually most dialogs, for // one reason or the other, will need to customize this padding // anyway. But not resetting it to 16px leaves it at the MUI - // defaults, which just don't work with our designs. + // defaults, which just doesn't work well with our designs. "& .MuiDialogTitle-root": { // MUI default is '16px 24px'. padding: "16px", @@ -66,9 +66,9 @@ export const getComponents = ( overflowY: "auto", }, "& .MuiDialogActions-root": { - // MUI default is way since they cluster the buttons to the - // right, our designs usually want the buttons to align with - // the heading / content. + // MUI default is way off for us since they cluster the + // buttons to the right, while our designs usually want the + // buttons to align with the heading / content. padding: "16px", }, ".MuiDialogTitle-root + .MuiDialogContent-root": {