[desktop] People bar - WIP - Part x/x (#3418)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useIsMobileWidth } from "@/base/hooks";
|
||||
import log from "@/base/log";
|
||||
import type { Person } from "@/new/photos/services/ml/cgroups";
|
||||
import type { CollectionSummary } from "@/new/photos/types/collection";
|
||||
import {
|
||||
@@ -22,8 +23,7 @@ import {
|
||||
MIN_COLUMNS,
|
||||
} from "components/PhotoList/constants";
|
||||
import { t } from "i18next";
|
||||
import memoize from "memoize-one";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList, ListChildComponentProps, areEqual } from "react-window";
|
||||
import { ALL_SECTION, COLLECTION_LIST_SORT_BY } from "utils/collection";
|
||||
@@ -82,21 +82,22 @@ export interface CollectionListBarProps {
|
||||
|
||||
export const CollectionListBar: React.FC<CollectionListBarProps> = ({
|
||||
mode,
|
||||
setMode,
|
||||
collectionSummaries,
|
||||
activeCollectionID,
|
||||
setActiveCollectionID,
|
||||
onShowAllCollections,
|
||||
collectionListSortBy,
|
||||
setCollectionListSortBy,
|
||||
// people,
|
||||
// activePerson,
|
||||
people,
|
||||
activePerson,
|
||||
// onSelectPerson
|
||||
}) => {
|
||||
const windowSize = useWindowSize();
|
||||
const isMobile = useIsMobileWidth();
|
||||
|
||||
const collectionListWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const collectionListRef = React.useRef(null);
|
||||
const listWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef(null);
|
||||
|
||||
const [scrollObj, setScrollObj] = useState<{
|
||||
scrollLeft?: number;
|
||||
@@ -105,40 +106,36 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
|
||||
}>({});
|
||||
|
||||
const updateScrollObj = () => {
|
||||
if (!collectionListWrapperRef.current) {
|
||||
if (!listWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
const { scrollLeft, scrollWidth, clientWidth } =
|
||||
collectionListWrapperRef.current;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = listWrapperRef.current;
|
||||
setScrollObj({ scrollLeft, scrollWidth, clientWidth });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!collectionListWrapperRef.current) {
|
||||
if (!listWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
// Add event listener
|
||||
collectionListWrapperRef.current?.addEventListener(
|
||||
"scroll",
|
||||
updateScrollObj,
|
||||
);
|
||||
listWrapperRef.current?.addEventListener("scroll", updateScrollObj);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
updateScrollObj();
|
||||
// Remove event listener on cleanup
|
||||
return () =>
|
||||
collectionListWrapperRef.current?.removeEventListener(
|
||||
listWrapperRef.current?.removeEventListener(
|
||||
"resize",
|
||||
updateScrollObj,
|
||||
);
|
||||
}, [collectionListWrapperRef.current]);
|
||||
}, [listWrapperRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollObj();
|
||||
}, [windowSize, collectionSummaries]);
|
||||
|
||||
const scrollComponent = (direction: number) => () => {
|
||||
collectionListWrapperRef.current.scrollBy(250 * direction, 0);
|
||||
listWrapperRef.current.scrollBy(250 * direction, 0);
|
||||
};
|
||||
|
||||
const onFarLeft = scrollObj.scrollLeft === 0;
|
||||
@@ -146,47 +143,44 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
|
||||
scrollObj.scrollLeft + scrollObj.clientWidth === scrollObj.scrollWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (!collectionListRef.current) {
|
||||
if (!listRef.current) {
|
||||
return;
|
||||
}
|
||||
// scroll the active collection into view
|
||||
const activeCollectionIndex = collectionSummaries.findIndex(
|
||||
(item) => item.id === activeCollectionID,
|
||||
);
|
||||
collectionListRef.current.scrollToItem(activeCollectionIndex, "smart");
|
||||
listRef.current.scrollToItem(activeCollectionIndex, "smart");
|
||||
}, [activeCollectionID]);
|
||||
|
||||
const onCollectionClick = (collectionID?: number) => {
|
||||
setActiveCollectionID(collectionID ?? ALL_SECTION);
|
||||
};
|
||||
|
||||
const itemData = createItemData(
|
||||
collectionSummaries,
|
||||
activeCollectionID,
|
||||
onCollectionClick,
|
||||
const itemData = useMemo<ItemData>(
|
||||
() =>
|
||||
mode == "albums" || mode == "hidden-albums"
|
||||
? {
|
||||
type: "collections",
|
||||
collectionSummaries,
|
||||
activeCollectionID,
|
||||
onCollectionClick: (id?: number) =>
|
||||
setActiveCollectionID(id ?? ALL_SECTION),
|
||||
}
|
||||
: { type: "people", people, activePerson },
|
||||
[
|
||||
mode,
|
||||
collectionSummaries,
|
||||
activeCollectionID,
|
||||
setActiveCollectionID,
|
||||
people,
|
||||
activePerson,
|
||||
],
|
||||
);
|
||||
|
||||
// TODO-Cluster
|
||||
log.debug(() => ["renderering collection-bar", itemData]);
|
||||
|
||||
return (
|
||||
<CollectionListBarWrapper>
|
||||
<BarWrapper>
|
||||
<SpaceBetweenFlex mb={1}>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Typography
|
||||
color={mode == "people" ? "text.muted" : "text.base"}
|
||||
>
|
||||
{mode == "hidden-albums"
|
||||
? t("hidden_albums")
|
||||
: t("albums")}
|
||||
</Typography>
|
||||
{process.env.NEXT_PUBLIC_ENTE_WIP_CL && (
|
||||
<Typography
|
||||
color={
|
||||
mode == "people" ? "text.base" : "text.muted"
|
||||
}
|
||||
>
|
||||
{t("people")}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<ModeIndicator {...{ mode, setMode }} />
|
||||
{isMobile && (
|
||||
<Box display="flex" alignItems={"center"} gap={1}>
|
||||
<CollectionListSortBy
|
||||
@@ -201,32 +195,32 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
|
||||
)}
|
||||
</SpaceBetweenFlex>
|
||||
<Box display="flex" alignItems="flex-start" gap={2}>
|
||||
<CollectionListWrapper>
|
||||
<ListWrapper>
|
||||
{!onFarLeft && (
|
||||
<ScrollButtonLeft onClick={scrollComponent(-1)} />
|
||||
)}
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<FixedSizeList
|
||||
ref={collectionListRef}
|
||||
outerRef={collectionListWrapperRef}
|
||||
ref={listRef}
|
||||
outerRef={listWrapperRef}
|
||||
itemData={itemData}
|
||||
layout="horizontal"
|
||||
width={width}
|
||||
height={110}
|
||||
itemKey={getItemKey}
|
||||
itemCount={collectionSummaries.length}
|
||||
itemSize={CollectionListBarCardWidth}
|
||||
itemCount={getItemCount(itemData)}
|
||||
itemSize={94}
|
||||
useIsScrolling
|
||||
>
|
||||
{CollectionCardContainer}
|
||||
{ListItem}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
{!onFarRight && (
|
||||
<ScrollButtonRight onClick={scrollComponent(+1)} />
|
||||
)}
|
||||
</CollectionListWrapper>
|
||||
</ListWrapper>
|
||||
{!isMobile && (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -244,25 +238,33 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CollectionListBarWrapper>
|
||||
</BarWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionListWrapper = styled(Box)`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 86px;
|
||||
width: 100%;
|
||||
const BarWrapper = styled(Box)`
|
||||
padding-inline: 24px;
|
||||
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
|
||||
padding-inline: 4px;
|
||||
}
|
||||
margin-block-end: 16px;
|
||||
border-block-end: 1px solid ${({ theme }) => theme.palette.divider};
|
||||
`;
|
||||
|
||||
const CollectionListBarWrapper = styled(Box)`
|
||||
padding: 0 24px;
|
||||
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.palette.divider};
|
||||
`;
|
||||
const ModeIndicator: React.FC<
|
||||
Pick<CollectionListBarProps, "mode" | "setMode">
|
||||
> = ({ mode }) => (
|
||||
<Stack direction="row" sx={{ gap: "12px" }}>
|
||||
<Typography color={mode == "people" ? "text.muted" : "text.base"}>
|
||||
{mode == "hidden-albums" ? t("hidden_albums") : t("albums")}
|
||||
</Typography>
|
||||
{process.env.NEXT_PUBLIC_ENTE_WIP_CL && (
|
||||
<Typography color={mode == "people" ? "text.base" : "text.muted"}>
|
||||
{t("people")}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
// // TODO-Cluster
|
||||
// const PeopleHeaderButton = styled("button")(
|
||||
@@ -283,36 +285,66 @@ const CollectionListBarWrapper = styled(Box)`
|
||||
// `,
|
||||
// );
|
||||
|
||||
interface ItemData {
|
||||
collectionSummaries: CollectionSummary[];
|
||||
activeCollectionID?: number;
|
||||
onCollectionClick: (id?: number) => void;
|
||||
}
|
||||
const ListWrapper = styled(Box)`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 86px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CollectionListBarCardWidth = 94;
|
||||
type ItemData =
|
||||
| {
|
||||
type: "collections";
|
||||
collectionSummaries: CollectionSummary[];
|
||||
activeCollectionID?: number;
|
||||
onCollectionClick: (id?: number) => void;
|
||||
}
|
||||
| {
|
||||
type: "people";
|
||||
people: Person[];
|
||||
activePerson: Person;
|
||||
};
|
||||
|
||||
const createItemData = memoize(
|
||||
(collectionSummaries, activeCollectionID, onCollectionClick) => ({
|
||||
collectionSummaries,
|
||||
activeCollectionID,
|
||||
onCollectionClick,
|
||||
}),
|
||||
);
|
||||
const getItemCount = (data: ItemData) => {
|
||||
switch (data.type) {
|
||||
case "collections": {
|
||||
return data.collectionSummaries.length;
|
||||
}
|
||||
case "people": {
|
||||
return data.people.length;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const CollectionCardContainer = React.memo(
|
||||
({
|
||||
data,
|
||||
index,
|
||||
style,
|
||||
isScrolling,
|
||||
}: ListChildComponentProps<ItemData>) => {
|
||||
const { collectionSummaries, activeCollectionID, onCollectionClick } =
|
||||
data;
|
||||
const getItemKey = (index: number, data: ItemData) => {
|
||||
switch (data.type) {
|
||||
case "collections": {
|
||||
const collectionSummary = data.collectionSummaries[index];
|
||||
return `${data.type}-${collectionSummary.id}-${collectionSummary.coverFile?.id}`;
|
||||
}
|
||||
case "people": {
|
||||
// TODO-Cluster
|
||||
const person =
|
||||
data.people[index] ?? data.activePerson ?? data.people[0];
|
||||
return `${data.type}-${person.id}-${person.displayFaceID}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collectionSummary = collectionSummaries[index];
|
||||
const ListItem = memo((props: ListChildComponentProps<ItemData>) => {
|
||||
const { data, index, style, isScrolling } = props;
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
let card: React.ReactNode;
|
||||
|
||||
switch (data.type) {
|
||||
case "collections": {
|
||||
const {
|
||||
collectionSummaries,
|
||||
activeCollectionID,
|
||||
onCollectionClick,
|
||||
} = data;
|
||||
const collectionSummary = collectionSummaries[index];
|
||||
card = (
|
||||
<CollectionListBarCard
|
||||
key={collectionSummary.id}
|
||||
activeCollectionID={activeCollectionID}
|
||||
@@ -320,15 +352,20 @@ const CollectionCardContainer = React.memo(
|
||||
collectionSummary={collectionSummary}
|
||||
onCollectionClick={onCollectionClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
areEqual,
|
||||
);
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const getItemKey = (index: number, data: ItemData) => {
|
||||
return `${data.collectionSummaries[index].id}-${data.collectionSummaries[index].coverFile?.id}`;
|
||||
};
|
||||
case "people": {
|
||||
const { people, activePerson } = data;
|
||||
const person = people[index];
|
||||
card = <PersonCard {...{ person, activePerson }} />;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return <div style={style}>{card}</div>;
|
||||
}, areEqual);
|
||||
|
||||
const ScrollButtonBase: React.FC<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
@@ -483,3 +520,26 @@ const Ellipse = styled(Typography)`
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
`;
|
||||
|
||||
interface PersondCardProps {
|
||||
person: Person;
|
||||
activePerson: Person;
|
||||
// onCollectionClick: (collectionID: number) => void;
|
||||
// isScrolling?: boolean;
|
||||
}
|
||||
|
||||
const PersonCard = ({ person, activePerson }: PersondCardProps) => (
|
||||
<Box>
|
||||
<CollectionCard
|
||||
collectionTile={CollectionBarTile}
|
||||
coverFile={person.displayFaceFile}
|
||||
onClick={() => {
|
||||
//onCollectionClick(collectionSummary.id);
|
||||
}}
|
||||
>
|
||||
<CollectionCardText collectionName={person.name} />
|
||||
{/* <CollectionCardIcon collectionType={collectionSummary.type} /> */}
|
||||
</CollectionCard>
|
||||
{activePerson.id === person.id && <ActiveIndicator />}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1034,8 +1034,10 @@ export default function Gallery() {
|
||||
setActiveCollectionID(ALL_SECTION);
|
||||
};
|
||||
|
||||
const handleSelectPerson = (person: Person) => {
|
||||
setActivePerson(person);
|
||||
const handleSelectPerson = (person: Person | undefined) => {
|
||||
// TODO-Cluster: The person bar does not have an "all" mode, use the
|
||||
// first person.
|
||||
setActivePerson(person || people[0]);
|
||||
setBarMode("people");
|
||||
};
|
||||
|
||||
|
||||
@@ -73,9 +73,13 @@ export interface SearchBarProps {
|
||||
*/
|
||||
onSelectSearchOption: (o: SearchOption | undefined) => void;
|
||||
/**
|
||||
* Select a person.
|
||||
* Called when the user selects a person shown in the empty state view, or
|
||||
* clicks the people list header itself.
|
||||
*
|
||||
* @param person The selected person, or `undefined` if the user clicked the
|
||||
* generic people header.
|
||||
*/
|
||||
onSelectPerson: (person: Person) => void;
|
||||
onSelectPerson: (person: Person | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +188,7 @@ const SearchInput: React.FC<Omit<SearchBarProps, "onShowSearchInput">> = ({
|
||||
onSelectSearchOption(undefined);
|
||||
};
|
||||
|
||||
const handleSelectPerson = (person: Person) => {
|
||||
const handleSelectPerson = (person: Person | undefined) => {
|
||||
resetSearch();
|
||||
onSelectPerson(person);
|
||||
};
|
||||
@@ -368,16 +372,13 @@ const shouldShowEmptyState = (inputValue: string) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Called when the user selects a person shown in the empty state view. */
|
||||
onSelectPerson: (person: Person) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view shown in the menu area when the user has not typed anything in the
|
||||
* search box.
|
||||
*/
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ onSelectPerson }) => {
|
||||
const EmptyState: React.FC<Pick<SearchBarProps, "onSelectPerson">> = ({
|
||||
onSelectPerson,
|
||||
}) => {
|
||||
const mlStatus = useSyncExternalStore(mlStatusSubscribe, mlStatusSnapshot);
|
||||
const people = useSyncExternalStore(peopleSubscribe, peopleSnapshot);
|
||||
|
||||
@@ -411,7 +412,7 @@ const EmptyState: React.FC<EmptyStateProps> = ({ onSelectPerson }) => {
|
||||
<Box sx={{ textAlign: "left" }}>
|
||||
{people && people.length > 0 && (
|
||||
<>
|
||||
<PeopleHeader />
|
||||
<PeopleHeader onClick={() => onSelectPerson(undefined)} />
|
||||
<SearchPeopleList {...{ people, onSelectPerson }} />
|
||||
</>
|
||||
)}
|
||||
@@ -422,13 +423,37 @@ const EmptyState: React.FC<EmptyStateProps> = ({ onSelectPerson }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PeopleHeader: React.FC = () => (
|
||||
<Stack direction="row" color="text.muted">
|
||||
<Typography color="text.base" variant="large">
|
||||
{t("people")}
|
||||
</Typography>
|
||||
<ChevronRightIcon />
|
||||
</Stack>
|
||||
interface PeopleHeaderProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const PeopleHeader: React.FC<PeopleHeaderProps> = ({ onClick }) => (
|
||||
<PeopleHeaderButton {...{ onClick }}>
|
||||
<Stack direction="row" color="text.muted">
|
||||
<Typography color="text.base" variant="large">
|
||||
{t("people")}
|
||||
</Typography>
|
||||
<ChevronRightIcon />
|
||||
</Stack>
|
||||
</PeopleHeaderButton>
|
||||
);
|
||||
|
||||
const PeopleHeaderButton = styled("button")(
|
||||
({ theme }) => `
|
||||
/* Reset some button defaults that are affecting us */
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
/* Button should do this for us, but it isn't working inside the select */
|
||||
cursor: pointer;
|
||||
/* The color for the chevron */
|
||||
color: ${theme.colors.stroke.muted};
|
||||
/* Hover indication */
|
||||
&& :hover {
|
||||
color: ${theme.colors.stroke.base};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const Option: React.FC<OptionProps<SearchOption, false>> = (props) => (
|
||||
|
||||
Reference in New Issue
Block a user