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 (
+
+ );
+};
+
+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);