FileThumbnail

This commit is contained in:
Manav Rathi
2025-03-12 11:01:58 +05:30
parent 4312b024f6
commit c8b07fcae5
2 changed files with 320 additions and 326 deletions

View File

@@ -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<FileListProps> = ({
index: number,
isScrolling: boolean,
) => (
<PreviewCard
<FileThumbnail
key={`tile-${file.id}-selected-${selected[file.id] ?? false}`}
file={file}
onClick={() => 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<FileThumbnailProps> = ({
file,
onClick,
selectable,
selected,
onSelect,
selectOnClick,
onHover,
onRangeSelect,
isRangeSelectActive,
isInsSelectRange,
isFav,
activeCollectionID,
showPlaceholder,
}) => {
const galleryContext = useContext(GalleryContext);
const [imageURL, setImageURL] = useState<string | undefined>(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<HTMLInputElement> = (e) => {
if (isRangeSelectActive) {
onRangeSelect?.();
} else {
onSelect(e.target.checked);
}
};
const handleHover = () => {
if (isRangeSelectActive) {
onHover();
}
};
return (
<FileThumbnail_
key={`thumb-${file.id}}`}
onClick={handleClick}
onMouseEnter={handleHover}
disabled={!imageURL}
{...(selectable ? longPress : {})}
>
{selectable && (
<Check
type="checkbox"
checked={selected}
onChange={handleSelect}
$active={isRangeSelectActive && isInsSelectRange}
onClick={(e) => e.stopPropagation()}
/>
)}
{file.metadata.hasStaticThumbnail ? (
<StaticThumbnail fileType={file.metadata.fileType} />
) : imageURL ? (
<img src={imageURL} />
) : (
<LoadingThumbnail />
)}
{file.metadata.fileType === FileType.livePhoto ? (
<FileTypeIndicatorOverlay>
<AlbumOutlinedIcon />
</FileTypeIndicatorOverlay>
) : (
file.metadata.fileType === FileType.video && (
<FileTypeIndicatorOverlay>
<PlayCircleOutlineOutlinedIcon />
</FileTypeIndicatorOverlay>
)
)}
{selected && <SelectedOverlay />}
{shouldShowAvatar(file, galleryContext.user) && (
<AvatarOverlay>
<Avatar file={file} />
</AvatarOverlay>
)}
{isFav && (
<FavoriteOverlay>
<FavoriteRoundedIcon />
</FavoriteOverlay>
)}
<HoverOverlay
className="preview-card-hover-overlay"
checked={selected}
/>
{isRangeSelectActive && isInsSelectRange && (
<InSelectRangeOverlay />
)}
{activeCollectionID === TRASH_SECTION && file.isTrashed && (
<TileBottomTextOverlay>
<Typography variant="small">
{formattedDateRelative(enteFileDeletionDate(file))}
</Typography>
</TileBottomTextOverlay>
)}
</FileThumbnail_>
);
};
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;
`,
);

View File

@@ -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<string | undefined>(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<HTMLInputElement> = (e) => {
if (isRangeSelectActive) {
onRangeSelect?.();
} else {
onSelect(e.target.checked);
}
};
const handleHover = () => {
if (isRangeSelectActive) {
onHover();
}
};
return (
<Cont
key={`thumb-${file.id}}`}
onClick={handleClick}
onMouseEnter={handleHover}
disabled={!imageURL}
{...(selectable ? longPress : {})}
>
{selectable && (
<Check
type="checkbox"
checked={selected}
onChange={handleSelect}
$active={isRangeSelectActive && isInsSelectRange}
onClick={(e) => e.stopPropagation()}
/>
)}
{file.metadata.hasStaticThumbnail ? (
<StaticThumbnail fileType={file.metadata.fileType} />
) : imageURL ? (
<img src={imageURL} />
) : (
<LoadingThumbnail />
)}
{file.metadata.fileType === FileType.livePhoto ? (
<FileTypeIndicatorOverlay>
<AlbumOutlinedIcon />
</FileTypeIndicatorOverlay>
) : (
file.metadata.fileType === FileType.video && (
<FileTypeIndicatorOverlay>
<PlayCircleOutlineOutlinedIcon />
</FileTypeIndicatorOverlay>
)
)}
{selected && <SelectedOverlay />}
{shouldShowAvatar(file, galleryContext.user) && (
<AvatarOverlay>
<Avatar file={file} />
</AvatarOverlay>
)}
{isFav && (
<FavoriteOverlay>
<FavoriteRoundedIcon />
</FavoriteOverlay>
)}
<HoverOverlay
className="preview-card-hover-overlay"
checked={selected}
/>
{isRangeSelectActive && isInsSelectRange && (
<InSelectRangeOverlay />
)}
{activeCollectionID === TRASH_SECTION && file.isTrashed && (
<TileBottomTextOverlay>
<Typography variant="small">
{formattedDateRelative(enteFileDeletionDate(file))}
</Typography>
</TileBottomTextOverlay>
)}
</Cont>
);
}