[desktop] People - WIP - Part x/x (#3667)

- Move cluster merging from behind ff
- Other minor people related features
This commit is contained in:
Manav Rathi
2024-10-11 19:56:37 +05:30
committed by GitHub
8 changed files with 109 additions and 187 deletions

View File

@@ -597,8 +597,9 @@ const ExitSection: React.FC = () => {
const handleLogout = () =>
showMiniDialog({
title: t("logout_message"),
message: t("logout_message"),
continue: { text: t("logout"), color: "critical", action: logout },
buttonDirection: "row",
});
return (

View File

@@ -108,6 +108,8 @@ export interface MiniDialogAttributes {
text: string;
action: () => void;
};
/** The direction in which the buttons are stacked. Default is "column". */
buttonDirection?: "row" | "column";
}
type MiniDialogProps = Omit<DialogProps, "onClose"> & {
@@ -173,7 +175,7 @@ export const AttributedMiniDialog: React.FC<
onClose={handleClose}
{...rest}
>
{(attributes.icon ?? attributes.title) && (
{(attributes.icon ?? attributes.title) ? (
<Box
sx={{
display: "flex",
@@ -192,6 +194,8 @@ export const AttributedMiniDialog: React.FC<
)}
{attributes.icon}
</Box>
) : (
<Box sx={{ height: "8px" }} /> /* Spacer */
)}
<DialogContent>
{attributes.message && (
@@ -205,7 +209,10 @@ export const AttributedMiniDialog: React.FC<
</Typography>
)}
{children}
<Stack sx={{ paddingBlockStart: "24px", gap: "12px" }}>
<Stack
sx={{ paddingBlockStart: "24px", gap: "12px" }}
direction={attributes.buttonDirection ?? "column"}
>
{phase == "failed" && (
<Typography variant="small" color="critical.main">
{t("generic_error")}

View File

@@ -29,7 +29,7 @@ export const LoadingButton: React.FC<ButtonProps & { loading?: boolean }> = ({
}}
{...rest}
>
<CircularProgress size={20} />
<CircularProgress size={20} sx={{ color: "inherit" }} />
</FocusVisibleButton>
) : (
<FocusVisibleButton {...{ color, disabled, sx }} {...rest}>

View File

@@ -1,77 +0,0 @@
import type { ModalVisibilityProps } from "@/base/components/utils/modal";
import log from "@/base/log";
import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { t } from "i18next";
import React from "react";
type NameInputDialogProps = ModalVisibilityProps & {
/** Title of the dialog. */
title: string;
/** Placeholder string to show in the text input when it is empty. */
placeholder: string;
/** The existing value, if any, of the text input. */
initialValue?: string | undefined;
/** Title of the submit button */
submitButtonTitle: string;
/**
* Callback invoked when the submit button is pressed.
*
* @param name The current value of the text input.
* */
onSubmit: ((name: string) => void) | ((name: string) => Promise<void>);
};
/**
* A dialog that can be used to ask for a name or some other such singular text
* input.
*
* See also: {@link CollectionNamer}, its older sibling.
*/
export const NameInputDialog: React.FC<NameInputDialogProps> = ({
open,
onClose,
title,
placeholder,
initialValue,
submitButtonTitle,
onSubmit,
}) => {
const handleSubmit: SingleInputFormProps["callback"] = async (
inputValue,
setFieldError,
) => {
try {
await onSubmit(inputValue);
onClose();
} catch (e) {
log.error(`Error when submitting value ${inputValue}`, e);
setFieldError(t("generic_error_retry"));
}
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
PaperProps={{ sx: { padding: "8px 4px 4px 4px" } }}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<SingleInputForm
fieldType="text"
placeholder={placeholder}
initialValue={initialValue}
callback={handleSubmit}
buttonText={submitButtonTitle}
submitButtonProps={{ sx: { mt: 2, mb: 1 } }}
secondaryButtonAction={onClose}
/>
</DialogContent>
</Dialog>
);
};

View File

@@ -226,16 +226,20 @@ export const GalleryBarImpl: React.FC<GalleryBarImplProps> = ({
],
);
const controls1 = isMobile && mode != "people" && (
<Box display="flex" alignItems={"center"} gap={1}>
<CollectionsSortOptions
activeSortBy={collectionsSortBy}
onChangeSortBy={onChangeCollectionsSortBy}
disableTriggerButtonBackground
/>
<IconButton onClick={onShowAllCollections}>
<ExpandMore />
</IconButton>
const controls1 = isMobile && (
<Box display="flex" alignItems={"center"} gap={1} minHeight={"64px"}>
{mode != "people" && (
<>
<CollectionsSortOptions
activeSortBy={collectionsSortBy}
onChangeSortBy={onChangeCollectionsSortBy}
disableTriggerButtonBackground
/>
<IconButton onClick={onShowAllCollections}>
<ExpandMore />
</IconButton>
</>
)}
</Box>
);

View File

@@ -26,6 +26,15 @@ interface GalleryItemsSummaryProps {
* An optional element, usually an icon, placed after the file count.
*/
endIcon?: React.ReactNode;
/**
* An optional click handler for the name.
*
* Note: Do not use this as the primary / only mechanism for the
* corresponding functionality to be invoked, since this click handler will
* be accessible only to sighted mouse users. However, it is fine to use it
* as an alternate means of invoking some function.
*/
onNameClick?: () => void;
}
/**
@@ -37,9 +46,10 @@ export const GalleryItemsSummary: React.FC<GalleryItemsSummaryProps> = ({
nameProps,
fileCount,
endIcon,
onNameClick,
}) => (
<div>
<Typography variant="h3" {...(nameProps ?? {})}>
<Typography variant="h3" {...(nameProps ?? {})} onClick={onNameClick}>
{name}
</Typography>

View File

@@ -1,10 +1,6 @@
import { useModalVisibility } from "@/base/components/utils/modal";
import { pt } from "@/base/i18n";
import {
addCGroup,
deleteCGroup,
renameCGroup,
} from "@/new/photos/services/ml";
import { deleteCGroup, renameCGroup } from "@/new/photos/services/ml";
import { type Person } from "@/new/photos/services/ml/people";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
@@ -14,15 +10,11 @@ import MoreHoriz from "@mui/icons-material/MoreHoriz";
import { IconButton, Stack, Tooltip } from "@mui/material";
import { ClearIcon } from "@mui/x-date-pickers";
import { t } from "i18next";
import React, { useState } from "react";
import type { FaceCluster } from "../../services/ml/cluster";
import type { CGroup } from "../../services/user-entity";
import React from "react";
import { useAppContext } from "../../types/context";
import { AddPersonDialog } from "../AddPersonDialog";
import { SpaceBetweenFlex } from "../mui";
import { NameInputDialog } from "../NameInputDialog";
import { SingleInputDialog } from "../SingleInputForm";
import { useWrapAsyncOperation } from "../use-wrap-async";
import type { GalleryBarImplProps } from "./BarImpl";
import { GalleryItemsHeaderAdapter, GalleryItemsSummary } from "./ListHeader";
@@ -62,47 +54,37 @@ export const PeopleHeader: React.FC<PeopleHeaderProps> = ({
return (
<GalleryItemsHeaderAdapter>
<SpaceBetweenFlex>
<GalleryItemsSummary
name={
person.name ?? pt("Unnamed person") /* TODO-Cluster */
}
nameProps={person.name ? {} : { color: "text.muted" }}
fileCount={person.fileIDs.length}
/>
{person.type == "cgroup" ? (
<CGroupPersonOptions
cgroup={person.cgroup}
<CGroupPersonHeader
person={person}
{...{ onSelectPerson }}
/>
) : (
<ClusterPersonOptions
cluster={person.cluster}
{...{ people }}
/>
<ClusterPersonHeader person={person} {...{ people }} />
)}
</SpaceBetweenFlex>
</GalleryItemsHeaderAdapter>
);
};
type CGroupPersonOptionsProps = Pick<PeopleHeaderProps, "onSelectPerson"> & {
cgroup: CGroup;
type CGroupPersonHeaderProps = Pick<PeopleHeaderProps, "onSelectPerson"> & {
person: Exclude<Person, { type: "cluster" }>;
};
const CGroupPersonOptions: React.FC<CGroupPersonOptionsProps> = ({
cgroup,
const CGroupPersonHeader: React.FC<CGroupPersonHeaderProps> = ({
person,
onSelectPerson,
}) => {
const cgroup = person.cgroup;
const { showMiniDialog } = useAppContext();
const { show: showNameInput, props: nameInputVisibilityProps } =
useModalVisibility();
const handleRename = useWrapAsyncOperation((name: string) =>
renameCGroup(cgroup, name),
);
const handleRename = (name: string) => renameCGroup(cgroup, name);
const handleDeletePerson = () =>
const handleReset = () =>
showMiniDialog({
title: pt("Reset person?"),
message: pt(
@@ -111,18 +93,24 @@ const CGroupPersonOptions: React.FC<CGroupPersonOptionsProps> = ({
continue: {
text: t("reset"),
color: "primary",
action: deletePerson,
action: async () => {
await deleteCGroup(cgroup);
// Reset the selection to the default state.
onSelectPerson(undefined);
},
},
});
const deletePerson = useWrapAsyncOperation(async () => {
await deleteCGroup(cgroup);
// Reset the selection to the default state.
onSelectPerson(undefined);
});
// While technically it is possible for the cgroup not to have a name,
// logical wise we shouldn't be ending up here without a name.
const name = cgroup.data.name ?? "";
return (
<>
<GalleryItemsSummary
name={name}
fileCount={person.fileIDs.length}
/>
<OverflowMenu
ariaControls={"person-options"}
triggerButtonIcon={<MoreHoriz />}
@@ -137,7 +125,7 @@ const CGroupPersonOptions: React.FC<CGroupPersonOptionsProps> = ({
<OverflowMenuOption
startIcon={<ClearIcon />}
centerAlign
onClick={handleDeletePerson}
onClick={handleReset}
>
{pt("Reset")}
</OverflowMenuOption>
@@ -150,7 +138,7 @@ const CGroupPersonOptions: React.FC<CGroupPersonOptionsProps> = ({
placeholder={t("enter_name")}
autoComplete="name"
autoFocus
initialValue={cgroup.data.name ?? ""}
initialValue={name}
submitButtonTitle={t("rename")}
onSubmit={handleRename}
/>
@@ -158,45 +146,30 @@ const CGroupPersonOptions: React.FC<CGroupPersonOptionsProps> = ({
);
};
type ClusterPersonOptionsProps = Pick<PeopleHeaderProps, "people"> & {
cluster: FaceCluster;
type ClusterPersonHeaderProps = Pick<PeopleHeaderProps, "people"> & {
person: Exclude<Person, { type: "cgroup" }>;
};
const ClusterPersonOptions: React.FC<ClusterPersonOptionsProps> = ({
const ClusterPersonHeader: React.FC<ClusterPersonHeaderProps> = ({
people,
cluster,
person,
}) => {
const { startLoading, finishLoading } = useAppContext();
const cluster = person.cluster;
const [openNameInput, setOpenNameInput] = useState(false);
const [openAddPersonDialog, setOpenAddPersonDialog] = useState(false);
const handleAddPerson = () => {
// TODO-Cluster
if (process.env.NEXT_PUBLIC_ENTE_WIP_CL) {
// WIP path
setOpenAddPersonDialog(true);
} else {
// Existing path
setOpenNameInput(true);
}
};
// TODO-Cluster
const addPersonWithName = async (name: string) => {
startLoading();
try {
await addCGroup(name, cluster);
} finally {
finishLoading();
}
};
const { show: showAddPerson, props: addPersonVisibilityProps } =
useModalVisibility();
return (
<>
<GalleryItemsSummary
name={pt("Unnamed person") /* TODO-Cluster */}
nameProps={{ color: "text.muted" }}
fileCount={person.fileIDs.length}
onNameClick={showAddPerson}
/>
<Stack direction="row" sx={{ alignItems: "center", gap: 2 }}>
<Tooltip title={pt("Add a name")}>
<IconButton onClick={handleAddPerson}>
<IconButton onClick={showAddPerson}>
<AddIcon />
</IconButton>
</Tooltip>
@@ -208,26 +181,15 @@ const ClusterPersonOptions: React.FC<ClusterPersonOptionsProps> = ({
<OverflowMenuOption
startIcon={<AddIcon />}
centerAlign
onClick={handleAddPerson}
onClick={showAddPerson}
>
{pt("Add a name")}
</OverflowMenuOption>
</OverflowMenu>
</Stack>
<NameInputDialog
open={openNameInput}
onClose={() => setOpenNameInput(false)}
title={pt("Add person") /* TODO-Cluster */}
placeholder={t("enter_name")}
initialValue={""}
submitButtonTitle={t("add")}
onSubmit={addPersonWithName}
/>
<AddPersonDialog
open={openAddPersonDialog}
onClose={() => setOpenAddPersonDialog(false)}
{...addPersonVisibilityProps}
{...{ people, cluster }}
/>
</>

View File

@@ -12,7 +12,8 @@ import type { SearchOption } from "@/new/photos/services/search/types";
import { VerticallyCentered } from "@ente/shared/components/Container";
import { Typography } from "@mui/material";
import { t } from "i18next";
import React from "react";
import React, { useSyncExternalStore } from "react";
import { mlStatusSnapshot, mlStatusSubscribe } from "../../services/ml";
import { GalleryItemsHeaderAdapter, GalleryItemsSummary } from "./ListHeader";
/**
@@ -43,16 +44,30 @@ export const SearchResultsHeader: React.FC<SearchResultsHeaderProps> = ({
</GalleryItemsHeaderAdapter>
);
export const PeopleEmptyState: React.FC = () => (
<VerticallyCentered>
<Typography
color="text.muted"
sx={{
// Approximately compensate for the hidden section bar
paddingBlockEnd: "86px",
}}
>
{pt("People will appear here once indexing completes")}
</Typography>
</VerticallyCentered>
);
export const PeopleEmptyState: React.FC = () => {
const mlStatus = useSyncExternalStore(mlStatusSubscribe, mlStatusSnapshot);
const message =
mlStatus?.phase == "done"
? pt(
"People will appear here when there are sufficient photos of a person",
)
: pt("People will appear here once sync completes");
return (
<VerticallyCentered>
<Typography
color="text.muted"
sx={{
mx: 1,
// Approximately compensate for the hidden section bar (86px),
// and then add a bit extra padding so that the message appears
// visually off the center, towards the top.
paddingBlockEnd: "126px",
}}
>
{message}
</Typography>
</VerticallyCentered>
);
};