[web] File viewer code cleanup (#5294)

Cleaning pending leftovers from old viewer.
This commit is contained in:
Manav Rathi
2025-03-12 12:52:14 +05:30
committed by GitHub
7 changed files with 640 additions and 683 deletions

View File

@@ -1,15 +1,30 @@
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,
IMAGE_CONTAINER_MAX_WIDTH,
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 type { PhotoFrameProps } from "components/PhotoFrame";
import Avatar from "components/pages/gallery/Avatar";
import { t } from "i18next";
import memoize from "memoize-one";
import { GalleryContext } from "pages/gallery";
@@ -20,12 +35,13 @@ import {
ListChildComponentProps,
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;
@@ -59,154 +75,58 @@ export interface TimeStampListItem {
fileCount?: number;
}
const ListItem = styled("div")`
display: flex;
justify-content: center;
`;
const getTemplateColumns = (
columns: number,
shrinkRatio: number,
groups?: number[],
): string => {
if (groups) {
// need to confirm why this was there
// const sum = groups.reduce((acc, item) => acc + item, 0);
// if (sum < columns) {
// groups[groups.length - 1] += columns - sum;
// }
return groups
.map(
(x) =>
`repeat(${x}, ${IMAGE_CONTAINER_MAX_WIDTH * shrinkRatio}px)`,
)
.join(` ${SPACE_BTW_DATES}px `);
} else {
return `repeat(${columns},${
IMAGE_CONTAINER_MAX_WIDTH * shrinkRatio
}px)`;
}
};
function getFractionFittableColumns(width: number): number {
return (
(width - 2 * getGapFromScreenEdge(width) + GAP_BTW_TILES) /
(IMAGE_CONTAINER_MAX_WIDTH + GAP_BTW_TILES)
);
}
function getGapFromScreenEdge(width: number) {
if (width > MIN_COLUMNS * IMAGE_CONTAINER_MAX_WIDTH) {
return 24;
} else {
return 4;
}
}
function getShrinkRatio(width: number, columns: number) {
return (
(width -
2 * getGapFromScreenEdge(width) -
(columns - 1) * GAP_BTW_TILES) /
(columns * IMAGE_CONTAINER_MAX_WIDTH)
);
}
const ListContainer = styled(Box, {
shouldForwardProp: (propName) => propName != "gridTemplateColumns",
})<{ gridTemplateColumns: string }>`
display: grid;
grid-template-columns: ${(props) => props.gridTemplateColumns};
grid-column-gap: ${GAP_BTW_TILES}px;
width: 100%;
padding: 0 24px;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
padding: 0 4px;
}
`;
const ListItemContainer = styled(FlexWrapper)<{ span: number }>`
grid-column: span ${(props) => props.span};
`;
const DateContainer = styled(ListItemContainer)(
({ theme }) => `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: ${DATE_CONTAINER_HEIGHT}px;
color: ${theme.vars.palette.text.muted};
`,
);
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;
`;
export interface FileListAnnotatedFile {
file: EnteFile;
/**
* The date string using with the associated {@link file} should be shown in
* the timeline.
*
* This for used for grouping files: all files which have the same
* {@link timelineDateString} are grouped together into a section titled
* with that {@link timelineDateString}.
* [Note: Timeline date string]
*
* The timeline date string is a formatted date string under which a
* particular file should be grouped in the gallery listing. e.g. "Today",
* "Yesterday", "Fri, 21 Feb" etc.
*
* All files which have the same timelineDateString will be grouped under a
* single section in the gallery listing, prefixed by the timelineDateString
* itself, and a checkbox to select all files on that date.
*/
timelineDateString: string;
}
type FileListProps = Pick<
PhotoFrameProps,
| "mode"
| "modePlus"
| "selectable"
| "selected"
| "setSelected"
| "favoriteFileIDs"
> & {
export interface FileListProps {
/** The height we should occupy (needed since the list is virtualized). */
height: number;
/** The width we should occupy.*/
width: number;
/**
* The files to show, annotated with cached precomputed properties that are
* frequently needed by the {@link FileList}.
*/
annotatedFiles: FileListAnnotatedFile[];
showAppDownloadBanner: boolean;
mode?: GalleryBarMode;
/**
* This is an experimental prop, to see if we can merge the separate
* "isInSearchMode" state kept by the gallery to be instead provided as a
* another mode in which the gallery operates.
*/
modePlus?: GalleryBarMode | "search";
showAppDownloadBanner?: boolean;
selectable?: boolean;
setSelected: (
selected: SelectedState | ((selected: SelectedState) => SelectedState),
) => void;
selected: SelectedState;
/** This will be set if mode is not "people". */
activeCollectionID: number;
activePersonID?: string;
/** This will be set if mode is "people". */
activePersonID?: string | undefined;
/**
* File IDs of all the files that the user has marked as a favorite.
*
* Not set in the context of the shared albums app.
*/
favoriteFileIDs?: Set<number>;
/**
* Called when the user activates the thumbnail at the given {@link index}.
*
@@ -214,55 +134,11 @@ type FileListProps = Pick<
* {@link annotatedFiles}.
*/
onItemClick: (index: number) => void;
};
interface ItemData {
timeStampList: TimeStampListItem[];
columns: number;
shrinkRatio: number;
renderListItem: (
timeStampListItem: TimeStampListItem,
isScrolling?: boolean,
) => React.JSX.Element;
}
const createItemData = memoize(
(
timeStampList: TimeStampListItem[],
columns: number,
shrinkRatio: number,
renderListItem: (
timeStampListItem: TimeStampListItem,
isScrolling?: boolean,
) => React.JSX.Element,
): ItemData => ({ timeStampList, columns, shrinkRatio, renderListItem }),
);
const PhotoListRow = React.memo(
({
index,
style,
isScrolling,
data,
}: ListChildComponentProps<ItemData>) => {
const { timeStampList, columns, shrinkRatio, renderListItem } = data;
return (
<ListItem style={style}>
<ListContainer
gridTemplateColumns={getTemplateColumns(
columns,
shrinkRatio,
timeStampList[index].groups,
)}
>
{renderListItem(timeStampList[index], isScrolling)}
</ListContainer>
</ListItem>
);
},
areEqual,
);
/**
* A virtualized list of files, each represented by their thumbnail.
*/
export const FileList: React.FC<FileListProps> = ({
height,
width,
@@ -545,7 +421,7 @@ export const FileList: React.FC<FileListProps> = ({
} else {
footerHeight = FOOTER_HEIGHT;
}
const photoFrameHeight = (() => {
const fileListHeight = (() => {
let sum = 0;
const getCurrentItemSize = getItemSize(timeStampList);
for (let i = 0; i < timeStampList.length; i++) {
@@ -559,7 +435,7 @@ export const FileList: React.FC<FileListProps> = ({
return {
itemType: ITEM_TYPE.OTHER,
item: <></>,
height: Math.max(height - photoFrameHeight - footerHeight, 0),
height: Math.max(height - fileListHeight - footerHeight, 0),
};
};
@@ -886,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)}
@@ -1039,3 +915,472 @@ export const FileList: React.FC<FileListProps> = ({
</List>
);
};
const ListItem = styled("div")`
display: flex;
justify-content: center;
`;
const getTemplateColumns = (
columns: number,
shrinkRatio: number,
groups?: number[],
): string => {
if (groups) {
// need to confirm why this was there
// const sum = groups.reduce((acc, item) => acc + item, 0);
// if (sum < columns) {
// groups[groups.length - 1] += columns - sum;
// }
return groups
.map(
(x) =>
`repeat(${x}, ${IMAGE_CONTAINER_MAX_WIDTH * shrinkRatio}px)`,
)
.join(` ${SPACE_BTW_DATES}px `);
} else {
return `repeat(${columns},${
IMAGE_CONTAINER_MAX_WIDTH * shrinkRatio
}px)`;
}
};
function getFractionFittableColumns(width: number): number {
return (
(width - 2 * getGapFromScreenEdge(width) + GAP_BTW_TILES) /
(IMAGE_CONTAINER_MAX_WIDTH + GAP_BTW_TILES)
);
}
function getGapFromScreenEdge(width: number) {
if (width > MIN_COLUMNS * IMAGE_CONTAINER_MAX_WIDTH) {
return 24;
} else {
return 4;
}
}
function getShrinkRatio(width: number, columns: number) {
return (
(width -
2 * getGapFromScreenEdge(width) -
(columns - 1) * GAP_BTW_TILES) /
(columns * IMAGE_CONTAINER_MAX_WIDTH)
);
}
const ListContainer = styled(Box, {
shouldForwardProp: (propName) => propName != "gridTemplateColumns",
})<{ gridTemplateColumns: string }>`
display: grid;
grid-template-columns: ${(props) => props.gridTemplateColumns};
grid-column-gap: ${GAP_BTW_TILES}px;
width: 100%;
padding: 0 24px;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
padding: 0 4px;
}
`;
const ListItemContainer = styled(FlexWrapper)<{ span: number }>`
grid-column: span ${(props) => props.span};
`;
const DateContainer = styled(ListItemContainer)(
({ theme }) => `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: ${DATE_CONTAINER_HEIGHT}px;
color: ${theme.vars.palette.text.muted};
`,
);
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;
`;
interface ItemData {
timeStampList: TimeStampListItem[];
columns: number;
shrinkRatio: number;
renderListItem: (
timeStampListItem: TimeStampListItem,
isScrolling?: boolean,
) => React.JSX.Element;
}
const createItemData = memoize(
(
timeStampList: TimeStampListItem[],
columns: number,
shrinkRatio: number,
renderListItem: (
timeStampListItem: TimeStampListItem,
isScrolling?: boolean,
) => React.JSX.Element,
): ItemData => ({ timeStampList, columns, shrinkRatio, renderListItem }),
);
const PhotoListRow = React.memo(
({
index,
style,
isScrolling,
data,
}: ListChildComponentProps<ItemData>) => {
const { timeStampList, columns, shrinkRatio, renderListItem } = data;
return (
<ListItem style={style}>
<ListContainer
gridTemplateColumns={getTemplateColumns(
columns,
shrinkRatio,
timeStampList[index].groups,
)}
>
{renderListItem(timeStampList[index], isScrolling)}
</ListContainer>
</ListItem>
);
},
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,107 +1,35 @@
import { isSameDay } from "@/base/date";
import { formattedDate } from "@/base/i18n-date";
import type { FileInfoProps } from "@/gallery/components/FileInfo";
import { FileViewer } from "@/gallery/components/viewer/FileViewer";
import { type RenderableSourceURLs } from "@/gallery/services/download";
import {
FileViewer,
type FileViewerProps,
} from "@/gallery/components/viewer/FileViewer";
import type { Collection } from "@/media/collection";
import { EnteFile } from "@/media/file";
import type { GalleryBarMode } from "@/new/photos/components/gallery/reducer";
import { moveToTrash, TRASH_SECTION } from "@/new/photos/services/collection";
import { styled } from "@mui/material";
import { t } from "i18next";
import { GalleryContext } from "pages/gallery";
import { useCallback, useContext, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import AutoSizer from "react-virtualized-auto-sizer";
import {
addToFavorites,
removeFromFavorites,
} from "services/collectionService";
import uploadManager from "services/upload/uploadManager";
import {
SelectedState,
SetFilesDownloadProgressAttributesCreator,
} from "types/gallery";
import { SetFilesDownloadProgressAttributesCreator } from "types/gallery";
import { downloadSingleFile } from "utils/file";
import { FileList, type FileListAnnotatedFile } from "./FileList";
import {
FileList,
type FileListAnnotatedFile,
type FileListProps,
} from "./FileList";
const Container = styled("div")`
display: block;
flex: 1;
width: 100%;
flex-wrap: wrap;
margin: 0 auto;
overflow: hidden;
.pswp-thumbnail {
display: inline-block;
cursor: pointer;
}
`;
/**
* An {@link EnteFile} augmented with various in-memory state used for
* displaying it in the photo viewer.
*/
export type DisplayFile = EnteFile & {
src?: string;
srcURLs?: RenderableSourceURLs;
export type FileListWithViewerProps = {
/**
* An object URL corresponding to the image portion, if any, associated with
* the {@link DisplayFile}.
*
* - For images, this will be the object URL of the renderable image itself.
* - For live photos, this will be the object URL of the image portion of
* the live photo.
* - For videos, this will not be defined.
* The list of files to show.
*/
associatedImageURL?: string | undefined;
msrc?: string;
html?: string;
w?: number;
h?: number;
title?: string;
isSourceLoaded?: boolean;
conversionFailed?: boolean;
canForceConvert?: boolean;
/**
* [Note: Timeline date string]
*
* The timeline date string is a formatted date string under which a
* particular file should be grouped in the gallery listing. e.g. "Today",
* "Yesterday", "Fri, 21 Feb" etc.
*
* All files which have the same timelineDateString will be grouped under a
* single section in the gallery listing, prefixed by the timelineDateString
* itself, and a checkbox to select all files on that date.
*/
timelineDateString?: string;
};
export type PhotoFrameProps = Pick<
FileInfoProps,
| "fileCollectionIDs"
| "allCollectionsNameByID"
| "onSelectCollection"
| "onSelectPerson"
> & {
mode?: GalleryBarMode;
/**
* This is an experimental prop, to see if we can merge the separate
* "isInSearchMode" state kept by the gallery to be instead provided as a
* another mode in which the gallery operates.
*/
modePlus?: GalleryBarMode | "search";
files: EnteFile[];
selectable?: boolean;
setSelected: (
selected: SelectedState | ((selected: SelectedState) => SelectedState),
) => void;
selected: SelectedState;
/**
* File IDs of all the files that the user has marked as a favorite.
*
* Not set in the context of the shared albums app.
*/
favoriteFileIDs?: Set<number>;
enableDownload?: boolean;
/**
* Called when the component wants to update the in-memory, unsynced,
* favorite status of a file.
@@ -125,51 +53,68 @@ export type PhotoFrameProps = Pick<
* Not set in the context of the shared albums app.
*/
onMarkTempDeleted?: (files: EnteFile[]) => void;
/** This will be set if mode is not "people". */
activeCollectionID: number;
/** This will be set if mode is "people". */
activePersonID?: string | undefined;
enableDownload?: boolean;
showAppDownloadBanner?: boolean;
isInIncomingSharedCollection?: boolean;
isInHiddenSection?: boolean;
setFilesDownloadProgressAttributesCreator?: SetFilesDownloadProgressAttributesCreator;
/**
* Called when the visibility of the file viewer dialog changes.
*/
onSetOpenFileViewer?: (open: boolean) => void;
/**
* Called when an action in the file viewer requires us to sync with remote.
*/
onSyncWithRemote: () => Promise<void>;
};
} & Pick<
FileListProps,
| "mode"
| "modePlus"
| "showAppDownloadBanner"
| "selectable"
| "selected"
| "setSelected"
| "activeCollectionID"
| "activePersonID"
| "favoriteFileIDs"
> &
Pick<
FileViewerProps,
| "user"
| "isInIncomingSharedCollection"
| "isInHiddenSection"
| "fileCollectionIDs"
| "allCollectionsNameByID"
| "onSelectCollection"
| "onSelectPerson"
>;
/**
* TODO: Rename me to FileListWithViewer (or Gallery?)
* A list of files (represented by their thumbnails), along with a file viewer
* that opens on activating the thumbnail (and also allows the user to navigate
* through this list of files).
*/
const PhotoFrame = ({
export const FileListWithViewer: React.FC<FileListWithViewerProps> = ({
mode,
modePlus,
user,
files,
enableDownload,
showAppDownloadBanner,
selectable,
selected,
setSelected,
favoriteFileIDs,
onMarkUnsyncedFavoriteUpdate,
onMarkTempDeleted,
activeCollectionID,
activePersonID,
enableDownload,
fileCollectionIDs,
allCollectionsNameByID,
showAppDownloadBanner,
favoriteFileIDs,
isInIncomingSharedCollection,
isInHiddenSection,
fileCollectionIDs,
allCollectionsNameByID,
setFilesDownloadProgressAttributesCreator,
onMarkUnsyncedFavoriteUpdate,
onMarkTempDeleted,
onSetOpenFileViewer,
onSyncWithRemote,
onSelectCollection,
onSelectPerson,
}: PhotoFrameProps) => {
const galleryContext = useContext(GalleryContext);
}) => {
const [openFileViewer, setOpenFileViewer] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
@@ -245,18 +190,18 @@ const PhotoFrame = ({
<AutoSizer>
{({ height, width }) => (
<FileList
{...{ width, height, annotatedFiles }}
{...{
mode,
modePlus,
showAppDownloadBanner,
selectable,
selected,
setSelected,
activeCollectionID,
activePersonID,
showAppDownloadBanner,
favoriteFileIDs,
}}
{...{ width, height, annotatedFiles }}
onItemClick={handleThumbnailClick}
/>
)}
@@ -264,31 +209,34 @@ const PhotoFrame = ({
<FileViewer
open={openFileViewer}
onClose={handleCloseFileViewer}
user={galleryContext.user ?? undefined}
files={files}
initialIndex={currentIndex}
disableDownload={!enableDownload}
isInIncomingSharedCollection={isInIncomingSharedCollection}
isInTrashSection={activeCollectionID === TRASH_SECTION}
isInHiddenSection={isInHiddenSection}
onTriggerSyncWithRemote={handleTriggerSyncWithRemote}
onToggleFavorite={handleToggleFavorite}
onDownload={handleDownload}
onDelete={handleDelete}
onSaveEditedImageCopy={handleSaveEditedImageCopy}
{...{
user,
files,
isInHiddenSection,
isInIncomingSharedCollection,
favoriteFileIDs,
fileCollectionIDs,
allCollectionsNameByID,
onSelectCollection,
onSelectPerson,
}}
onTriggerSyncWithRemote={handleTriggerSyncWithRemote}
onToggleFavorite={handleToggleFavorite}
onDownload={handleDownload}
onDelete={handleDelete}
onSaveEditedImageCopy={handleSaveEditedImageCopy}
/>
</Container>
);
};
export default PhotoFrame;
const Container = styled("div")`
flex: 1;
width: 100%;
`;
/**
* See: [Note: Timeline date string]

View File

@@ -1,349 +0,0 @@
import { Overlay } from "@/base/components/containers";
import { formattedDateRelative } from "@/base/i18n-date";
import log from "@/base/log";
import { downloadManager } from "@/gallery/services/download";
import { enteFileDeletionDate } 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 type { DisplayFile } from "components/PhotoFrame";
import { GalleryContext } from "pages/gallery";
import React, { useContext, useEffect, useRef, useState } from "react";
import { shouldShowAvatar } from "utils/file";
import Avatar from "./Avatar";
interface PreviewCardProps {
file: DisplayFile;
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 longPressCallback = () => {
onSelect(!selected);
};
const longPress = useLongPress(longPressCallback, 500);
const [imgSrc, setImgSrc] = useState<string>(file.msrc);
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
const main = async () => {
try {
if (file.msrc) {
return;
}
const url: string =
await downloadManager.renderableThumbnailURL(
file,
showPlaceholder,
);
if (!isMounted.current || !url) {
return;
}
setImgSrc(url);
} catch (e) {
log.error("preview card useEffect failed", e);
// no-op
}
};
main();
}, [showPlaceholder]);
const handleClick = () => {
if (selectOnClick) {
if (isRangeSelectActive) {
onRangeSelect();
} else {
onSelect(!selected);
}
} else if (file?.msrc || imgSrc) {
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={!file?.msrc && !imgSrc}
{...(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} />
) : imgSrc ? (
<img src={imgSrc} />
) : (
<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>
);
}

View File

@@ -96,13 +96,13 @@ import CollectionNamer, {
import { GalleryBarAndListHeader } from "components/Collections/GalleryBarAndListHeader";
import { Export } from "components/Export";
import { ITEM_TYPE, TimeStampListItem } from "components/FileList";
import { FileListWithViewer } from "components/FileListWithViewer";
import {
FilesDownloadProgress,
FilesDownloadProgressAttributes,
} from "components/FilesDownloadProgress";
import { FixCreationTime } from "components/FixCreationTime";
import GalleryEmptyState from "components/GalleryEmptyState";
import PhotoFrame from "components/PhotoFrame";
import { Sidebar } from "components/Sidebar";
import { Upload, type UploadTypeSelectorIntent } from "components/Upload";
import SelectedFileOptions from "components/pages/gallery/SelectedFileOptions";
@@ -1047,21 +1047,22 @@ const Page: React.FC = () => {
!state.view.activePerson ? (
<PeopleEmptyState />
) : (
<PhotoFrame
<FileListWithViewer
mode={barMode}
modePlus={isInSearchMode ? "search" : barMode}
user={user}
files={filteredFiles}
setSelected={setSelected}
selected={selected}
favoriteFileIDs={state.favoriteFileIDs}
activeCollectionID={activeCollectionID}
activePersonID={activePerson?.id}
enableDownload={true}
fileCollectionIDs={state.fileCollectionIDs}
allCollectionsNameByID={state.allCollectionsNameByID}
showAppDownloadBanner={
files.length < 30 && !isInSearchMode
}
selectable={true}
selected={selected}
setSelected={setSelected}
activeCollectionID={activeCollectionID}
activePersonID={activePerson?.id}
fileCollectionIDs={state.fileCollectionIDs}
allCollectionsNameByID={state.allCollectionsNameByID}
isInIncomingSharedCollection={
collectionSummaries.get(activeCollectionID)?.type ==
"incomingShareCollaborator" ||
@@ -1069,10 +1070,10 @@ const Page: React.FC = () => {
"incomingShareViewer"
}
isInHiddenSection={barMode == "hidden-albums"}
favoriteFileIDs={state.favoriteFileIDs}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
selectable={true}
onMarkUnsyncedFavoriteUpdate={
handleMarkUnsyncedFavoriteUpdate
}

View File

@@ -52,11 +52,11 @@ import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import { Box, Button, IconButton, Stack, styled, Tooltip } from "@mui/material";
import Typography from "@mui/material/Typography";
import { ITEM_TYPE, TimeStampListItem } from "components/FileList";
import { FileListWithViewer } from "components/FileListWithViewer";
import {
FilesDownloadProgress,
FilesDownloadProgressAttributes,
} from "components/FilesDownloadProgress";
import PhotoFrame from "components/PhotoFrame";
import { Upload } from "components/Upload";
import { t } from "i18next";
import { useRouter } from "next/router";
@@ -509,19 +509,19 @@ export default function PublicCollectionGallery() {
)}
</NavbarBase>
<PhotoFrame
<FileListWithViewer
files={publicFiles}
onSyncWithRemote={handleSyncWithRemote}
setSelected={setSelected}
selected={selected}
activeCollectionID={ALL_SECTION}
enableDownload={downloadEnabled}
selectable={downloadEnabled}
selected={selected}
setSelected={setSelected}
activeCollectionID={ALL_SECTION}
fileCollectionIDs={undefined}
allCollectionsNameByID={undefined}
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
selectable={downloadEnabled}
onSyncWithRemote={handleSyncWithRemote}
/>
{blockingLoad && <TranslucentLoadingOverlay />}
<Upload

View File

@@ -32,7 +32,6 @@ import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import FullscreenExitOutlinedIcon from "@mui/icons-material/FullscreenExitOutlined";
import FullscreenOutlinedIcon from "@mui/icons-material/FullscreenOutlined";
import {
Box,
Dialog,
DialogContent,
DialogTitle,
@@ -1025,7 +1024,7 @@ const Shortcuts: React.FC<ShortcutsProps> = ({
<DialogTitle>{t("shortcuts")}</DialogTitle>
<DialogCloseIconButton {...{ onClose }} />
</SpacedRow>
<ShortcutsContent sx={{ "&&": { pt: 2, pb: 5, px: 5 } }}>
<ShortcutsContent>
<Shortcut action={t("close")} shortcut={ut("Esc")} />
<Shortcut
action={formattedListJoin([t("previous"), t("next")])}
@@ -1074,10 +1073,17 @@ const Shortcuts: React.FC<ShortcutsProps> = ({
</Dialog>
);
const ShortcutsContent = styled(DialogContent)`
display: flex;
flex-direction: column;
gap: 16px;
const ShortcutsContent: React.FC<React.PropsWithChildren> = ({ children }) => (
<DialogContent sx={{ "&&": { pt: 1, pb: 5, px: 5 } }}>
<ShortcutsTable>
<tbody>{children}</tbody>
</ShortcutsTable>
</DialogContent>
);
const ShortcutsTable = styled("table")`
border-collapse: separate;
border-spacing: 0 14px;
`;
interface ShortcutProps {
@@ -1086,12 +1092,18 @@ interface ShortcutProps {
}
const Shortcut: React.FC<ShortcutProps> = ({ action, shortcut }) => (
<Box sx={{ display: "flex", gap: 2 }}>
<Typography sx={{ color: "text.muted", minWidth: "min(20ch, 40svw)" }}>
<tr>
<Typography
component="td"
sx={{ color: "text.muted", width: "min(20ch, 40svw)" }}
>
{action}
</Typography>
<Typography sx={{ fontWeight: "medium" }}>{shortcut}</Typography>
</Box>
<Typography component="td" sx={{ fontWeight: "medium" }}>
{shortcut}
</Typography>
</tr>
);
const fileIsEditableImage = (file: EnteFile) => {

View File

@@ -3,4 +3,4 @@ export const IMAGE_CONTAINER_MAX_HEIGHT = 180;
export const IMAGE_CONTAINER_MAX_WIDTH = 180;
export const MIN_COLUMNS = 4;
// TODO: Move the PhotoList component here.
// TODO: Move the FileList component here.