diff --git a/web/apps/cast/src/pages/slideshow.tsx b/web/apps/cast/src/pages/slideshow.tsx index 2bc1b32eff..4ecdb61e2a 100644 --- a/web/apps/cast/src/pages/slideshow.tsx +++ b/web/apps/cast/src/pages/slideshow.tsx @@ -9,7 +9,7 @@ import { imageURLGenerator } from "services/render"; const Page: React.FC = () => { const [isEmpty, setIsEmpty] = useState(false); - const [imageURL, setImageURL] = useState(); + const [imageURL, setImageURL] = useState(""); const router = useRouter(); diff --git a/web/apps/photos/src/components/Collections/CollectionShare.tsx b/web/apps/photos/src/components/Collections/CollectionShare.tsx index fd17723720..e09fe29fb4 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare.tsx @@ -1347,9 +1347,7 @@ const ManagePublicShareOptions: React.FC = ({ setBlockingLoad, onRemotePull, }) => { - const [errorMessage, setErrorMessage] = useState( - undefined, - ); + const [errorMessage, setErrorMessage] = useState(""); const [copied, handleCopyLink] = useClipboardCopy(resolvedURL); @@ -1362,7 +1360,7 @@ const ManagePublicShareOptions: React.FC = ({ updates: UpdatePublicURLAttributes, ) => { setBlockingLoad(true); - setErrorMessage(undefined); + setErrorMessage(""); try { setPublicURL(await updatePublicURL(collection.id, updates)); void onRemotePull({ silent: true }); @@ -1375,7 +1373,7 @@ const ManagePublicShareOptions: React.FC = ({ }; const handleRemovePublicLink = async () => { setBlockingLoad(true); - setErrorMessage(undefined); + setErrorMessage(""); try { await deleteShareURL(collection.id); setPublicURL(undefined); diff --git a/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx b/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx index 96b36a5a02..1bf7574f06 100644 --- a/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx +++ b/web/apps/photos/src/components/Collections/GalleryBarAndListHeader.tsx @@ -3,7 +3,7 @@ import { CollectionShare, type CollectionShareProps, } from "components/Collections/CollectionShare"; -import type { TimeStampListItem } from "components/FileList"; +import type { FileListHeaderOrFooter } from "components/FileList"; import { useModalVisibility } from "ente-base/components/utils/modal"; import { isSaveCancelled, @@ -49,7 +49,7 @@ type GalleryBarAndListHeaderProps = Omit< barCollectionSummaries: CollectionSummaries; activeCollection: Collection; setActiveCollectionID: (collectionID: number) => void; - setPhotoListHeader: (value: TimeStampListItem) => void; + setFileListHeader: (header: FileListHeaderOrFooter) => void; saveGroups: SaveGroup[]; } & Pick & Pick< @@ -62,11 +62,11 @@ type GalleryBarAndListHeaderProps = Omit< * dialogs that might be triggered by actions on either the bar or the header.. * * This component manages the sticky horizontally scrollable bar shown at the - * top of the gallery, AND the non-sticky header shown below the bar, at the top - * of the actual list of items. + * top of the gallery, AND the (non-sticky) header shown below the bar, at the + * top of the actual list of items. * * These are disparate views - indeed, the list header is not even a child of - * this component but is instead proxied via {@link setPhotoListHeader}. Still, + * this component but is instead proxied via {@link setFileListHeader}. Still, * having this intermediate wrapper component allows us to move some of the * common concerns shared by both the gallery bar and list header (e.g. some * dialogs that can be invoked from both places) into this file instead of @@ -95,7 +95,7 @@ export const GalleryBarAndListHeader: React.FC< onRemotePull, onAddSaveGroup, onSelectPerson, - setPhotoListHeader, + setFileListHeader, }) => { const { show: showAllAlbums, props: allAlbumsVisibilityProps } = useModalVisibility(); @@ -134,7 +134,7 @@ export const GalleryBarAndListHeader: React.FC< useEffect(() => { if (shouldHide) return; - setPhotoListHeader({ + setFileListHeader({ item: mode != "people" ? ( ), - tag: "header", height: 68, }); }, [ diff --git a/web/apps/photos/src/components/FileList.tsx b/web/apps/photos/src/components/FileList.tsx index cf2314ef3f..e7eaa88228 100644 --- a/web/apps/photos/src/components/FileList.tsx +++ b/web/apps/photos/src/components/FileList.tsx @@ -3,7 +3,7 @@ import AlbumOutlinedIcon from "@mui/icons-material/AlbumOutlined"; import FavoriteRoundedIcon from "@mui/icons-material/FavoriteRounded"; import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined"; -import { Box, Checkbox, Link, Typography, styled } from "@mui/material"; +import { Box, Checkbox, Typography, styled } from "@mui/material"; import Avatar from "components/Avatar"; import type { LocalUser } from "ente-accounts/services/user"; import { assertionFailed } from "ente-base/assert"; @@ -31,7 +31,6 @@ import { PseudoCollectionID } from "ente-new/photos/services/collection-summary" import { t } from "i18next"; import memoize from "memoize-one"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; -import { Trans } from "react-i18next"; import { VariableSizeList as List, type ListChildComponentProps, @@ -49,18 +48,27 @@ export const SPACE_BTW_DATES = 44; const SPACE_BTW_DATES_TO_IMAGE_CONTAINER_WIDTH_RATIO = 0.244; -const FOOTER_HEIGHT = 90; -const ALBUM_FOOTER_HEIGHT = 75; -const ALBUM_FOOTER_HEIGHT_WITH_REFERRAL = 113; +/** + * A component with an explicit height suitable for being plugged in as the + * {@link header} or {@link footer} of the {@link FileList}. + */ +export interface FileListHeaderOrFooter { + /** + * The component itself. + */ + item: React.ReactNode; + /** + * The height of the component (in px). + */ + height: number; +} -export type FileListItemTag = "header" | "publicAlbumsFooter" | "date" | "file"; - -export interface TimeStampListItem { +interface TimeStampListItem { /** * An optional {@link FileListItemTag} that can be used to identify item * types for conditional behaviour. */ - tag?: FileListItemTag; + tag?: "date" | "file"; items?: FileListAnnotatedFile[]; itemStartIndex?: number; date?: string; @@ -122,6 +130,18 @@ export interface FileListProps { * another mode in which the gallery operates. */ modePlus?: GalleryBarMode | "search"; + /** + * An optional component shown before all the items in the list. + * + * It is not sticky, and scrolls along with the content of the list. + */ + header?: FileListHeaderOrFooter; + /** + * An optional component shown after all the items in the list. + * + * It is not sticky, and scrolls along with the content of the list. + */ + footer?: FileListHeaderOrFooter; /** * The logged in user, if any. * @@ -130,11 +150,13 @@ export interface FileListProps { * omit this prop. */ user?: LocalUser; - showAppDownloadBanner?: boolean; /** - * If `true`, then the current listing is showing magic search results. + * If `true`, then the default behaviour of grouping files by their date is + * suppressed. + * + * This behaviour is used when showing magic search results. */ - isMagicSearchResult?: boolean; + disableGrouping?: boolean; selectable?: boolean; setSelected: ( selected: SelectedState | ((selected: SelectedState) => SelectedState), @@ -157,16 +179,6 @@ export interface FileListProps { * omitted when running in the public albums app. */ emailByUserID?: Map; - /** - * An optional {@link TimeStampListItem} shown before all the items in the - * list. It is not sticky, and scrolls along with the content of the list. - */ - header?: TimeStampListItem; - /** - * An optional {@link TimeStampListItem} shown after all the items in the - * list. It is not sticky, and scrolls along with the content of the list. - */ - footer?: TimeStampListItem; /** * Called when the user activates the thumbnail at the given {@link index}. * @@ -185,10 +197,10 @@ export const FileList: React.FC = ({ mode, modePlus, header, + footer, user, annotatedFiles, - showAppDownloadBanner, - isMagicSearchResult, + disableGrouping, selectable, selected, setSelected, @@ -196,7 +208,6 @@ export const FileList: React.FC = ({ activePersonID, favoriteFileIDs, emailByUserID, - footer, onItemClick, }) => { const publicCollectionGalleryContext = useContext( @@ -246,7 +257,7 @@ export const FileList: React.FC = ({ if (header) { timeStampList.push(asFullSpanListItem(header)); } - if (isMagicSearchResult) { + if (disableGrouping) { noGrouping(timeStampList); } else { groupByTime(timeStampList); @@ -258,15 +269,10 @@ export const FileList: React.FC = ({ if (timeStampList.length === 1) { timeStampList.push(getEmptyListItem()); } - timeStampList.push(getVacuumItem(timeStampList)); + const footerHeight = footer?.height ?? 0; + timeStampList.push(getVacuumItem(timeStampList, footerHeight)); if (footer) { timeStampList.push(asFullSpanListItem(footer)); - } else if (showAppDownloadBanner) { - timeStampList.push(getAppDownloadFooter()); - } - - if (publicCollectionGalleryContext.credentials) { - timeStampList.push(getAlbumsFooter()); } setTimeStampList(timeStampList); @@ -283,7 +289,7 @@ export const FileList: React.FC = ({ annotatedFiles, header, footer, - isMagicSearchResult, + disableGrouping, publicCollectionGalleryContext.credentials, ]); @@ -358,15 +364,7 @@ export const FileList: React.FC = ({ }; }; - const getVacuumItem = (timeStampList) => { - let footerHeight; - if (publicCollectionGalleryContext.credentials) { - footerHeight = publicCollectionGalleryContext.referralCode - ? ALBUM_FOOTER_HEIGHT_WITH_REFERRAL - : ALBUM_FOOTER_HEIGHT; - } else { - footerHeight = FOOTER_HEIGHT; - } + const getVacuumItem = (timeStampList, footerHeight: number) => { const fileListHeight = (() => { let sum = 0; const getCurrentItemSize = getItemSize(timeStampList); @@ -384,95 +382,6 @@ export const FileList: React.FC = ({ }; }; - const getAppDownloadFooter = (): TimeStampListItem => ({ - tag: "publicAlbumsFooter", - height: FOOTER_HEIGHT, - item: ( - - - - ), - b: ( - - ), - }} - /> - - - ), - }); - - const getAlbumsFooter = (): TimeStampListItem => ({ - tag: "publicAlbumsFooter", - height: publicCollectionGalleryContext.referralCode - ? ALBUM_FOOTER_HEIGHT_WITH_REFERRAL - : ALBUM_FOOTER_HEIGHT, - item: ( - - {/* Make the entire area tappable, otherwise it is hard to - get at on mobile devices. */} - - - - - ), - }} - values={{ url: "ente.io" }} - /> - - - {publicCollectionGalleryContext.referralCode ? ( - - - - - - ) : null} - - - ), - }); - /** * Checks and merge multiple dates into a single row. */ @@ -953,41 +862,6 @@ const DateContainer = styled(ListItemContainer)( `, ); -const FooterContainer = styled(ListItemContainer)` - margin-bottom: 0.75rem; - @media (max-width: 540px) { - font-size: 12px; - margin-bottom: 0.5rem; - } - text-align: center; - justify-content: center; - align-items: flex-end; - margin-top: calc(2rem + 20px); -`; - -const AlbumFooterContainer = styled(ListItemContainer, { - shouldForwardProp: (propName) => propName != "hasReferral", -})<{ hasReferral: boolean }>` - margin-top: 48px; - margin-bottom: ${({ hasReferral }) => (!hasReferral ? `10px` : "0px")}; - text-align: center; - justify-content: center; -`; - -const FullStretchContainer = styled("div")( - ({ theme }) => ` - margin: 0 -24px; - width: calc(100% + 46px); - left: -24px; - @media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) { - margin: 0 -4px; - width: calc(100% + 6px); - left: -4px; - } - background-color: ${theme.vars.palette.accent.main}; -`, -); - const NothingContainer = styled(ListItemContainer)` text-align: center; justify-content: center; diff --git a/web/apps/photos/src/components/FileListWithViewer.tsx b/web/apps/photos/src/components/FileListWithViewer.tsx index 3386588ec6..92c81df25f 100644 --- a/web/apps/photos/src/components/FileListWithViewer.tsx +++ b/web/apps/photos/src/components/FileListWithViewer.tsx @@ -58,8 +58,7 @@ export type FileListWithViewerProps = { | "modePlus" | "header" | "footer" - | "showAppDownloadBanner" - | "isMagicSearchResult" + | "disableGrouping" | "selectable" | "selected" | "setSelected" @@ -98,8 +97,7 @@ export const FileListWithViewer: React.FC = ({ user, files, enableDownload, - showAppDownloadBanner, - isMagicSearchResult, + disableGrouping, selectable, selected, setSelected, @@ -186,8 +184,7 @@ export const FileListWithViewer: React.FC = ({ header, footer, user, - showAppDownloadBanner, - isMagicSearchResult, + disableGrouping, selectable, selected, setSelected, diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 949b6e43a4..dbb6f1425c 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -1,11 +1,11 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined"; import MenuIcon from "@mui/icons-material/Menu"; -import { IconButton, Stack, Typography } from "@mui/material"; +import { IconButton, Link, Stack, Typography } from "@mui/material"; import { AuthenticateUser } from "components/AuthenticateUser"; import { GalleryBarAndListHeader } from "components/Collections/GalleryBarAndListHeader"; import { DownloadStatusNotifications } from "components/DownloadStatusNotifications"; -import { type TimeStampListItem } from "components/FileList"; +import type { FileListHeaderOrFooter } from "components/FileList"; import { FileListWithViewer } from "components/FileListWithViewer"; import { FixCreationTime } from "components/FixCreationTime"; import { Sidebar } from "components/Sidebar"; @@ -180,9 +180,8 @@ const Page: React.FC = () => { const [fixCreationTimeFiles, setFixCreationTimeFiles] = useState< EnteFile[] >([]); - // The (non-sticky) header shown at the top of the gallery items. const [fileListHeader, setFileListHeader] = useState< - TimeStampListItem | undefined + FileListHeaderOrFooter | undefined >(undefined); const [openCollectionSelector, setOpenCollectionSelector] = useState(false); @@ -409,14 +408,13 @@ const Page: React.FC = () => { useEffect(() => { if (isInSearchMode && state.searchSuggestion) { setFileListHeader({ - height: 104, item: ( ), - tag: "header", + height: 104, }); } }, [isInSearchMode, state.searchSuggestion, state.searchResults]); @@ -966,6 +964,14 @@ const Page: React.FC = () => { [], ); + const showAppDownloadFooter = + state.collectionFiles.length < 30 && !isInSearchMode; + + const fileListFooter = useMemo( + () => (showAppDownloadFooter ? createAppDownloadFooter() : undefined), + [showAppDownloadFooter], + ); + const showSelectionBar = selected.count > 0 && selected.collectionID === activeCollectionID; @@ -1072,7 +1078,7 @@ const Page: React.FC = () => { activeCollection, activeCollectionID, activePerson, - setPhotoListHeader: setFileListHeader, + setFileListHeader, saveGroups, onAddSaveGroup, }} @@ -1148,13 +1154,11 @@ const Page: React.FC = () => { mode={barMode} modePlus={isInSearchMode ? "search" : barMode} header={fileListHeader} + footer={fileListFooter} user={user} files={filteredFiles} enableDownload={true} - showAppDownloadBanner={ - state.collectionFiles.length < 30 && !isInSearchMode - } - isMagicSearchResult={state.searchSuggestion?.type == "clip"} + disableGrouping={state.searchSuggestion?.type == "clip"} selectable={true} selected={selected} setSelected={setSelected} @@ -1382,3 +1386,39 @@ const handleSubscriptionCompletionRedirectIfNeeded = async ( } } }; + +const createAppDownloadFooter = (): FileListHeaderOrFooter => ({ + item: ( + + + ), + b: ( + + ), + }} + /> + + ), + height: 90, +}); diff --git a/web/apps/photos/src/pages/shared-albums.tsx b/web/apps/photos/src/pages/shared-albums.tsx index b3427da707..4ec23bae9f 100644 --- a/web/apps/photos/src/pages/shared-albums.tsx +++ b/web/apps/photos/src/pages/shared-albums.tsx @@ -3,7 +3,15 @@ import AddPhotoAlternateOutlinedIcon from "@mui/icons-material/AddPhotoAlternate import CloseIcon from "@mui/icons-material/Close"; import DownloadIcon from "@mui/icons-material/Download"; import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; -import { Box, Button, IconButton, Stack, styled, Tooltip } from "@mui/material"; +import { + Box, + Button, + IconButton, + Link, + Stack, + styled, + Tooltip, +} from "@mui/material"; import Typography from "@mui/material/Typography"; import { DownloadStatusNotifications } from "components/DownloadStatusNotifications"; import { FileListWithViewer } from "components/FileListWithViewer"; @@ -84,6 +92,7 @@ import { t } from "i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FileWithPath } from "react-dropzone"; +import { Trans } from "react-i18next"; import { uploadManager } from "services/upload-manager"; import { getSelectedFiles, type SelectedState } from "utils/file"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; @@ -98,7 +107,8 @@ export default function PublicCollectionGallery() { const [publicFiles, setPublicFiles] = useState( undefined, ); - const [errorMessage, setErrorMessage] = useState(null); + const [referralCode, setReferralCode] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); const [loading, setLoading] = useState(true); const [isPasswordProtected, setIsPasswordProtected] = useState(false); const [uploadTypeSelectorView, setUploadTypeSelectorView] = useState(false); @@ -117,7 +127,6 @@ export default function PublicCollectionGallery() { const credentials = useRef(undefined); const collectionKey = useRef(null); const url = useRef(null); - const referralCode = useRef(""); const { saveGroups, onAddSaveGroup, onRemoveSaveGroup } = useSaveGroups(); @@ -180,8 +189,9 @@ export default function PublicCollectionGallery() { const accessToken = t; let accessTokenJWT: string | undefined; if (collection) { - referralCode.current = - await savedLastPublicCollectionReferralCode(); + setReferralCode( + (await savedLastPublicCollectionReferralCode()) ?? "", + ); setPublicCollection(collection); setIsPasswordProtected( !!collection.publicURLs[0]?.passwordEnabled, @@ -223,13 +233,13 @@ export default function PublicCollectionGallery() { try { const { collection, referralCode: userReferralCode } = await pullCollection(accessToken, collectionKey.current); - referralCode.current = userReferralCode; + setReferralCode(userReferralCode); setPublicCollection(collection); const isPasswordProtected = !!collection.publicURLs[0]?.passwordEnabled; setIsPasswordProtected(isPasswordProtected); - setErrorMessage(null); + setErrorMessage(""); // Remove the locally cached accessTokenJWT if the sharer has // disabled password protection on the link. @@ -390,7 +400,7 @@ export default function PublicCollectionGallery() { publicCollection && publicFiles ? { item: ( - ), - tag: "header" as const, - height: 68, + height: fileListHeaderHeight, } : undefined, [onAddSaveGroup, publicCollection, publicFiles], ); - const fileListFooter = useMemo( - () => - onAddPhotos - ? { - item: ( - - - - ), - height: 104, - } - : undefined, - [onAddPhotos], - ); + const fileListFooter = useMemo(() => { + const props = { referralCode, onAddPhotos }; + return { + item: , + height: fileListFooterHeightForProps(props), + }; + }, [referralCode, onAddPhotos]); if (loading && (!publicFiles || !credentials.current)) { return ; @@ -460,10 +462,7 @@ export default function PublicCollectionGallery() { } // TODO: memo this (after the dependencies are traceable). - const context = { - credentials: credentials.current, - referralCode: referralCode.current, - }; + const context = { credentials: credentials.current }; return ( @@ -627,13 +626,24 @@ const SelectedFileOptions: React.FC = ({ ); -interface ListHeaderProps { +interface FileListHeaderProps { publicCollection: Collection; publicFiles: EnteFile[]; onAddSaveGroup: AddSaveGroup; } -const ListHeader: React.FC = ({ +/** + * The fixed height (in px) of {@link FileListHeader}. + */ +const fileListHeaderHeight = 68; + +/** + * A header shown before the listing of files. + * + * It scrolls along with the content. It has a fixed height, + * {@link fileListHeaderHeight}. + */ +const FileListHeader: React.FC = ({ publicCollection, publicFiles, onAddSaveGroup, @@ -671,3 +681,85 @@ const ListHeader: React.FC = ({ ); }; + +interface FileListFooterProps { + referralCode?: string; + onAddPhotos?: () => void; +} + +/** + * The dynamic (prop-depedent) height of {@link FileListFooter}. + */ +const fileListFooterHeightForProps = ({ + referralCode, + onAddPhotos, +}: FileListFooterProps) => (onAddPhotos ? 104 : 0) + (referralCode ? 113 : 75); + +/** + * A footer shown after the listing of files. + * + * It scrolls along with the content. It has a dynamic height, dependent on the + * props, calculated using {@link fileListFooterHeightForProps}. + */ + +const FileListFooter: React.FC = ({ + referralCode, + onAddPhotos, +}) => ( + + {onAddPhotos && ( + + + + )} + {/* Make the entire area tappable, otherwise it is hard to + get at on mobile devices. */} + + + + ), + }} + values={{ url: "ente.io" }} + /> + + + {referralCode && ( + + + + )} + +); diff --git a/web/apps/photos/src/utils/publicCollectionGallery/index.ts b/web/apps/photos/src/utils/publicCollectionGallery/index.ts index 38b29a9cf1..828134098c 100644 --- a/web/apps/photos/src/utils/publicCollectionGallery/index.ts +++ b/web/apps/photos/src/utils/publicCollectionGallery/index.ts @@ -8,11 +8,9 @@ export interface PublicCollectionGalleryContextType { * undefined when we're in the default photos app context. */ credentials: PublicAlbumsCredentials | undefined; - referralCode: string | null; } export const PublicCollectionGalleryContext = createContext({ credentials: undefined, - referralCode: null, }); diff --git a/web/packages/new/photos/components/gallery/ListHeader.tsx b/web/packages/new/photos/components/gallery/ListHeader.tsx index 91838f3852..1979ff5fe5 100644 --- a/web/packages/new/photos/components/gallery/ListHeader.tsx +++ b/web/packages/new/photos/components/gallery/ListHeader.tsx @@ -38,8 +38,8 @@ interface GalleryItemsSummaryProps { } /** - * A component suitable for being used as a (non-sticky) summary displayed on - * top of the of a list of photos (or other items) shown in the gallery. + * A component suitable for being used as a summary displayed on top of the of a + * list of photos (or other items) shown in the gallery. */ export const GalleryItemsSummary: React.FC = ({ name,