diff --git a/web/apps/photos/src/components/Collections/AllCollections/content.tsx b/web/apps/photos/src/components/Collections/AllCollections/content.tsx index 27a7bc733c..5b8be1e863 100644 --- a/web/apps/photos/src/components/Collections/AllCollections/content.tsx +++ b/web/apps/photos/src/components/Collections/AllCollections/content.tsx @@ -1,8 +1,8 @@ import { - AllCollectionTile, + CollectionTileButton, ItemCard, LargeTileTextOverlay, -} from "@/new/photos/components/ItemCards"; +} from "@/new/photos/components/Tiles"; import type { CollectionSummary } from "@/new/photos/services/collection/ui"; import { FlexWrapper } from "@ente/shared/components/Container"; import useWindowSize from "@ente/shared/hooks/useWindowSize"; @@ -68,7 +68,7 @@ const AllCollectionRow = React.memo(
{collectionRow.map((item: any) => ( - = ({ +const CollectionButton: React.FC = ({ onCollectionClick, collectionSummary, isScrolling, }) => ( onCollectionClick(collectionSummary.id)} isScrolling={isScrolling} diff --git a/web/apps/photos/src/components/Collections/CollectionSelector.tsx b/web/apps/photos/src/components/Collections/CollectionSelector.tsx deleted file mode 100644 index 12109da32a..0000000000 --- a/web/apps/photos/src/components/Collections/CollectionSelector.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import type { Collection } from "@/media/collection"; -import { - AllCollectionTile, - ItemCard, - ItemTileOverlay, - LargeTileTextOverlay, -} from "@/new/photos/components/ItemCards"; -import { - canAddToCollection, - canMoveToCollection, - CollectionSummaryOrder, - type CollectionSummaries, - type CollectionSummary, -} from "@/new/photos/services/collection/ui"; -import { FlexWrapper } from "@ente/shared/components/Container"; -import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleWithCloseButton"; -import { - Dialog, - DialogContent, - styled, - Typography, - useMediaQuery, -} from "@mui/material"; -import { t } from "i18next"; -import { useEffect, useState } from "react"; - -export enum CollectionSelectorIntent { - upload, - add, - move, - restore, - unhide, -} - -export interface CollectionSelectorAttributes { - callback: (collection: Collection) => void; - showNextModal: () => void; - /** - * The {@link intent} modifies the title of the dialog, and also filters - * the list of collections the user can select from appropriately. - */ - intent: CollectionSelectorIntent; - fromCollection?: number; - onCancel?: () => void; -} - -interface CollectionSelectorProps { - open: boolean; - onClose: () => void; - attributes: CollectionSelectorAttributes; - collectionSummaries: CollectionSummaries; - /** - * A function to map from a collection ID to a {@link Collection}. - * - * This is invoked when the user makes a selection, to convert the ID of the - * selected collection into a collection object that can be passed to the - * {@link callback} attribute of {@link CollectionSelectorAttributes}. - */ - collectionForCollectionID: (collectionID: number) => Promise; -} - -/** - * A dialog allowing the user to select one of their existing collections or - * create a new one. - */ -export const CollectionSelector: React.FC = ({ - attributes, - collectionSummaries, - collectionForCollectionID, - ...props -}) => { - // Make the dialog fullscreen if the screen is <= the dialog's max width. - const isFullScreen = useMediaQuery("(max-width: 494px)"); - - const [collectionsToShow, setCollectionsToShow] = useState< - CollectionSummary[] - >([]); - - useEffect(() => { - if (!attributes || !props.open) { - return; - } - const main = async () => { - const collectionsToShow = [...collectionSummaries.values()] - ?.filter(({ id, type }) => { - if (id === attributes.fromCollection) { - return false; - } else if ( - attributes.intent === CollectionSelectorIntent.add - ) { - return canAddToCollection(type); - } else if ( - attributes.intent === CollectionSelectorIntent.upload - ) { - return ( - canMoveToCollection(type) || type == "uncategorized" - ); - } else if ( - attributes.intent === CollectionSelectorIntent.restore - ) { - return ( - canMoveToCollection(type) || type == "uncategorized" - ); - } else { - return canMoveToCollection(type); - } - }) - .sort((a, b) => { - return a.name.localeCompare(b.name); - }) - .sort((a, b) => { - return ( - CollectionSummaryOrder.get(a.type) - - CollectionSummaryOrder.get(b.type) - ); - }); - if (collectionsToShow.length === 0) { - props.onClose(); - attributes.showNextModal(); - } - setCollectionsToShow(collectionsToShow); - }; - main(); - }, [collectionSummaries, attributes, props.open]); - - if (!collectionsToShow?.length) { - return <>; - } - - const handleCollectionClick = async (collectionID: number) => { - attributes.callback(await collectionForCollectionID(collectionID)); - props.onClose(); - }; - - const onUserTriggeredClose = () => { - attributes.onCancel?.(); - props.onClose(); - }; - - return ( - - - {attributes.intent === CollectionSelectorIntent.upload - ? t("upload_to_album") - : attributes.intent === CollectionSelectorIntent.add - ? t("add_to_album") - : attributes.intent === CollectionSelectorIntent.move - ? t("move_to_album") - : attributes.intent === CollectionSelectorIntent.restore - ? t("restore_to_album") - : attributes.intent === - CollectionSelectorIntent.unhide - ? t("unhide_to_album") - : t("select_album")} - - - - - {collectionsToShow.map((collectionSummary) => ( - - ))} - - - - ); -}; - -export const AllCollectionMobileBreakpoint = 559; - -export const Dialog_ = styled(Dialog)(({ theme }) => ({ - "& .MuiPaper-root": { - maxWidth: "494px", - }, - "& .MuiDialogTitle-root": { - padding: "16px", - paddingRight: theme.spacing(1), - }, - "& .MuiDialogContent-root": { - padding: "16px", - }, -})); - -interface CollectionSelectorCardProps { - collectionSummary: CollectionSummary; - onCollectionClick: (collectionID: number) => void; -} - -const CollectionSelectorCard: React.FC = ({ - collectionSummary, - onCollectionClick, -}) => ( - onCollectionClick(collectionSummary.id)} - > - - {collectionSummary.name} - - -); - -interface AddCollectionButtonProps { - showNextModal: () => void; -} - -const AddCollectionButton: React.FC = ({ - showNextModal, -}) => ( - - {t("create_albums")} - + - -); - -const ImageContainer = styled(ItemTileOverlay)` - display: flex; - justify-content: center; - align-items: center; - font-size: 42px; -`; diff --git a/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx b/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx index d45aab5330..fdc2900fc8 100644 --- a/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx +++ b/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx @@ -5,8 +5,8 @@ import { } from "@/new/photos/components/Gallery/BarImpl"; import { PeopleHeader } from "@/new/photos/components/Gallery/PeopleHeader"; import { + areOnlySystemCollections, collectionsSortBy, - hasNonSystemCollections, isSystemCollection, shouldShowOnCollectionBar, type CollectionsSortBy, @@ -122,7 +122,7 @@ export const GalleryBarAndListHeader: React.FC = ({ const shouldBeHidden = useMemo( () => shouldHide || - (!hasNonSystemCollections(toShowCollectionSummaries) && + (areOnlySystemCollections(toShowCollectionSummaries) && activeCollectionID === ALL_SECTION), [shouldHide, toShowCollectionSummaries, activeCollectionID], ); diff --git a/web/apps/photos/src/components/ExportPendingList.tsx b/web/apps/photos/src/components/ExportPendingList.tsx index 371d295165..51b8ac87e9 100644 --- a/web/apps/photos/src/components/ExportPendingList.tsx +++ b/web/apps/photos/src/components/ExportPendingList.tsx @@ -1,4 +1,4 @@ -import { ItemCard, PreviewItemTile } from "@/new/photos/components/ItemCards"; +import { ItemCard, PreviewItemTile } from "@/new/photos/components/Tiles"; import { EnteFile } from "@/new/photos/types/file"; import { FlexWrapper } from "@ente/shared/components/Container"; import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index f9e0e5c728..65e6b6ed62 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -3,6 +3,7 @@ import log from "@/base/log"; import type { CollectionMapping, Electron, ZipItem } from "@/base/types/ipc"; import type { Collection } from "@/media/collection"; import { CollectionMappingChoiceDialog } from "@/new/photos/components/CollectionMappingChoiceDialog"; +import type { CollectionSelectorAttributes } from "@/new/photos/components/CollectionSelector"; import { exportMetadataDirectoryName } from "@/new/photos/services/export"; import type { FileAndPath, @@ -13,7 +14,6 @@ import { firstNonEmpty } from "@/utils/array"; import { ensure } from "@/utils/ensure"; import { CustomError } from "@ente/shared/error"; import DiscFullIcon from "@mui/icons-material/DiscFull"; -import { CollectionSelectorIntent } from "components/Collections/CollectionSelector"; import UserNameInputDialog from "components/UserNameInputDialog"; import { t } from "i18next"; import isElectron from "is-electron"; @@ -36,12 +36,7 @@ import type { } from "services/upload/uploadManager"; import uploadManager from "services/upload/uploadManager"; import watcher from "services/watch"; -import { - SetCollectionSelectorAttributes, - SetCollections, - SetFiles, - SetLoading, -} from "types/gallery"; +import { SetCollections, SetFiles, SetLoading } from "types/gallery"; import { NotificationAttributes } from "types/Notification"; import { getOrCreateAlbum } from "utils/collection"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; @@ -64,9 +59,17 @@ enum PICKED_UPLOAD_TYPE { interface Props { syncWithRemote: (force?: boolean, silent?: boolean) => Promise; - closeCollectionSelector?: () => void; closeUploadTypeSelector: () => void; - setCollectionSelectorAttributes?: SetCollectionSelectorAttributes; + /** + * Show the collection selector with the given {@link attributes}. + */ + onOpenCollectionSelector?: ( + attributes: CollectionSelectorAttributes, + ) => void; + /** + * Close the collection selector if it is open. + */ + onCloseCollectionSelector?: () => void; setCollectionNamerAttributes?: SetCollectionNamerAttributes; setLoading: SetLoading; setShouldDisableDropzone: (value: boolean) => void; @@ -460,17 +463,17 @@ export default function Uploader({ showCollectionCreateModal(importSuggestion.rootFolderName); } - props.setCollectionSelectorAttributes({ - callback: uploadFilesToExistingCollection, + props.onOpenCollectionSelector({ + action: "upload", + onSelectCollection: uploadFilesToExistingCollection, + onCreateCollection: showNextModal, onCancel: handleCollectionSelectorCancel, - showNextModal, - intent: CollectionSelectorIntent.upload, }); })(); }, [webFiles, desktopFiles, desktopFilePaths, desktopZipItems]); const preCollectionCreationAction = async () => { - props.closeCollectionSelector?.(); + props.onCloseCollectionSelector?.(); props.setShouldDisableDropzone(!uploadManager.shouldAllowNewUpload()); setUploadStage(UPLOAD_STAGES.START); setUploadProgressView(true); diff --git a/web/apps/photos/src/components/pages/gallery/SelectedFileOptions.tsx b/web/apps/photos/src/components/pages/gallery/SelectedFileOptions.tsx index bae62e864f..a876ae4ae3 100644 --- a/web/apps/photos/src/components/pages/gallery/SelectedFileOptions.tsx +++ b/web/apps/photos/src/components/pages/gallery/SelectedFileOptions.tsx @@ -1,5 +1,6 @@ import { SelectionBar } from "@/base/components/Navbar"; import type { Collection } from "@/media/collection"; +import type { CollectionSelectorAttributes } from "@/new/photos/components/CollectionSelector"; import type { GalleryBarMode } from "@/new/photos/components/Gallery/BarImpl"; import { FluidContainer } from "@ente/shared/components/Container"; import ClockIcon from "@mui/icons-material/AccessTime"; @@ -15,11 +16,9 @@ import UnArchiveIcon from "@mui/icons-material/Unarchive"; import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"; import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"; import { Box, IconButton, Stack, Tooltip } from "@mui/material"; -import { CollectionSelectorIntent } from "components/Collections/CollectionSelector"; import { t } from "i18next"; import { AppContext } from "pages/_app"; import { useContext } from "react"; -import { SetCollectionSelectorAttributes } from "types/gallery"; import { ALL_SECTION, ARCHIVE_SECTION, @@ -36,7 +35,15 @@ interface Props { ) => (...args: any[]) => void; handleFileOps: (opsType: FILE_OPS_TYPE) => (...args: any[]) => void; showCreateCollectionModal: (opsType: COLLECTION_OPS_TYPE) => () => void; - setCollectionSelectorAttributes: SetCollectionSelectorAttributes; + /** + * Callback to open a dialog where the user can choose a collection. + * + * The reason for opening the dialog and other properties are passed as the + * {@link attributes} argument. + */ + onOpenCollectionSelector: ( + attributes: CollectionSelectorAttributes, + ) => void; count: number; ownCount: number; clearSelection: () => void; @@ -52,7 +59,7 @@ interface Props { const SelectedFileOptions = ({ showCreateCollectionModal, - setCollectionSelectorAttributes, + onOpenCollectionSelector, handleCollectionOps, handleFileOps, selectedCollection, @@ -72,12 +79,14 @@ const SelectedFileOptions = ({ const peopleMode = barMode == "people"; const addToCollection = () => - setCollectionSelectorAttributes({ - callback: handleCollectionOps(COLLECTION_OPS_TYPE.ADD), - showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.ADD), - intent: CollectionSelectorIntent.add, - fromCollection: - !isInSearchMode && !peopleMode ? activeCollectionID : undefined, + onOpenCollectionSelector({ + action: "add", + onSelectCollection: handleCollectionOps(COLLECTION_OPS_TYPE.ADD), + onCreateCollection: showCreateCollectionModal( + COLLECTION_OPS_TYPE.ADD, + ), + ignoredCollectionID: + isInSearchMode || peopleMode ? undefined : activeCollectionID, }); const trashHandler = () => @@ -98,12 +107,14 @@ const SelectedFileOptions = ({ }); const restoreHandler = () => - setCollectionSelectorAttributes({ - callback: handleCollectionOps(COLLECTION_OPS_TYPE.RESTORE), - showNextModal: showCreateCollectionModal( + onOpenCollectionSelector({ + action: "restore", + onSelectCollection: handleCollectionOps( + COLLECTION_OPS_TYPE.RESTORE, + ), + onCreateCollection: showCreateCollectionModal( COLLECTION_OPS_TYPE.RESTORE, ), - intent: CollectionSelectorIntent.restore, }); const removeFromCollectionHandler = () => { @@ -141,22 +152,24 @@ const SelectedFileOptions = ({ }; const moveToCollection = () => { - setCollectionSelectorAttributes({ - callback: handleCollectionOps(COLLECTION_OPS_TYPE.MOVE), - showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.MOVE), - intent: CollectionSelectorIntent.move, - fromCollection: - !isInSearchMode && !peopleMode ? activeCollectionID : undefined, + onOpenCollectionSelector({ + action: "move", + onSelectCollection: handleCollectionOps(COLLECTION_OPS_TYPE.MOVE), + onCreateCollection: showCreateCollectionModal( + COLLECTION_OPS_TYPE.MOVE, + ), + ignoredCollectionID: + isInSearchMode || peopleMode ? undefined : activeCollectionID, }); }; const unhideToCollection = () => { - setCollectionSelectorAttributes({ - callback: handleCollectionOps(COLLECTION_OPS_TYPE.UNHIDE), - showNextModal: showCreateCollectionModal( + onOpenCollectionSelector({ + action: "unhide", + onSelectCollection: handleCollectionOps(COLLECTION_OPS_TYPE.UNHIDE), + onCreateCollection: showCreateCollectionModal( COLLECTION_OPS_TYPE.UNHIDE, ), - intent: CollectionSelectorIntent.unhide, }); }; diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index ff7243e133..34b004ce9f 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -3,6 +3,10 @@ import { NavbarBase } from "@/base/components/Navbar"; import { useIsMobileWidth } from "@/base/hooks"; import log from "@/base/log"; import type { Collection } from "@/media/collection"; +import { + CollectionSelector, + type CollectionSelectorAttributes, +} from "@/new/photos/components/CollectionSelector"; import { PeopleEmptyState, SearchResultsHeader, @@ -16,7 +20,7 @@ import { import { WhatsNew } from "@/new/photos/components/WhatsNew"; import { shouldShowWhatsNew } from "@/new/photos/services/changelog"; import type { CollectionSummaries } from "@/new/photos/services/collection/ui"; -import { hasNonSystemCollections } from "@/new/photos/services/collection/ui"; +import { areOnlySystemCollections } from "@/new/photos/services/collection/ui"; import downloadManager from "@/new/photos/services/download"; import { getLocalFiles, @@ -67,10 +71,6 @@ import AuthenticateUserModal from "components/AuthenticateUserModal"; import CollectionNamer, { CollectionNamerAttributes, } from "components/Collections/CollectionNamer"; -import { - CollectionSelector, - CollectionSelectorAttributes, -} from "components/Collections/CollectionSelector"; import { GalleryBarAndListHeader } from "components/Collections/GalleryBarAndListHeader"; import ExportModal from "components/ExportModal"; import { @@ -96,6 +96,7 @@ import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import { createContext, + useCallback, useContext, useEffect, useMemo, @@ -212,9 +213,6 @@ export default function Gallery() { }); const [planModalView, setPlanModalView] = useState(false); const [blockingLoad, setBlockingLoad] = useState(false); - const [collectionSelectorAttributes, setCollectionSelectorAttributes] = - useState(null); - const [collectionSelectorView, setCollectionSelectorView] = useState(false); const [collectionNamerAttributes, setCollectionNamerAttributes] = useState(null); const [collectionNamerView, setCollectionNamerView] = useState(false); @@ -350,6 +348,10 @@ export default function Gallery() { new Set(), ); + const [openCollectionSelector, setOpenCollectionSelector] = useState(false); + const [collectionSelectorAttributes, setCollectionSelectorAttributes] = + useState(); + const router = useRouter(); // Ensure that the keys in local storage are not malformed by verifying that @@ -474,10 +476,6 @@ export default function Gallery() { setEmailList(emailList); }, [user, collections, familyData]); - useEffect(() => { - collectionSelectorAttributes && setCollectionSelectorView(true); - }, [collectionSelectorAttributes]); - useEffect(() => { collectionNamerAttributes && setCollectionNamerView(true); }, [collectionNamerAttributes]); @@ -700,7 +698,7 @@ export default function Gallery() { if ( sidebarView || uploadTypeSelectorView || - collectionSelectorView || + openCollectionSelector || collectionNamerView || fixCreationTimeView || planModalView || @@ -943,7 +941,7 @@ export default function Gallery() { (ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => { startLoading(); try { - setCollectionSelectorView(false); + setOpenCollectionSelector(false); const selectedFiles = getSelectedFiles(selected, filteredData); const toProcessFiles = ops === COLLECTION_OPS_TYPE.REMOVE @@ -1067,10 +1065,6 @@ export default function Gallery() { setUploadTypeSelectorIntent(intent ?? "upload"); }; - const closeCollectionSelector = () => { - setCollectionSelectorView(false); - }; - const openExportModal = () => { setExportModalView(true); }; @@ -1112,6 +1106,19 @@ export default function Gallery() { setBarMode("people"); }; + const handleOpenCollectionSelector = useCallback( + (attributes: CollectionSelectorAttributes) => { + setCollectionSelectorAttributes(attributes); + setOpenCollectionSelector(true); + }, + [], + ); + + const handleCloseCollectionSelector = useCallback( + () => setOpenCollectionSelector(false), + [], + ); + if (!collectionSummaries || !filteredData) { return
; } @@ -1173,10 +1180,10 @@ export default function Gallery() { attributes={collectionNamerAttributes} /> findCollectionCreatingUncategorizedIfNeeded( collections, @@ -1244,29 +1251,20 @@ export default function Gallery() { >; export type SetCollections = React.Dispatch>; export type SetLoading = React.Dispatch>; -export type SetCollectionSelectorAttributes = React.Dispatch< - React.SetStateAction ->; export type SetFilesDownloadProgressAttributes = ( value: | Partial diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index a007165892..fbf70c191d 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -303,7 +303,6 @@ "LARGER_THAN_AVAILABLE_STORAGE_INFO": "These files were not uploaded as they exceed the maximum size limit for your storage plan", "TOO_LARGE_INFO": "These files were not uploaded as they exceed our maximum file size limit", "THUMBNAIL_GENERATION_FAILED_INFO": "These files were uploaded, but unfortunately we could not generate the thumbnails for them.", - "select_album": "Select album", "upload_to_album": "Upload to album", "add_to_album": "Add to album", "move_to_album": "Move to album", diff --git a/web/packages/new/photos/components/CollectionMappingChoiceDialog.tsx b/web/packages/new/photos/components/CollectionMappingChoiceDialog.tsx index 0281d040b2..5c16915dc9 100644 --- a/web/packages/new/photos/components/CollectionMappingChoiceDialog.tsx +++ b/web/packages/new/photos/components/CollectionMappingChoiceDialog.tsx @@ -12,9 +12,12 @@ import { import { t } from "i18next"; import React from "react"; import { SpaceBetweenFlex } from "./mui"; -import { DialogCloseIconButton, type DialogVisiblityProps } from "./mui/Dialog"; +import { + DialogCloseIconButton, + type DialogVisibilityProps, +} from "./mui/Dialog"; -type CollectionMappingChoiceModalProps = DialogVisiblityProps & { +type CollectionMappingChoiceModalProps = DialogVisibilityProps & { didSelect: (mapping: CollectionMapping) => void; }; diff --git a/web/packages/new/photos/components/CollectionSelector.tsx b/web/packages/new/photos/components/CollectionSelector.tsx new file mode 100644 index 0000000000..e7690be1bb --- /dev/null +++ b/web/packages/new/photos/components/CollectionSelector.tsx @@ -0,0 +1,253 @@ +import type { Collection } from "@/media/collection"; +import { + CollectionTileButton, + ItemCard, + ItemTileOverlay, + LargeTileTextOverlay, +} from "@/new/photos/components/Tiles"; +import { + canAddToCollection, + canMoveToCollection, + CollectionSummaryOrder, + type CollectionSummaries, + type CollectionSummary, +} from "@/new/photos/services/collection/ui"; +import { ensure } from "@/utils/ensure"; +import { + Dialog, + DialogContent, + DialogTitle, + styled, + Typography, + useMediaQuery, +} from "@mui/material"; +import { t } from "i18next"; +import React, { useEffect, useState } from "react"; +import { SpaceBetweenFlex } from "./mui"; +import { + DialogCloseIconButton, + type DialogVisibilityProps, +} from "./mui/Dialog"; + +export type CollectionSelectorAction = + | "upload" + | "add" + | "move" + | "restore" + | "unhide"; + +export interface CollectionSelectorAttributes { + /** + * The {@link action} modifies the title of the dialog, and also removes + * some system collections that don't might not make sense for that + * particular action. + */ + action: CollectionSelectorAction; + /** + * Callback invoked when the user selects one the existing collections + * listed in the dialog. + */ + onSelectCollection: (collection: Collection) => void; + /** + * Callback invoked when the user selects the option to create a new + * collection. + */ + onCreateCollection: () => void; + /** + * Callback invoked when the user cancels the collection selection dialog. + */ + onCancel?: () => void; + /** + * Some actions, like "add" and "move", happen in the context of an existing + * collection. In such cases, their ID can be set as the + * {@link ignoredCollectionID} to omit showing them again in the list of + * collections. + */ + ignoredCollectionID?: number | undefined; +} + +type CollectionSelectorProps = DialogVisibilityProps & { + /** + * The same {@link CollectionSelector} can be used for different + * purposes by customizing the {@link attributes} prop before opening it. + */ + attributes: CollectionSelectorAttributes | undefined; + /** + * The collections to list. + */ + collectionSummaries: CollectionSummaries; + /** + * A function to map from a collection ID to a {@link Collection}. + * + * This is invoked when the user makes a selection, to convert the ID of the + * selected collection into a collection object that can be passed to the + * {@link callback} attribute of {@link CollectionSelectorAttributes}. + */ + collectionForCollectionID: (collectionID: number) => Promise; +}; + +/** + * A dialog allowing the user to select one of their existing collections or + * create a new one. + */ +export const CollectionSelector: React.FC = ({ + open, + onClose, + attributes, + collectionSummaries, + collectionForCollectionID, +}) => { + // Make the dialog fullscreen if the screen is <= the dialog's max width. + const isFullScreen = useMediaQuery("(max-width: 494px)"); + + const [filteredCollections, setFilteredCollections] = useState< + CollectionSummary[] + >([]); + + useEffect(() => { + if (!attributes || !open) { + return; + } + + const collections = [...collectionSummaries.values()] + .filter(({ id, type }) => { + if (id === attributes.ignoredCollectionID) { + return false; + } else if (attributes.action == "add") { + return canAddToCollection(type); + } else if (attributes.action == "upload") { + return canMoveToCollection(type) || type == "uncategorized"; + } else if (attributes.action == "restore") { + return canMoveToCollection(type) || type == "uncategorized"; + } else { + return canMoveToCollection(type); + } + }) + .sort((a, b) => { + return a.name.localeCompare(b.name); + }) + .sort((a, b) => { + return ( + ensure(CollectionSummaryOrder.get(a.type)) - + ensure(CollectionSummaryOrder.get(b.type)) + ); + }); + + if (collections.length === 0) { + onClose(); + attributes.onCreateCollection(); + } + + setFilteredCollections(collections); + }, [collectionSummaries, attributes, open, onClose]); + + if (!filteredCollections.length) { + return <>; + } + + if (!attributes) { + return <>; + } + + const { action, onSelectCollection, onCancel, onCreateCollection } = + attributes; + + const handleCollectionClick = async (collectionID: number) => { + onSelectCollection(await collectionForCollectionID(collectionID)); + onClose(); + }; + + const handleClose = () => { + onCancel?.(); + onClose(); + }; + + return ( + + + + {titleForAction(action)} + + + + + + + {filteredCollections.map((collectionSummary) => ( + + ))} + + + ); +}; + +const DialogContent_ = styled(DialogContent)` + display: flex; + flex-wrap: wrap; + gap: 4px; +`; + +const titleForAction = (action: CollectionSelectorAction) => { + switch (action) { + case "upload": + return t("upload_to_album"); + case "add": + return t("add_to_album"); + case "move": + return t("move_to_album"); + case "restore": + return t("restore_to_album"); + case "unhide": + return t("unhide_to_album"); + } +}; + +interface CollectionButtonProps { + collectionSummary: CollectionSummary; + onCollectionClick: (collectionID: number) => void; +} + +const CollectionButton: React.FC = ({ + collectionSummary, + onCollectionClick, +}) => ( + onCollectionClick(collectionSummary.id)} + > + + {collectionSummary.name} + + +); + +interface AddCollectionButtonProps { + onClick: () => void; +} + +const AddCollectionButton: React.FC = ({ + onClick, +}) => ( + + {t("create_albums")} + + + +); + +const PlusOverlay = styled(ItemTileOverlay)` + display: flex; + justify-content: center; + align-items: center; + font-size: 42px; +`; diff --git a/web/packages/new/photos/components/Gallery/BarImpl.tsx b/web/packages/new/photos/components/Gallery/BarImpl.tsx index 1c1ef216e8..288ddd95c6 100644 --- a/web/packages/new/photos/components/Gallery/BarImpl.tsx +++ b/web/packages/new/photos/components/Gallery/BarImpl.tsx @@ -1,15 +1,15 @@ import { useIsMobileWidth } from "@/base/hooks"; import { CollectionsSortOptions } from "@/new/photos/components/CollectionsSortOptions"; -import { - BarItemTile, - ItemCard, - TileTextOverlay, -} from "@/new/photos/components/ItemCards"; import { FilledIconButton } from "@/new/photos/components/mui"; import { IMAGE_CONTAINER_MAX_WIDTH, MIN_COLUMNS, } from "@/new/photos/components/PhotoList"; +import { + BarItemTile, + ItemCard, + TileTextOverlay, +} from "@/new/photos/components/Tiles"; import { UnstyledButton } from "@/new/photos/components/UnstyledButton"; import type { CollectionSummary, diff --git a/web/packages/new/photos/components/NameInputDialog.tsx b/web/packages/new/photos/components/NameInputDialog.tsx index 636c69f2a4..a270a861a8 100644 --- a/web/packages/new/photos/components/NameInputDialog.tsx +++ b/web/packages/new/photos/components/NameInputDialog.tsx @@ -5,9 +5,9 @@ import SingleInputForm, { import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { t } from "i18next"; import React from "react"; -import { type DialogVisiblityProps } from "./mui/Dialog"; +import { type DialogVisibilityProps } from "./mui/Dialog"; -type NameInputDialogProps = DialogVisiblityProps & { +type NameInputDialogProps = DialogVisibilityProps & { /** Title of the dialog. */ title: string; /** Placeholder string to show in the text input when it is empty. */ diff --git a/web/packages/new/photos/components/SearchBar.tsx b/web/packages/new/photos/components/SearchBar.tsx index f934d1307b..cb78f649a7 100644 --- a/web/packages/new/photos/components/SearchBar.tsx +++ b/web/packages/new/photos/components/SearchBar.tsx @@ -1,6 +1,6 @@ import { assertionFailed } from "@/base/assert"; import { useIsMobileWidth } from "@/base/hooks"; -import { ItemCard, PreviewItemTile } from "@/new/photos/components/ItemCards"; +import { ItemCard, PreviewItemTile } from "@/new/photos/components/Tiles"; import { isMLSupported, mlStatusSnapshot, diff --git a/web/packages/new/photos/components/ItemCards.tsx b/web/packages/new/photos/components/Tiles.tsx similarity index 85% rename from web/packages/new/photos/components/ItemCards.tsx rename to web/packages/new/photos/components/Tiles.tsx index 8c73fb3ad6..c687f26224 100644 --- a/web/packages/new/photos/components/ItemCards.tsx +++ b/web/packages/new/photos/components/Tiles.tsx @@ -7,6 +7,7 @@ import { type EnteFile } from "@/new/photos/types/file"; import { styled } from "@mui/material"; import React, { useEffect, useState } from "react"; import { faceCrop } from "../services/ml"; +import { UnstyledButton } from "./UnstyledButton"; interface ItemCardProps { /** @@ -135,10 +136,32 @@ export const BarItemTile = styled(ItemTile)` `; /** - * A large 150x150 TileComponent used when showing the list of all collections - * in the all collections view. + * A variant of {@link ItemTile} meant for use when the tile is interactable. */ -export const AllCollectionTile = styled(ItemTile)` +export const ItemTileButton = styled(UnstyledButton)` + /* Buttons reset this to center */ + text-align: inherit; + + /* Rest of this is mostly verbatim from ItemTile ... */ + + display: flex; + /* Act as container for the absolutely positioned ItemTileOverlays. */ + position: relative; + border-radius: 4px; + overflow: hidden; + & > img { + object-fit: cover; + width: 100%; + height: 100%; + pointer-events: none; + } +`; + +/** + * A large 150x150 TileComponent used when showing the list of collections in + * the all collections view and in the collection selector. + */ +export const CollectionTileButton = styled(ItemTileButton)` width: 150px; height: 150px; `; @@ -171,7 +194,7 @@ export const TileTextOverlay = styled(ItemTileOverlay)` /** * A variation of {@link TileTextOverlay} for use with larger tiles like the - * {@link AllCollectionTile}. + * {@link CollectionTile}. */ export const LargeTileTextOverlay = styled(ItemTileOverlay)` padding: 8px; diff --git a/web/packages/new/photos/components/mui/Dialog.tsx b/web/packages/new/photos/components/mui/Dialog.tsx index 56e86aba88..59cf2084d9 100644 --- a/web/packages/new/photos/components/mui/Dialog.tsx +++ b/web/packages/new/photos/components/mui/Dialog.tsx @@ -6,14 +6,14 @@ import React from "react"; /** * Common props to control the display of a dialog-like component. */ -export interface DialogVisiblityProps { +export interface DialogVisibilityProps { /** If `true`, the dialog is shown. */ open: boolean; /** Callback fired when the dialog wants to be closed. */ onClose: () => void; } -type DialogCloseIconButtonProps = Omit; +type DialogCloseIconButtonProps = Omit; /** * A convenience {@link IconButton} commonly needed on {@link Dialog}s, at the diff --git a/web/packages/new/photos/services/collection/ui.ts b/web/packages/new/photos/services/collection/ui.ts index b17c48b1d0..4258bf288c 100644 --- a/web/packages/new/photos/services/collection/ui.ts +++ b/web/packages/new/photos/services/collection/ui.ts @@ -87,24 +87,13 @@ const systemCSTypes = new Set([ ]); const addToDisabledCSTypes = new Set([ - "all", - "archive", + ...systemCSTypes, "incomingShareViewer", - "trash", - "uncategorized", - "defaultHidden", - "hiddenItems", ]); const moveToDisabledCSTypes = new Set([ - "all", - "archive", - "incomingShareViewer", + ...addToDisabledCSTypes, "incomingShareCollaborator", - "trash", - "uncategorized", - "defaultHidden", - "hiddenItems", ]); const hideFromCollectionBarCSTypes = new Set([ @@ -114,23 +103,21 @@ const hideFromCollectionBarCSTypes = new Set([ "defaultHidden", ]); -export const hasNonSystemCollections = ( - collectionSummaries: CollectionSummaries, -) => { - for (const collectionSummary of collectionSummaries.values()) { - if (!isSystemCollection(collectionSummary.type)) return true; - } - return false; -}; +export const isSystemCollection = (type: CollectionSummaryType) => + systemCSTypes.has(type); -export const canMoveToCollection = (type: CollectionSummaryType) => - !moveToDisabledCSTypes.has(type); +export const areOnlySystemCollections = ( + collectionSummaries: CollectionSummaries, +) => + [...collectionSummaries.values()].every(({ type }) => + isSystemCollection(type), + ); export const canAddToCollection = (type: CollectionSummaryType) => !addToDisabledCSTypes.has(type); -export const isSystemCollection = (type: CollectionSummaryType) => - systemCSTypes.has(type); +export const canMoveToCollection = (type: CollectionSummaryType) => + !moveToDisabledCSTypes.has(type); export const shouldShowOnCollectionBar = (type: CollectionSummaryType) => !hideFromCollectionBarCSTypes.has(type);