[desktop] People - WIP - Part x/x (#3667)
- Move cluster merging from behind ff - Other minor people related features
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user