diff --git a/web/apps/photos/src/components/FileList.tsx b/web/apps/photos/src/components/FileList.tsx index c81da4acb5..48771df94d 100644 --- a/web/apps/photos/src/components/FileList.tsx +++ b/web/apps/photos/src/components/FileList.tsx @@ -1,6 +1,10 @@ import { assertionFailed } from "@/base/assert"; +import { Overlay } from "@/base/components/containers"; import { isSameDay } from "@/base/date"; -import { EnteFile } from "@/media/file"; +import { formattedDateRelative } from "@/base/i18n-date"; +import { downloadManager } from "@/gallery/services/download"; +import { EnteFile, enteFileDeletionDate } from "@/media/file"; +import { FileType } from "@/media/file-type"; import { GAP_BTW_TILES, IMAGE_CONTAINER_MAX_HEIGHT, @@ -8,8 +12,19 @@ import { MIN_COLUMNS, } from "@/new/photos/components/FileList"; import type { GalleryBarMode } from "@/new/photos/components/gallery/reducer"; +import { + LoadingThumbnail, + StaticThumbnail, +} from "@/new/photos/components/PlaceholderThumbnails"; +import { TileBottomTextOverlay } from "@/new/photos/components/Tiles"; +import { TRASH_SECTION } from "@/new/photos/services/collection"; import { FlexWrapper } from "@ente/shared/components/Container"; +import useLongPress from "@ente/shared/hooks/useLongPress"; +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 Avatar from "components/pages/gallery/Avatar"; import { t } from "i18next"; import memoize from "memoize-one"; import { GalleryContext } from "pages/gallery"; @@ -21,12 +36,12 @@ import { areEqual, } from "react-window"; import { SelectedState } from "types/gallery"; +import { shouldShowAvatar } from "utils/file"; import { handleSelectCreator, handleSelectCreatorMulti, } from "utils/photoFrame"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; -import PreviewCard from "./pages/gallery/PreviewCard"; export const DATE_CONTAINER_HEIGHT = 48; export const SPACE_BTW_DATES = 44; @@ -747,7 +762,7 @@ export const FileList: React.FC = ({ index: number, isScrolling: boolean, ) => ( - onItemClick(index)} @@ -1067,3 +1082,305 @@ const PhotoListRow = React.memo( }, areEqual, ); + +interface FileThumbnailProps { + file: EnteFile; + onClick: () => void; + selectable: boolean; + selected: boolean; + onSelect: (checked: boolean) => void; + onHover: () => void; + onRangeSelect: () => void; + isRangeSelectActive: boolean; + selectOnClick: boolean; + isInsSelectRange: boolean; + activeCollectionID: number; + showPlaceholder: boolean; + isFav: boolean; +} + +const FileThumbnail: React.FC = ({ + file, + onClick, + selectable, + selected, + onSelect, + selectOnClick, + onHover, + onRangeSelect, + isRangeSelectActive, + isInsSelectRange, + isFav, + activeCollectionID, + showPlaceholder, +}) => { + const galleryContext = useContext(GalleryContext); + + const [imageURL, setImageURL] = useState(undefined); + + const longPress = useLongPress(() => onSelect(!selected), 500); + + useEffect(() => { + let didCancel = false; + + void downloadManager + .renderableThumbnailURL(file, showPlaceholder) + .then((url) => !didCancel && setImageURL(url)); + + return () => { + didCancel = true; + }; + }, [file, showPlaceholder]); + + const handleClick = () => { + if (selectOnClick) { + if (isRangeSelectActive) { + onRangeSelect(); + } else { + onSelect(!selected); + } + } else if (imageURL) { + onClick?.(); + } + }; + + const handleSelect: React.ChangeEventHandler = (e) => { + if (isRangeSelectActive) { + onRangeSelect?.(); + } else { + onSelect(e.target.checked); + } + }; + + const handleHover = () => { + if (isRangeSelectActive) { + onHover(); + } + }; + + return ( + + {selectable && ( + e.stopPropagation()} + /> + )} + {file.metadata.hasStaticThumbnail ? ( + + ) : imageURL ? ( + + ) : ( + + )} + {file.metadata.fileType === FileType.livePhoto ? ( + + + + ) : ( + file.metadata.fileType === FileType.video && ( + + + + ) + )} + {selected && } + {shouldShowAvatar(file, galleryContext.user) && ( + + + + )} + {isFav && ( + + + + )} + + + {isRangeSelectActive && isInsSelectRange && ( + + )} + + {activeCollectionID === TRASH_SECTION && file.isTrashed && ( + + + {formattedDateRelative(enteFileDeletionDate(file))} + + + )} + + ); +}; + +const FileThumbnail_ = styled("div")<{ disabled: boolean }>` + display: flex; + width: fit-content; + margin-bottom: ${GAP_BTW_TILES}px; + min-width: 100%; + overflow: hidden; + position: relative; + flex: 1; + cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; + user-select: none; + & > img { + object-fit: cover; + max-width: 100%; + min-height: 100%; + flex: 1; + pointer-events: none; + } + + &:hover { + input[type="checkbox"] { + visibility: visible; + opacity: 0.5; + } + + .preview-card-hover-overlay { + opacity: 1; + } + } + + border-radius: 4px; +`; + +const Check = styled("input")<{ $active: boolean }>( + ({ theme, $active }) => ` + appearance: none; + position: absolute; + /* Increase z-index in stacking order to capture clicks */ + z-index: 1; + left: 0; + outline: none; + cursor: pointer; + @media (pointer: coarse) { + pointer-events: none; + } + + &::before { + content: ""; + width: 19px; + height: 19px; + background-color: #ddd; + display: inline-block; + border-radius: 50%; + vertical-align: bottom; + margin: 6px 6px; + transition: background-color 0.3s ease; + pointer-events: inherit; + + } + &::after { + content: ""; + position: absolute; + width: 5px; + height: 11px; + border-right: 2px solid #333; + border-bottom: 2px solid #333; + transition: transform 0.3s ease; + pointer-events: inherit; + transform: translate(-18px, 9px) rotate(45deg); + } + + /* checkmark background (filled circle) */ + &:checked::before { + content: ""; + background-color: ${theme.vars.palette.accent.main}; + border-color: ${theme.vars.palette.accent.main}; + color: white; + } + /* checkmark foreground (tick) */ + &:checked::after { + content: ""; + border-right: 2px solid #ddd; + border-bottom: 2px solid #ddd; + } + visibility: hidden; + ${$active && "visibility: visible; opacity: 0.5;"}; + &:checked { + visibility: visible; + opacity: 1 !important; + } +`, +); + +const HoverOverlay = styled("div")<{ checked: boolean }>` + opacity: 0; + left: 0; + top: 0; + outline: none; + height: 40%; + width: 100%; + position: absolute; + ${(props) => + !props.checked && + "background:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0))"}; +`; + +/** + * An overlay showing the avatars of the person who shared the item, at the top + * right. + */ +const AvatarOverlay = styled(Overlay)` + display: flex; + justify-content: flex-end; + align-items: flex-start; + padding: 5px; +`; + +/** + * An overlay showing the favorite icon at bottom left. + */ +const FavoriteOverlay = styled(Overlay)` + display: flex; + justify-content: flex-start; + align-items: flex-end; + padding: 5px; + color: white; + opacity: 0.6; +`; + +/** + * An overlay with a gradient, showing the file type indicator (e.g. live photo, + * video) at the bottom right. + */ +const FileTypeIndicatorOverlay = styled(Overlay)` + display: flex; + justify-content: flex-end; + align-items: flex-end; + padding: 5px; + color: white; + background: linear-gradient( + 315deg, + rgba(0 0 0 / 0.14) 0%, + rgba(0 0 0 / 0.05) 30%, + transparent 50% + ); +`; + +const InSelectRangeOverlay = styled(Overlay)( + ({ theme }) => ` + outline: none; + background: ${theme.vars.palette.accent.main}; + opacity: 0.14; +`, +); + +const SelectedOverlay = styled(Overlay)( + ({ theme }) => ` + border: 2px solid ${theme.vars.palette.accent.main}; + border-radius: 4px; +`, +); diff --git a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx deleted file mode 100644 index d819390f4b..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { Overlay } from "@/base/components/containers"; -import { formattedDateRelative } from "@/base/i18n-date"; -import { downloadManager } from "@/gallery/services/download"; -import { enteFileDeletionDate, type EnteFile } from "@/media/file"; -import { FileType } from "@/media/file-type"; -import { GAP_BTW_TILES } from "@/new/photos/components/FileList"; -import { - LoadingThumbnail, - StaticThumbnail, -} from "@/new/photos/components/PlaceholderThumbnails"; -import { TileBottomTextOverlay } from "@/new/photos/components/Tiles"; -import { TRASH_SECTION } from "@/new/photos/services/collection"; -import useLongPress from "@ente/shared/hooks/useLongPress"; -import AlbumOutlinedIcon from "@mui/icons-material/AlbumOutlined"; -import FavoriteRoundedIcon from "@mui/icons-material/FavoriteRounded"; -import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined"; -import { styled, Typography } from "@mui/material"; -import { GalleryContext } from "pages/gallery"; -import React, { useContext, useEffect, useState } from "react"; -import { shouldShowAvatar } from "utils/file"; -import Avatar from "./Avatar"; - -interface PreviewCardProps { - file: EnteFile; - onClick: () => void; - selectable: boolean; - selected: boolean; - onSelect: (checked: boolean) => void; - onHover: () => void; - onRangeSelect: () => void; - isRangeSelectActive: boolean; - selectOnClick: boolean; - isInsSelectRange: boolean; - activeCollectionID: number; - showPlaceholder: boolean; - isFav: boolean; -} - -const Check = styled("input")<{ $active: boolean }>( - ({ theme, $active }) => ` - appearance: none; - position: absolute; - /* Increase z-index in stacking order to capture clicks */ - z-index: 1; - left: 0; - outline: none; - cursor: pointer; - @media (pointer: coarse) { - pointer-events: none; - } - - &::before { - content: ""; - width: 19px; - height: 19px; - background-color: #ddd; - display: inline-block; - border-radius: 50%; - vertical-align: bottom; - margin: 6px 6px; - transition: background-color 0.3s ease; - pointer-events: inherit; - - } - &::after { - content: ""; - position: absolute; - width: 5px; - height: 11px; - border-right: 2px solid #333; - border-bottom: 2px solid #333; - transition: transform 0.3s ease; - pointer-events: inherit; - transform: translate(-18px, 9px) rotate(45deg); - } - - /* checkmark background (filled circle) */ - &:checked::before { - content: ""; - background-color: ${theme.vars.palette.accent.main}; - border-color: ${theme.vars.palette.accent.main}; - color: white; - } - /* checkmark foreground (tick) */ - &:checked::after { - content: ""; - border-right: 2px solid #ddd; - border-bottom: 2px solid #ddd; - } - visibility: hidden; - ${$active && "visibility: visible; opacity: 0.5;"}; - &:checked { - visibility: visible; - opacity: 1 !important; - } -`, -); - -const HoverOverlay = styled("div")<{ checked: boolean }>` - opacity: 0; - left: 0; - top: 0; - outline: none; - height: 40%; - width: 100%; - position: absolute; - ${(props) => - !props.checked && - "background:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0))"}; -`; - -/** - * An overlay showing the avatars of the person who shared the item, at the top - * right. - */ -const AvatarOverlay = styled(Overlay)` - display: flex; - justify-content: flex-end; - align-items: flex-start; - padding: 5px; -`; - -/** - * An overlay showing the favorite icon at bottom left. - */ -const FavoriteOverlay = styled(Overlay)` - display: flex; - justify-content: flex-start; - align-items: flex-end; - padding: 5px; - color: white; - opacity: 0.6; -`; - -/** - * An overlay with a gradient, showing the file type indicator (e.g. live photo, - * video) at the bottom right. - */ -const FileTypeIndicatorOverlay = styled(Overlay)` - display: flex; - justify-content: flex-end; - align-items: flex-end; - padding: 5px; - color: white; - background: linear-gradient( - 315deg, - rgba(0 0 0 / 0.14) 0%, - rgba(0 0 0 / 0.05) 30%, - transparent 50% - ); -`; - -const InSelectRangeOverlay = styled(Overlay)( - ({ theme }) => ` - outline: none; - background: ${theme.vars.palette.accent.main}; - opacity: 0.14; -`, -); - -const SelectedOverlay = styled(Overlay)( - ({ theme }) => ` - border: 2px solid ${theme.vars.palette.accent.main}; - border-radius: 4px; -`, -); - -const Cont = styled("div")<{ disabled: boolean }>` - display: flex; - width: fit-content; - margin-bottom: ${GAP_BTW_TILES}px; - min-width: 100%; - overflow: hidden; - position: relative; - flex: 1; - cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; - user-select: none; - & > img { - object-fit: cover; - max-width: 100%; - min-height: 100%; - flex: 1; - pointer-events: none; - } - - &:hover { - input[type="checkbox"] { - visibility: visible; - opacity: 0.5; - } - - .preview-card-hover-overlay { - opacity: 1; - } - } - - border-radius: 4px; -`; - -export default function PreviewCard({ - file, - onClick, - selectable, - selected, - onSelect, - selectOnClick, - onHover, - onRangeSelect, - isRangeSelectActive, - isInsSelectRange, - isFav, - activeCollectionID, - showPlaceholder, -}: PreviewCardProps) { - const galleryContext = useContext(GalleryContext); - - const [imageURL, setImageURL] = useState(undefined); - - const longPress = useLongPress(() => onSelect(!selected), 500); - - useEffect(() => { - let didCancel = false; - - void downloadManager - .renderableThumbnailURL(file, showPlaceholder) - .then((url) => !didCancel && setImageURL(url)); - - return () => { - didCancel = true; - }; - }, [file, showPlaceholder]); - - const handleClick = () => { - if (selectOnClick) { - if (isRangeSelectActive) { - onRangeSelect(); - } else { - onSelect(!selected); - } - } else if (imageURL) { - onClick?.(); - } - }; - - const handleSelect: React.ChangeEventHandler = (e) => { - if (isRangeSelectActive) { - onRangeSelect?.(); - } else { - onSelect(e.target.checked); - } - }; - - const handleHover = () => { - if (isRangeSelectActive) { - onHover(); - } - }; - - return ( - - {selectable && ( - e.stopPropagation()} - /> - )} - {file.metadata.hasStaticThumbnail ? ( - - ) : imageURL ? ( - - ) : ( - - )} - {file.metadata.fileType === FileType.livePhoto ? ( - - - - ) : ( - file.metadata.fileType === FileType.video && ( - - - - ) - )} - {selected && } - {shouldShowAvatar(file, galleryContext.user) && ( - - - - )} - {isFav && ( - - - - )} - - - {isRangeSelectActive && isInsSelectRange && ( - - )} - - {activeCollectionID === TRASH_SECTION && file.isTrashed && ( - - - {formattedDateRelative(enteFileDeletionDate(file))} - - - )} - - ); -}