[web] PhotoSwipe update - WIP (#5200)

This commit is contained in:
Manav Rathi
2025-02-28 19:57:51 +05:30
committed by GitHub
23 changed files with 790 additions and 477 deletions

View File

@@ -217,12 +217,14 @@ const CodeDisplay: React.FC<CodeDisplayProps> = ({ code }) => {
<Snackbar
open={openCopied}
message={t("copied")}
ContentProps={{
sx: (theme) => ({
backgroundColor: theme.vars.palette.fill.faint,
color: theme.vars.palette.primary.main,
backdropFilter: "blur(10px)",
}),
slotProps={{
content: {
sx: {
backgroundColor: "fill.faint",
color: "primary.main",
backdropFilter: "blur(10px)",
},
},
}}
/>
</ButtonBase>

View File

@@ -717,9 +717,11 @@ const CollectionSortOrderMenu: React.FC<CollectionSortOrderMenuProps> = ({
anchorEl={overFlowMenuIconRef.current}
open={open}
onClose={onClose}
MenuListProps={{
disablePadding: true,
"aria-labelledby": "collection-files-sort",
slotProps={{
list: {
disablePadding: true,
"aria-labelledby": "collection-files-sort",
},
}}
anchorOrigin={{
vertical: "bottom",

View File

@@ -21,8 +21,12 @@ import { t } from "i18next";
import { useRouter } from "next/router";
import { GalleryContext } from "pages/gallery";
import PhotoSwipe from "photoswipe";
import { useCallback, useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, 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,
@@ -101,7 +105,6 @@ export type PhotoFrameProps = Pick<
*/
modePlus?: GalleryBarMode | "search";
files: EnteFile[];
syncWithRemote: () => Promise<void>;
setSelected: (
selected: SelectedState | ((selected: SelectedState) => SelectedState),
) => void;
@@ -121,7 +124,10 @@ export type PhotoFrameProps = Pick<
*
* Not set in the context of the shared albums app.
*/
markUnsyncedFavoriteUpdate?: (fileID: number, isFavorite: boolean) => void;
onMarkUnsyncedFavoriteUpdate?: (
fileID: number,
isFavorite: boolean,
) => void;
/**
* Called when the component wants to mark the given files as deleted in the
* the in-memory, unsynced, state maintained by the top level gallery.
@@ -131,7 +137,7 @@ export type PhotoFrameProps = Pick<
*
* Not set in the context of the shared albums app.
*/
markTempDeleted?: (files: EnteFile[]) => void;
onMarkTempDeleted?: (files: EnteFile[]) => void;
/** This will be set if mode is not "people". */
activeCollectionID: number;
/** This will be set if mode is "people". */
@@ -142,6 +148,7 @@ export type PhotoFrameProps = Pick<
isInHiddenSection?: boolean;
setFilesDownloadProgressAttributesCreator?: SetFilesDownloadProgressAttributesCreator;
selectable?: boolean;
onSyncWithRemote: () => Promise<void>;
};
/**
@@ -151,12 +158,11 @@ const PhotoFrame = ({
mode,
modePlus,
files,
syncWithRemote,
setSelected,
selected,
favoriteFileIDs,
markUnsyncedFavoriteUpdate,
markTempDeleted,
onMarkUnsyncedFavoriteUpdate,
onMarkTempDeleted,
activeCollectionID,
activePersonID,
enableDownload,
@@ -167,6 +173,7 @@ const PhotoFrame = ({
isInHiddenSection,
setFilesDownloadProgressAttributesCreator,
selectable,
onSyncWithRemote,
onSelectCollection,
onSelectPerson,
}: PhotoFrameProps) => {
@@ -259,10 +266,23 @@ const PhotoFrame = ({
}, [selected]);
const handleTriggerSyncWithRemote = useCallback(
() => void syncWithRemote(),
[syncWithRemote],
() => void onSyncWithRemote(),
[onSyncWithRemote],
);
const handleToggleFavorite = useMemo(() => {
return favoriteFileIDs
? async (file: EnteFile) => {
const isFavorite = favoriteFileIDs!.has(file.id);
await (isFavorite ? removeFromFavorites : addToFavorites)(
file,
true,
);
onMarkUnsyncedFavoriteUpdate(file.id, !isFavorite);
}
: undefined;
}, [favoriteFileIDs, onMarkUnsyncedFavoriteUpdate]);
const handleSaveEditedImageCopy = useCallback(
(editedFile: File, collection: Collection, enteFile: EnteFile) => {
uploadManager.prepareForNewUpload();
@@ -300,7 +320,7 @@ const PhotoFrame = ({
throw new Error("Not implemented");
} else {
setOpen(false);
needUpdate && syncWithRemote();
needUpdate && onSyncWithRemote();
setIsPhotoSwipeOpen?.(false);
}
};
@@ -550,10 +570,11 @@ const PhotoFrame = ({
user={galleryContext.user ?? undefined}
files={files}
initialIndex={currentIndex}
isInTrashSection={activeCollectionID === TRASH_SECTION}
isInHiddenSection={isInHiddenSection}
disableDownload={!enableDownload}
isInHiddenSection={isInHiddenSection}
isInTrashSection={activeCollectionID === TRASH_SECTION}
onTriggerSyncWithRemote={handleTriggerSyncWithRemote}
onToggleFavorite={handleToggleFavorite}
onSaveEditedImageCopy={handleSaveEditedImageCopy}
{...{
favoriteFileIDs,
@@ -591,8 +612,8 @@ const PhotoFrame = ({
enableDownload={enableDownload}
{...{
favoriteFileIDs,
markUnsyncedFavoriteUpdate,
markTempDeleted,
onMarkUnsyncedFavoriteUpdate,
onMarkTempDeleted,
setFilesDownloadProgressAttributesCreator,
fileCollectionIDs,
allCollectionsNameByID,

View File

@@ -76,8 +76,8 @@ import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery";
export type PhotoViewerProps = Pick<
PhotoFrameProps,
| "favoriteFileIDs"
| "markUnsyncedFavoriteUpdate"
| "markTempDeleted"
| "onMarkUnsyncedFavoriteUpdate"
| "onMarkTempDeleted"
| "fileCollectionIDs"
| "allCollectionsNameByID"
| "onSelectCollection"
@@ -132,8 +132,8 @@ export const PhotoViewer: React.FC<PhotoViewerProps> = ({
gettingData,
forceConvertItem,
favoriteFileIDs,
markUnsyncedFavoriteUpdate,
markTempDeleted,
onMarkUnsyncedFavoriteUpdate,
onMarkTempDeleted,
isTrashCollection,
isInHiddenSection,
enableDownload,
@@ -511,16 +511,16 @@ export const PhotoViewer: React.FC<PhotoViewerProps> = ({
const isFavorite = favoriteFileIDs!.has(file.id);
if (!isFavorite) {
markUnsyncedFavoriteUpdate(file.id, true);
onMarkUnsyncedFavoriteUpdate(file.id, true);
void addToFavorites(file).catch((e: unknown) => {
log.error("Failed to add favorite", e);
markUnsyncedFavoriteUpdate(file.id, undefined);
onMarkUnsyncedFavoriteUpdate(file.id, undefined);
});
} else {
markUnsyncedFavoriteUpdate(file.id, false);
onMarkUnsyncedFavoriteUpdate(file.id, false);
void removeFromFavorites(file).catch((e: unknown) => {
log.error("Failed to remove favorite", e);
markUnsyncedFavoriteUpdate(file.id, undefined);
onMarkUnsyncedFavoriteUpdate(file.id, undefined);
});
}
@@ -538,7 +538,7 @@ export const PhotoViewer: React.FC<PhotoViewerProps> = ({
const handleDeleteFile = async () => {
const file = fileToDelete!;
await moveToTrash([file]);
markTempDeleted?.([file]);
onMarkTempDeleted?.([file]);
updateItems(items.filter((item) => item.id !== file.id));
setFileToDelete(undefined);
needUpdate.current = true;

View File

@@ -515,88 +515,96 @@ const Page: React.FC = () => {
};
}, [selectAll, clearSelection]);
const showSessionExpiredDialog = () =>
showMiniDialog(sessionExpiredDialogAttributes(logout));
const showSessionExpiredDialog = useCallback(
() => showMiniDialog(sessionExpiredDialogAttributes(logout)),
[showMiniDialog, logout],
);
const syncWithRemote = async (force = false, silent = false) => {
if (!navigator.onLine) return;
if (syncInProgress.current && !force) {
resync.current = { force, silent };
return;
}
const isForced = syncInProgress.current && force;
syncInProgress.current = true;
try {
const token = getToken();
if (!token) {
const handleSyncWithRemote = useCallback(
async (force = false, silent = false) => {
if (!navigator.onLine) return;
if (syncInProgress.current && !force) {
resync.current = { force, silent };
return;
}
const tokenValid = await isTokenValid(token);
if (!tokenValid) {
throw new Error(CustomError.SESSION_EXPIRED);
const isForced = syncInProgress.current && force;
syncInProgress.current = true;
try {
const token = getToken();
if (!token) {
return;
}
const tokenValid = await isTokenValid(token);
if (!tokenValid) {
throw new Error(CustomError.SESSION_EXPIRED);
}
!silent && showLoadingBar();
await preCollectionsAndFilesSync();
const allCollections = await getAllLatestCollections();
const [hiddenCollections, collections] = splitByPredicate(
allCollections,
isHiddenCollection,
);
dispatch({
type: "setAllCollections",
collections,
hiddenCollections,
});
const didUpdateNormalFiles = await syncFiles(
"normal",
collections,
(files) => dispatch({ type: "setFiles", files }),
(files) => dispatch({ type: "fetchFiles", files }),
);
const didUpdateHiddenFiles = await syncFiles(
"hidden",
hiddenCollections,
(hiddenFiles) =>
dispatch({ type: "setHiddenFiles", hiddenFiles }),
(hiddenFiles) =>
dispatch({ type: "fetchHiddenFiles", hiddenFiles }),
);
if (didUpdateNormalFiles || didUpdateHiddenFiles)
exportService.onLocalFilesUpdated();
await syncTrash(allCollections, (trashedFiles: EnteFile[]) =>
dispatch({ type: "setTrashedFiles", trashedFiles }),
);
// syncWithRemote is called with the force flag set to true before
// doing an upload. So it is possible, say when resuming a pending
// upload, that we get two syncWithRemotes happening in parallel.
//
// Do the non-file-related sync only for one of these parallel ones.
if (!isForced) {
await sync();
}
} catch (e) {
switch (e.message) {
case CustomError.SESSION_EXPIRED:
showSessionExpiredDialog();
break;
case CustomError.KEY_MISSING:
clearKeys();
router.push(PAGES.CREDENTIALS);
break;
default:
log.error("syncWithRemote failed", e);
}
} finally {
dispatch({ type: "clearUnsyncedState" });
!silent && hideLoadingBar();
}
!silent && showLoadingBar();
await preCollectionsAndFilesSync();
const allCollections = await getAllLatestCollections();
const [hiddenCollections, collections] = splitByPredicate(
allCollections,
isHiddenCollection,
);
dispatch({
type: "setAllCollections",
collections,
hiddenCollections,
});
const didUpdateNormalFiles = await syncFiles(
"normal",
collections,
(files) => dispatch({ type: "setFiles", files }),
(files) => dispatch({ type: "fetchFiles", files }),
);
const didUpdateHiddenFiles = await syncFiles(
"hidden",
hiddenCollections,
(hiddenFiles) =>
dispatch({ type: "setHiddenFiles", hiddenFiles }),
(hiddenFiles) =>
dispatch({ type: "fetchHiddenFiles", hiddenFiles }),
);
if (didUpdateNormalFiles || didUpdateHiddenFiles)
exportService.onLocalFilesUpdated();
await syncTrash(allCollections, (trashedFiles: EnteFile[]) =>
dispatch({ type: "setTrashedFiles", trashedFiles }),
);
// syncWithRemote is called with the force flag set to true before
// doing an upload. So it is possible, say when resuming a pending
// upload, that we get two syncWithRemotes happening in parallel.
//
// Do the non-file-related sync only for one of these parallel ones.
if (!isForced) {
await sync();
syncInProgress.current = false;
if (resync.current) {
const { force, silent } = resync.current;
setTimeout(() => handleSyncWithRemote(force, silent), 0);
resync.current = undefined;
}
} catch (e) {
switch (e.message) {
case CustomError.SESSION_EXPIRED:
showSessionExpiredDialog();
break;
case CustomError.KEY_MISSING:
clearKeys();
router.push(PAGES.CREDENTIALS);
break;
default:
log.error("syncWithRemote failed", e);
}
} finally {
dispatch({ type: "clearUnsyncedState" });
!silent && hideLoadingBar();
}
syncInProgress.current = false;
if (resync.current) {
const { force, silent } = resync.current;
setTimeout(() => syncWithRemote(force, silent), 0);
resync.current = undefined;
}
};
},
[showLoadingBar, hideLoadingBar, router, showSessionExpiredDialog],
);
// Alias for existing code.
const syncWithRemote = handleSyncWithRemote;
const setupSelectAllKeyBoardShortcutHandler = () => {
const handleKeyUp = (e: KeyboardEvent) => {
@@ -695,7 +703,7 @@ const Page: React.FC = () => {
await handleFileOps(
ops,
toProcessFiles,
(files) => dispatch({ type: "markTempDeleted", files }),
handleMarkTempDeleted,
() => dispatch({ type: "clearTempDeleted" }),
(files) => dispatch({ type: "markTempHidden", files }),
() => dispatch({ type: "clearTempHidden" }),
@@ -793,6 +801,35 @@ const Page: React.FC = () => {
});
};
const handleMarkUnsyncedFavoriteUpdate = useCallback(
(fileID: number, isFavorite: boolean) =>
dispatch({
type: "markUnsyncedFavoriteUpdate",
fileID,
isFavorite,
}),
[],
);
const handleMarkTempDeleted = useCallback(
(files: EnteFile[]) => dispatch({ type: "markTempDeleted", files }),
[],
);
const handleSelectCollection = useCallback(
(collectionID: number) =>
dispatch({
type: "showNormalOrHiddenCollectionSummary",
collectionSummaryID: collectionID,
}),
[],
);
const handleSelectPerson = useCallback(
(personID: string) => dispatch({ type: "showPerson", personID }),
[],
);
const handleOpenCollectionSelector = useCallback(
(attributes: CollectionSelectorAttributes) => {
setCollectionSelectorAttributes(attributes);
@@ -934,9 +971,7 @@ const Page: React.FC = () => {
onSelectPeople={() =>
dispatch({ type: "showPeople" })
}
onSelectPerson={(personID) =>
dispatch({ type: "showPerson", personID })
}
onSelectPerson={handleSelectPerson}
/>
)}
</NavbarBase>
@@ -959,8 +994,7 @@ const Page: React.FC = () => {
? state.view.visiblePeople
: undefined) ?? [],
activePerson,
onSelectPerson: (personID) =>
dispatch({ type: "showPerson", personID }),
onSelectPerson: handleSelectPerson,
setCollectionNamerAttributes,
setPhotoListHeader,
setFilesDownloadProgressAttributesCreator,
@@ -1021,20 +1055,9 @@ const Page: React.FC = () => {
mode={barMode}
modePlus={isInSearchMode ? "search" : barMode}
files={filteredFiles}
syncWithRemote={syncWithRemote}
setSelected={setSelected}
selected={selected}
favoriteFileIDs={state.favoriteFileIDs}
markUnsyncedFavoriteUpdate={(fileID, isFavorite) =>
dispatch({
type: "markUnsyncedFavoriteUpdate",
fileID,
isFavorite,
})
}
markTempDeleted={(files) =>
dispatch({ type: "markTempDeleted", files })
}
setIsPhotoSwipeOpen={setIsPhotoSwipeOpen}
activeCollectionID={activeCollectionID}
activePersonID={activePerson?.id}
@@ -1049,15 +1072,13 @@ const Page: React.FC = () => {
setFilesDownloadProgressAttributesCreator
}
selectable={true}
onSelectCollection={(collectionID) =>
dispatch({
type: "showNormalOrHiddenCollectionSummary",
collectionSummaryID: collectionID,
})
onMarkUnsyncedFavoriteUpdate={
handleMarkUnsyncedFavoriteUpdate
}
onSelectPerson={(personID) => {
dispatch({ type: "showPerson", personID });
}}
onMarkTempDeleted={handleMarkTempDeleted}
onSyncWithRemote={handleSyncWithRemote}
onSelectCollection={handleSelectCollection}
onSelectPerson={handleSelectPerson}
/>
)}
<Export

View File

@@ -60,7 +60,7 @@ import { ITEM_TYPE, TimeStampListItem } from "components/PhotoList";
import { Upload } from "components/Upload";
import { t } from "i18next";
import { useRouter } from "next/router";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type FileWithPath } from "react-dropzone";
import {
getLocalPublicCollection,
@@ -289,7 +289,7 @@ export default function PublicCollectionGallery() {
);
}, [onAddPhotos]);
const syncWithRemote = async () => {
const handleSyncWithRemote = useCallback(async () => {
const collectionUID = getPublicCollectionUID(
credentials.current.accessToken,
);
@@ -369,7 +369,10 @@ export default function PublicCollectionGallery() {
hideLoadingBar();
setLoading(false);
}
};
}, [showLoadingBar, hideLoadingBar]);
// TODO: See gallery
const syncWithRemote = handleSyncWithRemote;
const verifyLinkPassword: SingleInputFormProps["callback"] = async (
password,
@@ -510,7 +513,7 @@ export default function PublicCollectionGallery() {
<PhotoFrame
files={publicFiles}
syncWithRemote={syncWithRemote}
onSyncWithRemote={handleSyncWithRemote}
setSelected={setSelected}
selected={selected}
activeCollectionID={ALL_SECTION}

View File

@@ -139,11 +139,17 @@ export const createFavoritesCollection = () => {
return createCollection(FAVORITE_COLLECTION_NAME, CollectionType.favorites);
};
export const addToFavorites = async (file: EnteFile) => {
await addMultipleToFavorites([file]);
export const addToFavorites = async (
file: EnteFile,
disableOldWorkaround?: boolean,
) => {
await addMultipleToFavorites([file], disableOldWorkaround);
};
export const addMultipleToFavorites = async (files: EnteFile[]) => {
export const addMultipleToFavorites = async (
files: EnteFile[],
disableOldWorkaround?: boolean,
) => {
try {
let favCollection = await getFavCollection();
if (!favCollection) {
@@ -152,10 +158,19 @@ export const addMultipleToFavorites = async (files: EnteFile[]) => {
await addToCollection(favCollection, files);
} catch (e) {
log.error("failed to add to favorite", e);
// Old code swallowed the error here. This isn't good, but to
// avoid changing existing behaviour only new code will set the
// disableOldWorkaround flag to instead rethrow it.
//
// TODO: Migrate old code, remove this flag, always throw.
if (disableOldWorkaround) throw e;
}
};
export const removeFromFavorites = async (file: EnteFile) => {
export const removeFromFavorites = async (
file: EnteFile,
disableOldWorkaround?: boolean,
) => {
try {
const favCollection = await getFavCollection();
if (!favCollection) {
@@ -164,6 +179,8 @@ export const removeFromFavorites = async (file: EnteFile) => {
await removeFromCollection(favCollection.id, [file]);
} catch (e) {
log.error("remove from favorite failed", e);
// TODO: See disableOldWorkaround in addMultipleToFavorites.
if (disableOldWorkaround) throw e;
}
};

View File

@@ -148,10 +148,24 @@ body {
height: 60px;
/* Unlike the loading indicator, "display" is used to toggle visibility, and
the opacity is fixed to be similar to that of the counter. */
opacity: 0.85;
display: none;
opacity: 0.85;
}
.pswp-ente .pswp__error--active {
display: initial;
}
/* Scale the built in controls to better fit our requirements */
.pswp-ente .pswp__button--zoom .pswp__icn {
transform: scale(0.85);
}
.pswp-ente .pswp__button--arrow--prev .pswp__icn {
transform: scale(0.8);
}
.pswp-ente .pswp__button--arrow--next .pswp__icn {
/* default is a horizontal flip, transform: scale(-1, 1); */
transform: scale(-0.8, 0.8);
}

View File

@@ -74,12 +74,14 @@ export const OverflowMenu: React.FC<
{...(anchorEl ? { anchorEl } : {})}
open={!!anchorEl}
onClose={() => setAnchorEl(undefined)}
MenuListProps={{
// Disable padding at the top and bottom of the menu list.
disablePadding: true,
"aria-labelledby": ariaID,
slotProps={{
paper: { sx: menuPaperSxProps },
list: {
// Disable padding at the top and bottom of the menu list.
disablePadding: true,
"aria-labelledby": ariaID,
},
}}
slotProps={{ paper: { sx: menuPaperSxProps } }}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>

View File

@@ -23,16 +23,23 @@ import React from "react";
* It also does some trickery with a sticky opaque bar to ensure that the
* content scrolls below our inline title bar on desktop.
*/
export const SidebarDrawer: React.FC<DrawerProps> = ({ children, ...rest }) => (
export const SidebarDrawer: React.FC<DrawerProps> = ({
slotProps,
children,
...rest
}) => (
<Drawer
{...rest}
PaperProps={{
sx: {
maxWidth: "375px",
width: "100%",
scrollbarWidth: "thin",
// Need to increase specificity to override inherited padding.
"&&": { padding: 0 },
slotProps={{
...(slotProps ?? {}),
paper: {
sx: {
maxWidth: "375px",
width: "100%",
scrollbarWidth: "thin",
// Need to increase specificity to override inherited padding.
"&&": { padding: 0 },
},
},
}}
>

View File

@@ -41,7 +41,7 @@ const getTheme = (appName: AppName): Theme => {
*
* 2. These can be groups of color values that have roughly the same hue, but
* different levels of saturation. Such hue groups are arranged together into
* a "Colors" exported by "@/mui/material":
* a "Colors" exported by "@mui/material":
*
* export interface Color {
* 50: string;

View File

@@ -104,6 +104,10 @@ if (process.env.NEXT_PUBLIC_ENTE_FAMILY_URL) {
const nextConfig = {
// Generate a static export when we run `next build`.
output: "export",
// Instead of the nice and useful HMR indicator that used to exist prior to
// 15.2, the Next.js folks have made this a persistent "branding" indicator
// that gets in the way and needs to be disabled.
devIndicators: false,
compiler: {
emotion: true,
},

View File

@@ -7,8 +7,8 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource-variable/inter": "^5.1.1",
"@mui/icons-material": "^6.4.3",
"@mui/material": "^6.4.3",
"@mui/icons-material": "^6.4.6",
"@mui/material": "^6.4.6",
"comlink": "^4.4.2",
"formik": "^2.4.6",
"get-user-locale": "^2.3.2",
@@ -16,18 +16,18 @@
"i18next-resources-to-backend": "^1.2.1",
"idb": "^8.0.2",
"libsodium-wrappers-sumo": "^0.7.15",
"nanoid": "^5.0.9",
"next": "^15.1.6",
"nanoid": "^5.1.2",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.4.0",
"react-i18next": "^15.4.1",
"yup": "^1.6.1",
"zod": "^3.24.1"
"zod": "^3.24.2"
},
"devDependencies": {
"@/build-config": "*",
"@types/libsodium-wrappers-sumo": "^0.7.8",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3"
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4"
}
}

View File

@@ -200,6 +200,7 @@ export const FileInfo: React.FC<FileInfoProps> = ({
useEffect(() => {
if (!file) return;
if (!isMLEnabled()) return;
let didCancel = false;
@@ -317,7 +318,7 @@ export const FileInfo: React.FC<FileInfoProps> = ({
)
}
/>
{isMLEnabled() && annotatedFaces.length > 0 && (
{annotatedFaces.length > 0 && (
<InfoItem icon={<FaceRetouchingNaturalIcon />}>
<FilePeopleList
file={file}

View File

@@ -15,7 +15,9 @@ if (process.env.NEXT_PUBLIC_ENTE_WIP_PS5) {
import { isDesktop } from "@/base/app";
import { type ModalVisibilityProps } from "@/base/components/utils/modal";
import { useBaseContext } from "@/base/context";
import { lowercaseExtension } from "@/base/file-name";
import { pt } from "@/base/i18n";
import type { LocalUser } from "@/base/local-user";
import log from "@/base/log";
import {
@@ -31,12 +33,17 @@ import {
ImageEditorOverlay,
type ImageEditorOverlayProps,
} from "@/new/photos/components/ImageEditorOverlay";
import { Button, styled } from "@mui/material";
import { Button, Menu, MenuItem, styled } from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fileInfoExifForFile } from "./data-source";
import {
FileViewerPhotoSwipe,
moreButtonID,
moreMenuID,
resetMoreMenuButtonOnMenuClose,
type FileViewerAnnotatedFile,
type FileViewerFileAnnotation,
type FileViewerPhotoSwipeDelegate,
} from "./photoswipe";
export type FileViewerProps = ModalVisibilityProps & {
@@ -106,6 +113,16 @@ export type FileViewerProps = ModalVisibilityProps & {
* not defined, then this prop is not used.
*/
onTriggerSyncWithRemote?: () => void;
/**
* Called when the favorite status of given {@link file} should be toggled
* from its current value.
*
* If this is not provided then the favorite toggle button will not be shown
* in the file actions.
*
* See also {@link favoriteFileIDs}.
*/
onToggleFavorite?: (file: EnteFile) => Promise<void>;
/**
* Called when the user edits an image in the image editor and asks us to
* save their edits as a copy.
@@ -139,16 +156,39 @@ const FileViewer: React.FC<FileViewerProps> = ({
favoriteFileIDs,
fileCollectionIDs,
allCollectionsNameByID,
onTriggerSyncWithRemote,
onToggleFavorite,
onSelectCollection,
onSelectPerson,
onTriggerSyncWithRemote,
onSaveEditedImageCopy,
}) => {
const pswpRef = useRef<FileViewerPhotoSwipe | undefined>();
const { onGenericError } = useBaseContext();
// There are 3 things involved in this dance:
//
// 1. Us, "FileViewer". We're a React component.
// 2. The custom PhotoSwipe wrapper, "FileViewerPhotoSwipe". It is a class.
// 3. The delegate, "FileViewerPhotoSwipeDelegate".
//
// The delegate acts as a bridge between us and (our custom) photoswipe
// class, to avoid recreating the class each time a "dynamic" prop changes.
// The delegate has a stable identity, we just keep updating the callback
// functions that it holds.
//
// The word "dynamic" here means a prop on whose change we should not
// recreate the photoswipe dialog.
const delegateRef = useRef<FileViewerPhotoSwipeDelegate | undefined>(
undefined,
);
// We also need to maintain a ref to the currently displayed dialog since we
// might need to ask it to refresh its contents.
const psRef = useRef<FileViewerPhotoSwipe | undefined>(undefined);
// Whenever we get a callback from our custom PhotoSwipe instance, we also
// get the active file on which that action was performed as an argument.
// Save it as a prop so that the rest of our React tree can use it.
// get the active file on which that action was performed as an argument. We
// save it as the `activeAnnotatedFile` state so that the rest of our React
// tree can use it.
//
// This is not guaranteed, or even intended, to be in sync with the active
// file shown within the file viewer. All that this guarantees is this will
@@ -156,13 +196,16 @@ const FileViewer: React.FC<FileViewerProps> = ({
const [activeAnnotatedFile, setActiveAnnotatedFile] = useState<
FileViewerAnnotatedFile | undefined
>(undefined);
// With semantics similar to activeFile, this is the exif data associated
// with the activeAnnotatedFile, if any.
// With semantics similar to `activeAnnotatedFile`, this is the exif data
// associated with the `activeAnnotatedFile`, if any.
const [activeFileExif, setActiveFileExif] = useState<
FileInfoExif | undefined
>(undefined);
const [openFileInfo, setOpenFileInfo] = useState(false);
const [moreMenuAnchorEl, setMoreMenuAnchorEl] =
useState<HTMLElement | null>(null);
const [openImageEditor, setOpenImageEditor] = useState(false);
// If `true`, then we need to trigger a sync with remote when we close.
@@ -170,48 +213,18 @@ const FileViewer: React.FC<FileViewerProps> = ({
const handleClose = useCallback(() => {
setNeedsSync((needSync) => {
console.log("needs sync", needSync);
if (needSync) onTriggerSyncWithRemote?.();
return false;
});
setOpenFileInfo(false);
setOpenImageEditor(false);
// No need to `resetMoreMenuButtonOnMenuClose` since we're closing
// anyway and it'll be removed from the DOM.
setMoreMenuAnchorEl(null);
onClose();
}, [onTriggerSyncWithRemote, onClose]);
const handleAnnotate = useCallback(
(file: EnteFile) => {
log.debug(() => ["viewer", { action: "annotate", file }]);
const fileID = file.id;
const isOwnFile = file.ownerID == user?.id;
const canFavoriteOrEdit =
isOwnFile && !isInTrashSection && !isInHiddenSection;
const isFavorite = canFavoriteOrEdit
? favoriteFileIDs?.has(file.id)
: undefined;
const isEditableImage =
onSaveEditedImageCopy && canFavoriteOrEdit
? fileIsEditableImage(file)
: undefined;
return { fileID, isOwnFile, isFavorite, isEditableImage };
},
[
user,
isInTrashSection,
isInHiddenSection,
favoriteFileIDs,
onSaveEditedImageCopy,
],
);
const handleToggleFavorite = useMemo(() => {
return favoriteFileIDs
? (annotatedFile: FileViewerAnnotatedFile) => {
setActiveAnnotatedFile(annotatedFile);
console.log("handleToggleFavorite", annotatedFile);
}
: undefined;
}, [favoriteFileIDs]);
const handleViewInfo = useCallback(
(annotatedFile: FileViewerAnnotatedFile) => {
setActiveAnnotatedFile(annotatedFile);
@@ -225,7 +238,64 @@ const FileViewer: React.FC<FileViewerProps> = ({
[],
);
const handleInfoClose = useCallback(() => setOpenFileInfo(false), []);
const handleFileInfoClose = useCallback(() => setOpenFileInfo(false), []);
const handleMore = useCallback(
(
annotatedFile: FileViewerAnnotatedFile,
buttonElement: HTMLElement,
) => {
setActiveAnnotatedFile(annotatedFile);
setMoreMenuAnchorEl(buttonElement);
},
[],
);
const handleMoreMenuClose = useCallback(() => {
setMoreMenuAnchorEl((el) => {
resetMoreMenuButtonOnMenuClose(el);
return null;
});
}, []);
const handleEditImage = useMemo(() => {
return onSaveEditedImageCopy
? () => {
handleMoreMenuClose();
setOpenImageEditor(true);
}
: undefined;
}, [onSaveEditedImageCopy, handleMoreMenuClose]);
const handleImageEditorClose = useCallback(
() => setOpenImageEditor(false),
[],
);
const handleSaveEditedCopy = useCallback(
(editedFile: File, collection: Collection, enteFile: EnteFile) => {
onSaveEditedImageCopy(editedFile, collection, enteFile);
handleClose();
},
[onSaveEditedImageCopy, handleClose],
);
const handleAnnotate = useCallback(
(file: EnteFile): FileViewerFileAnnotation => {
log.debug(() => ["viewer", { action: "annotate", file }]);
const fileID = file.id;
const isOwnFile = file.ownerID == user?.id;
const canModify =
isOwnFile && !isInTrashSection && !isInHiddenSection;
const showFavorite = canModify;
const isEditableImage =
handleEditImage && canModify
? fileIsEditableImage(file)
: undefined;
return { fileID, isOwnFile, showFavorite, isEditableImage };
},
[user, isInTrashSection, isInHiddenSection, handleEditImage],
);
const handleScheduleUpdate = useCallback(() => setNeedsSync(true), []);
@@ -246,97 +316,116 @@ const FileViewer: React.FC<FileViewerProps> = ({
: undefined;
}, [onSelectPerson, handleClose]);
const handleEditImage = useMemo(() => {
return onSaveEditedImageCopy
? (annotatedFile: FileViewerAnnotatedFile) => {
setActiveAnnotatedFile(annotatedFile);
setOpenImageEditor(true);
}
: undefined;
}, [onSaveEditedImageCopy]);
const haveUser = !!user;
const handleImageEditorClose = useCallback(
() => setOpenImageEditor(false),
[],
);
const getFiles = useCallback(() => files, [files]);
const handleSaveEditedCopy = useCallback(
(editedFile: File, collection: Collection, enteFile: EnteFile) => {
onSaveEditedImageCopy(editedFile, collection, enteFile);
handleImageEditorClose();
handleClose();
const isFavorite = useCallback(
({ file }: FileViewerAnnotatedFile) => {
if (!haveUser || !favoriteFileIDs || !onToggleFavorite) {
return undefined;
}
return favoriteFileIDs.has(file.id);
},
[onSaveEditedImageCopy, handleImageEditorClose, handleClose],
[haveUser, favoriteFileIDs, onToggleFavorite],
);
const toggleFavorite = useCallback(
({ file }: FileViewerAnnotatedFile) =>
onToggleFavorite!(file)
.then(handleScheduleUpdate)
.catch(onGenericError),
[onToggleFavorite, handleScheduleUpdate, onGenericError],
);
// Initial value of delegate.
if (!delegateRef.current) {
delegateRef.current = { getFiles, isFavorite, toggleFavorite };
}
// Updates to delegate callbacks.
useEffect(() => {
const delegate = delegateRef.current!;
delegate.getFiles = getFiles;
delegate.isFavorite = isFavorite;
delegate.toggleFavorite = toggleFavorite;
}, [getFiles, isFavorite, toggleFavorite]);
useEffect(() => {
log.debug(() => ["viewer", { action: "useEffect", open }]);
if (open) {
// We're open. Create psRef. This will show the file viewer dialog.
log.debug(() => ["viewer", { action: "open" }]);
if (!open) {
// The close state will be handled by the cleanup function.
return;
const pswp = new FileViewerPhotoSwipe({
initialIndex,
disableDownload,
haveUser,
delegate: delegateRef.current!,
onClose: handleClose,
onAnnotate: handleAnnotate,
onViewInfo: handleViewInfo,
onMore: handleMore,
});
psRef.current = pswp;
return () => {
// Close dialog in the effect callback.
log.debug(() => ["viewer", { action: "close" }]);
pswp.closeIfNeeded();
};
}
const pswp = new FileViewerPhotoSwipe({
files,
initialIndex,
disableDownload,
onClose: handleClose,
onAnnotate: handleAnnotate,
onToggleFavorite: handleToggleFavorite,
onViewInfo: handleViewInfo,
onEditImage: handleEditImage,
});
pswpRef.current = pswp;
return () => {
log.debug(() => [
"viewer",
{ action: "useEffect/cleanup", pswpRef: pswpRef.current },
]);
pswpRef.current?.closeIfNeeded();
pswpRef.current = undefined;
};
// The hook is missing dependencies; this is intentional - we don't want
// to recreate the PhotoSwipe dialog when these dependencies change.
//
// - Updates to initialIndex can be safely ignored: they don't matter,
// only their initial value at the time of open mattered.
//
// - Updates to other properties are not expected after open. We could've
// also added it to the dependencies array, not adding it was a more
// conservative choice to be on the safer side and trigger too few
// instead of too many updates.
//
// - Updates to files matter, but these are conveyed separately.
// TODO(PS):
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, onClose, handleViewInfo]);
}, [
open,
onClose,
user,
initialIndex,
disableDownload,
haveUser,
handleClose,
handleAnnotate,
handleViewInfo,
handleMore,
]);
const handleRefreshPhotoswipe = useCallback(() => {
pswpRef.current.refreshCurrentSlideContent();
psRef.current!.refreshCurrentSlideContent();
}, []);
log.debug(() => ["viewer", { action: "render", pswpRef: pswpRef.current }]);
log.debug(() => ["viewer", { action: "render", psRef: psRef.current }]);
return (
<Container>
<Button>Test</Button>
<FileInfo
open={openFileInfo}
onClose={handleInfoClose}
onClose={handleFileInfoClose}
file={activeAnnotatedFile?.file}
exif={activeFileExif}
allowEdits={!!activeAnnotatedFile?.annotation.isOwnFile}
allowMap={!!user}
showCollections={!!user}
allowMap={haveUser}
showCollections={haveUser}
scheduleUpdate={handleScheduleUpdate}
refreshPhotoswipe={handleRefreshPhotoswipe}
onSelectCollection={handleSelectCollection}
onSelectPerson={handleSelectPerson}
{...{ fileCollectionIDs, allCollectionsNameByID }}
/>
<Menu
open={!!moreMenuAnchorEl}
onClose={handleMoreMenuClose}
anchorEl={moreMenuAnchorEl}
id={moreMenuID}
slotProps={{
list: { "aria-labelledby": moreButtonID },
}}
>
{activeAnnotatedFile?.annotation.isEditableImage && (
<MenuItem onClick={handleEditImage}>
{/*TODO */ pt("Edit image")}
</MenuItem>
)}
</Menu>
<ImageEditorOverlay
open={openImageEditor}
onClose={handleImageEditorClose}

View File

@@ -224,8 +224,11 @@ export const itemDataForFile = (file: EnteFile, needsRefresh: () => void) => {
* This is called when the user moves away from a slide so that we attempt a
* full retry when they come back the next time.
*/
export const forgetFailedItemDataForFile = (file: EnteFile) =>
forgetFailedItemDataForFileID(file.id);
export const forgetFailedItemDataForFileID = (fileID: number) => {
if (_state.itemDataByFileID.get(fileID)?.fetchFailed) {
_state.itemDataByFileID.delete(fileID);
}
};
/**
* Forget item data for the all files whose fetch had failed.
@@ -236,12 +239,6 @@ export const forgetFailedItemDataForFile = (file: EnteFile) =>
export const forgetFailedItems = () =>
[..._state.itemDataByFileID.keys()].forEach(forgetFailedItemDataForFileID);
const forgetFailedItemDataForFileID = (fileID: number) => {
if (_state.itemDataByFileID.get(fileID)?.fetchFailed) {
_state.itemDataByFileID.delete(fileID);
}
};
const enqueueUpdates = async (file: EnteFile) => {
const fileID = file.id;
const fileType = file.metadata.fileType;

View File

@@ -21,14 +21,14 @@ const paths = {
info: '<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8" transform="translate(3.5, 3.5)"',
// "@mui/icons-material/ErrorOutline"
error: '<path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8" transform="translate(7, 5.7) scale(0.85)"',
// "@mui/icons-material/Edit"
edit: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75z" transform="translate(3, 3) scale(0.97)"',
// "@mui/icons-material/FavoriteBorderRounded"
favorite:
'<path d="M19.66 3.99c-2.64-1.8-5.9-.96-7.66 1.1-1.76-2.06-5.02-2.91-7.66-1.1-1.4.96-2.28 2.58-2.34 4.29-.14 3.88 3.3 6.99 8.55 11.76l.1.09c.76.69 1.93.69 2.69-.01l.11-.1c5.25-4.76 8.68-7.87 8.55-11.75-.06-1.7-.94-3.32-2.34-4.28M12.1 18.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05" transform="translate(3, 3)"',
// "@mui/icons-material/FavoriteRounded"
unfavorite:
'<path d="M13.35 20.13c-.76.69-1.93.69-2.69-.01l-.11-.1C5.3 15.27 1.87 12.16 2 8.28c.06-1.7.93-3.33 2.34-4.29 2.64-1.8 5.9-.96 7.66 1.1 1.76-2.06 5.02-2.91 7.66-1.1 1.41.96 2.28 2.59 2.34 4.29.14 3.88-3.3 6.99-8.55 11.76z" transform="translate(3, 3)"',
// "@mui/icons-material/MoreHoriz"
more: '<path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2" transform="translate(3, 4)"',
};
/**

View File

@@ -9,12 +9,11 @@ import { t } from "i18next";
import {
forgetExif,
forgetExifForItemData,
forgetFailedItemDataForFile,
forgetFailedItemDataForFileID,
forgetFailedItems,
itemDataForFile,
updateFileInfoExifIfNeeded,
} from "./data-source";
import type { FileViewerProps } from "./FileViewer";
import { createPSRegisterElementIconHTML } from "./icons";
// TODO(PS): WIP gallery using upstream photoswipe
@@ -51,55 +50,101 @@ export interface FileViewerFileAnnotation {
*/
isOwnFile: boolean;
/**
* `true` if this file has been marked as a favorite by the user.
*
* The toggle favorite button will not be shown if this is not defined.
* Otherwise it determines the toggle state of the toggle favorite button.
* `true` if the toggle favorite action should be shown for this file.
*/
isFavorite?: boolean | undefined;
showFavorite: boolean;
/**
* `true` if this is an image which can be edited.
*
* The edit button is shown when this is true. See also the
* {@link onEditImage} option for {@link FileViewerPhotoSwipe} constructor.
* `true` if this is an image which can be edited, and editing is possible,
* and the edit action should therefore be shown for this file.
*/
isEditableImage?: boolean | undefined;
isEditableImage: boolean;
}
type FileViewerPhotoSwipeOptions = {
export interface FileViewerPhotoSwipeDelegate {
/**
* Called to obtain the latest list of files.
*
* [Note: Changes to underlying files when file viewer is open]
*
* The list of files shown by the viewer might change while the viewer is
* open. We do not actively refresh the viewer when this happens since that
* would result in the user's zoom / pan state being lost.
*
* However, we always read the latest list via the delegate, so any
* subsequent user initiated slide navigation (e.g. moving to the next
* slide) will use the new list.
*/
getFiles: () => EnteFile[];
/**
* Return `true` if the provided file has been marked as a favorite by the
* user.
*
* The toggle favorite button will not be shown for the file if
* this callback returns `undefined`. Otherwise the return value determines
* the toggle state of the toggle favorite button for the file.
*/
isFavorite: (annotatedFile: FileViewerAnnotatedFile) => boolean | undefined;
/**
* Called when the user activates the toggle favorite action on a file.
*
* The toggle favorite button will be disabled for the file until the
* promise returned by this function returns fulfills.
*
* > Note: The caller is expected to handle any errors that occur, and
* > should not reject for foreseeable failures, otherwise the button will
* > remain in the disabled state (until the file viewer is closed).
*/
toggleFavorite: (annotatedFile: FileViewerAnnotatedFile) => Promise<void>;
}
type FileViewerPhotoSwipeOptions = Pick<
FileViewerProps,
"initialIndex" | "disableDownload"
> & {
/**
* `true` if we're running in the context of a logged in user, and so
* various actions that modify the file should be shown.
*
* This is the static variant of various per file annotations that control
* various modifications. If this is not `true`, then various actions like
* favorite, delete etc are never shown. If this is `true`, then their
* visibility depends on the corresponding annotation.
*
* For example, the favorite action is shown only if both this and the
* {@link showFavorite} file annotation are true.
*/
haveUser: boolean;
/**
* Dynamic callbacks.
*
* The extra level of indirection allows these to be updated without
* recreating us.
*/
delegate: FileViewerPhotoSwipeDelegate;
/**
* Called when the file viewer is closed.
*/
onClose: () => void;
/**
* Called whenever the slide changes to obtain the derived data for the file
* that is about to be displayed.
* Called whenever the slide is initially displayed or changes, to obtain
* various derived data for the file that is about to be displayed.
*/
onAnnotate: (file: EnteFile) => FileViewerFileAnnotation;
/**
* Called when the user activates the toggle favorite action on a file.
*
* If this callback is not provided, then the toggle favorite button is not
* shown. If this callback is provided, then the favorite button is shown if
* the {@link isFavorite} property of {@link FileViewerFileAnnotation} for
* the file is provided. In that case, the value of the {@link isFavorite}
* property will determine the current toggle state of the favorite button.
*/
onToggleFavorite?: (annotatedFile: FileViewerAnnotatedFile) => void;
/**
* Called when the user activates the info action on a file.
*/
onViewInfo: (annotatedFile: FileViewerAnnotatedFile) => void;
/**
* Called when the user activates the edit action on an image.
* Called when the user activates the more action on a file.
*
* If this callback is not provided, then the edit button is never shown. If
* this callback is provided, then the visibility of the edit button is
* determined by the {@link isEditableImage} property of
* {@link FileViewerFileAnnotation} for the file.
* In addition to the file, callback is also passed a reference to the HTML
* DOM more button element.
*/
onEditImage?: (annotatedFile: FileViewerAnnotatedFile) => void;
} & Pick<FileViewerProps, "files" | "initialIndex" | "disableDownload">;
onMore: (
annotatedFile: FileViewerAnnotatedFile,
buttonElement: HTMLElement,
) => void;
};
/**
* A file and its annotation, in a nice cosy box.
@@ -109,6 +154,21 @@ export interface FileViewerAnnotatedFile {
annotation: FileViewerFileAnnotation;
}
/**
* The ID that is used by the "more" action button (if one is being displayed).
*
* @see also {@link moreMenuID}.
*/
export const moreButtonID = "ente-pswp-more-button";
/**
* The ID this is expected to be used by the more menu that is shown in response
* to the more action button being activated.
*
* @see also {@link moreButtonID}.
*/
export const moreMenuID = "ente-pswp-more-menu";
/**
* A wrapper over {@link PhotoSwipe} to tailor its interface for use by our file
* viewer.
@@ -169,18 +229,21 @@ export class FileViewerPhotoSwipe {
* scope.
*/
private activeFileAnnotation: FileViewerFileAnnotation | undefined;
/**
* IDs of files for which a there is a favorite update in progress.
*/
private pendingFavoriteUpdates = new Set<number>();
constructor({
files,
initialIndex,
disableDownload,
haveUser,
delegate,
onClose,
onAnnotate,
onToggleFavorite,
onViewInfo,
onEditImage,
onMore,
}: FileViewerPhotoSwipeOptions) {
this.files = files;
this.opts = { disableDownload };
this.lastActivityDate = new Date();
@@ -241,9 +304,9 @@ export class FileViewerPhotoSwipe {
this.pswp = pswp;
// Helper routines to obtain the file at `currIndex`.
// Various helper routines to obtain the file at `currIndex`.
const currentFile = () => this.files[pswp.currIndex]!;
const currentFile = () => delegate.getFiles()[pswp.currIndex]!;
const currentAnnotatedFile = () => {
const file = currentFile();
@@ -265,23 +328,18 @@ export class FileViewerPhotoSwipe {
const currentFileAnnotation = () => currentAnnotatedFile().annotation;
const withCurrentAnnotatedFile =
(cb: (af: AnnotatedFile) => void) => () =>
cb(currentAnnotatedFile());
// Provide data about slides to PhotoSwipe via callbacks
// https://photoswipe.com/data-sources/#dynamically-generated-data
pswp.addFilter("numItems", () => {
return this.files.length;
});
pswp.addFilter("numItems", () => delegate.getFiles().length);
pswp.addFilter("itemData", (_, index) => {
const files = delegate.getFiles();
const file = files[index]!;
let itemData = itemDataForFile(file, () => {
this.pswp.refreshSlideContent(index);
});
let itemData = itemDataForFile(file, () =>
pswp.refreshSlideContent(index),
);
const { fileType, videoURL, ...rest } = itemData;
if (fileType === FileType.video && videoURL) {
@@ -367,7 +425,8 @@ export class FileViewerPhotoSwipe {
// more than 2 slides and then back, or if they reopen the viewer.
//
// See: [Note: File viewer error handling]
forgetFailedItemDataForFile(currentFile());
const fileID = e.content?.data?.fileID;
if (fileID) forgetFailedItemDataForFileID(fileID);
// Pause the video element, if any, when we move away from the
// slide.
@@ -391,7 +450,7 @@ export class FileViewerPhotoSwipe {
);
pswp.on("change", (e) => {
const itemData = pswp.currSlide.content.data;
const itemData = this.pswp.currSlide.content.data;
updateFileInfoExifIfNeeded(itemData);
});
@@ -423,6 +482,17 @@ export class FileViewerPhotoSwipe {
// - zoom: 10
// - close: 20
pswp.on("uiRegister", () => {
// Move the zoom button to the left so that it is in the same place
// as the other items like preloader or the error indicator that
// come and go as files get loaded.
//
// We cannot use the PhotoSwipe "uiElement" filter to modify the
// order since that only allows us to edit the DOM element, not the
// underlying UI element data.
pswp.ui.uiElementsData.find((e) => e.name == "zoom").order = 6;
// Register our custom elements...
pswp.ui.registerElement({
name: "error",
order: 6,
@@ -440,22 +510,61 @@ export class FileViewerPhotoSwipe {
},
});
if (onToggleFavorite) {
// Only one of these two will end up being shown, so they can
// safely share the same order.
if (haveUser) {
const toggleFavorite = async (
buttonElement: HTMLButtonElement,
) => {
const af = currentAnnotatedFile();
this.pendingFavoriteUpdates.add(af.file.id);
buttonElement.disabled = true;
await delegate.toggleFavorite(af);
// TODO: This can be improved in two ways:
//
// 1. We currently have a setTimeout to ensure that the
// updated `favoriteFileIDs` have made their way to our
// delegate before we query for the status again.
// Obviously, this is hacky. Note that a timeout of 0
// (i.e., just deferring till the next tick) isn't enough
// here, for reasons I need to investigate more (hence
// this TODO).
//
// 2. We reload the entire slide instead of just updating
// the button state. This is because there are two
// buttons, instead of a single button toggling between
// two states (e.g. like the zoom button). A single
// button can be achieved by moving the fill as a layer.
await new Promise((r) => setTimeout(r, 100));
this.pendingFavoriteUpdates.delete(af.file.id);
this.refreshCurrentSlideContent();
};
const showFavoriteIf = (
buttonElement: HTMLButtonElement,
value: boolean,
) => {
const af = currentAnnotatedFile();
const isFavorite = delegate.isFavorite(af);
showIf(
buttonElement,
af.annotation.showFavorite && isFavorite === value,
);
buttonElement.disabled = this.pendingFavoriteUpdates.has(
af.file.id,
);
};
// Only one of these two ("favorite" or "unfavorite") will end
// up being shown, so they can safely share the same order.
pswp.ui.registerElement({
name: "favorite",
title: t("favorite_key"),
order: 8,
isButton: true,
html: createPSRegisterElementIconHTML("favorite"),
onClick: withCurrentAnnotatedFile(onToggleFavorite),
onClick: (e) => toggleFavorite(e.target),
onInit: (buttonElement) =>
pswp.on("change", () =>
showIf(
buttonElement,
currentFileAnnotation().isFavorite === false,
),
showFavoriteIf(buttonElement, false),
),
});
pswp.ui.registerElement({
@@ -464,13 +573,10 @@ export class FileViewerPhotoSwipe {
order: 8,
isButton: true,
html: createPSRegisterElementIconHTML("unfavorite"),
onClick: withCurrentAnnotatedFile(onToggleFavorite),
onClick: (e) => toggleFavorite(e.target),
onInit: (buttonElement) =>
pswp.on("change", () =>
showIf(
buttonElement,
currentFileAnnotation().isFavorite === true,
),
showFavoriteIf(buttonElement, true),
),
});
}
@@ -481,26 +587,34 @@ export class FileViewerPhotoSwipe {
order: 9,
isButton: true,
html: createPSRegisterElementIconHTML("info"),
onClick: withCurrentAnnotatedFile(onViewInfo),
onClick: () => onViewInfo(currentAnnotatedFile()),
});
if (onEditImage) {
if (haveUser) {
pswp.ui.registerElement({
name: "edit",
name: "more",
// TODO(PS):
// title: t("edit_image"),
title: pt("Edit image"),
title: pt("More"),
order: 16,
isButton: true,
html: createPSRegisterElementIconHTML("edit"),
onClick: withCurrentAnnotatedFile(onEditImage),
onInit: (buttonElement) =>
html: createPSRegisterElementIconHTML("more"),
onInit: (buttonElement) => {
buttonElement.setAttribute("id", moreButtonID);
buttonElement.setAttribute("aria-haspopup", "true");
pswp.on("change", () =>
showIf(
buttonElement,
!!currentFileAnnotation().isEditableImage,
),
),
);
},
onClick: (e) => {
const buttonElement = e.target;
// See also: `resetMoreMenuButtonOnMenuClose`.
buttonElement.setAttribute("aria-controls", moreMenuID);
buttonElement.setAttribute("aria-expanded", true);
onMore(currentAnnotatedFile(), buttonElement);
},
});
}
});
@@ -548,10 +662,6 @@ export class FileViewerPhotoSwipe {
this.pswp.refreshSlideContent(this.pswp.currIndex);
}
updateFiles(files: EnteFile[]) {
// TODO(PS)
}
private clearAutoHideIntervalIfNeeded() {
if (this.autoHideCheckIntervalId) {
clearInterval(this.autoHideCheckIntervalId);
@@ -626,3 +736,12 @@ const createElementFromHTMLString = (htmlString: string) => {
template.innerHTML = htmlString.trim();
return template.content.firstChild;
};
/**
* Update the ARIA attributes for the button that controls the more menu when
* the menu is closed.
*/
export const resetMoreMenuButtonOnMenuClose = (buttonElement: HTMLElement) => {
buttonElement.removeAttribute("aria-controls");
buttonElement.removeAttribute("aria-expanded");
};

View File

@@ -7,9 +7,9 @@
"@/gallery": "*",
"@/utils": "*",
"@ente/shared": "*",
"@mui/material": "^6.4.3",
"@mui/system": "^6.4.3",
"@mui/x-date-pickers": "^7.25.0",
"@mui/material": "^6.4.6",
"@mui/system": "^6.4.6",
"@mui/x-date-pickers": "^7.27.1",
"dayjs": "^1.11.13",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View File

@@ -73,17 +73,17 @@ export const DropdownInput = <T extends string>({
// maxWidth to 0 forces element widths to equal minWidth.
sx: { maxWidth: 0 },
},
},
MenuListProps: {
sx: {
backgroundColor: "background.paper2",
".MuiMenuItem-root": {
color: "text.faint",
whiteSpace: "normal",
},
// Make the selected item pop out by using color.
"&&& > .Mui-selected": {
color: "text.base",
list: {
sx: {
backgroundColor: "background.paper2",
".MuiMenuItem-root": {
color: "text.faint",
whiteSpace: "normal",
},
// Make the selected item pop out by using color.
"&&& > .Mui-selected": {
color: "text.base",
},
},
},
},

View File

@@ -654,6 +654,8 @@ export interface AnnotatedFaceID {
export const getAnnotatedFacesForFile = async (
file: EnteFile,
): Promise<AnnotatedFaceID[]> => {
if (!isMLEnabled()) return [];
const index = await savedFaceIndex(file.id);
if (!index) return [];

View File

@@ -684,28 +684,28 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@mui/core-downloads-tracker@^6.4.3":
version "6.4.3"
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.3.tgz#aa9506e4d43e02d61fb82e23de9741fd24b22b61"
integrity sha512-hlyOzo2ObarllAOeT1ZSAusADE5NZNencUeIvXrdQ1Na+FL1lcznhbxfV5He1KqGiuR8Az3xtCUcYKwMVGFdzg==
"@mui/core-downloads-tracker@^6.4.6":
version "6.4.6"
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.6.tgz#42820be160159df81976f467ce496f5310e08eb1"
integrity sha512-rho5Q4IscbrVmK9rCrLTJmjLjfH6m/NcqKr/mchvck0EIXlyYUB9+Z0oVmkt/+Mben43LMRYBH8q/Uzxj/c4Vw==
"@mui/icons-material@^6.4.3":
version "6.4.3"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-6.4.3.tgz#11f275781f442f864ca5522a6196a0a17cc063ea"
integrity sha512-3IY9LpjkwIJVgL/SkZQKKCUcumdHdQEsJaIavvsQze2QEztBt0HJ17naToN0DBBdhKdtwX5xXrfD6ZFUeWWk8g==
"@mui/icons-material@^6.4.6":
version "6.4.6"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-6.4.6.tgz#a26eaeae2f7f1359b48dac3fe8a8eec61640c325"
integrity sha512-rGJBvIQQbQAlyKYljHQ8wAQS/K2/uYwvemcpygnAmCizmCI4zSF9HQPuiG8Ql4YLZ6V/uKjA3WHIYmF/8sV+pQ==
dependencies:
"@babel/runtime" "^7.26.0"
"@mui/material@^6.4.3":
version "6.4.3"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-6.4.3.tgz#18cb003b6526bf4b3ad6f2bb46ab53154a51b19f"
integrity sha512-ubtQjplbWneIEU8Y+4b2VA0CDBlyH5I3AmVFGmsLyDe/bf0ubxav5t11c8Afem6rkSFWPlZA2DilxmGka1xiKQ==
"@mui/material@^6.4.6":
version "6.4.6"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-6.4.6.tgz#6114a02977735d70170243efc487aed0ca974197"
integrity sha512-6UyAju+DBOdMogfYmLiT3Nu7RgliorimNBny1pN/acOjc+THNFVE7hlxLyn3RDONoZJNDi/8vO4AQQr6dLAXqA==
dependencies:
"@babel/runtime" "^7.26.0"
"@mui/core-downloads-tracker" "^6.4.3"
"@mui/system" "^6.4.3"
"@mui/core-downloads-tracker" "^6.4.6"
"@mui/system" "^6.4.6"
"@mui/types" "^7.2.21"
"@mui/utils" "^6.4.3"
"@mui/utils" "^6.4.6"
"@popperjs/core" "^2.11.8"
"@types/react-transition-group" "^4.4.12"
clsx "^2.1.1"
@@ -714,19 +714,19 @@
react-is "^19.0.0"
react-transition-group "^4.4.5"
"@mui/private-theming@^6.4.3":
version "6.4.3"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-6.4.3.tgz#40d7d95316e9e52d465f0c96da23f9fb8f6a989f"
integrity sha512-7x9HaNwDCeoERc4BoEWLieuzKzXu5ZrhRnEM6AUcRXUScQLvF1NFkTlP59+IJfTbEMgcGg1wWHApyoqcksrBpQ==
"@mui/private-theming@^6.4.6":
version "6.4.6"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-6.4.6.tgz#77c0b150be94c061b34b34ce00eb60cdfb92837f"
integrity sha512-T5FxdPzCELuOrhpA2g4Pi6241HAxRwZudzAuL9vBvniuB5YU82HCmrARw32AuCiyTfWzbrYGGpZ4zyeqqp9RvQ==
dependencies:
"@babel/runtime" "^7.26.0"
"@mui/utils" "^6.4.3"
"@mui/utils" "^6.4.6"
prop-types "^15.8.1"
"@mui/styled-engine@^6.4.3":
version "6.4.3"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-6.4.3.tgz#fbd7a6b925dfaeaa84ffbf8ed9be78a0ff0b3d6e"
integrity sha512-OC402VfK+ra2+f12Gef8maY7Y9n7B6CZcoQ9u7mIkh/7PKwW/xH81xwX+yW+Ak1zBT3HYcVjh2X82k5cKMFGoQ==
"@mui/styled-engine@^6.4.6":
version "6.4.6"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-6.4.6.tgz#cd0783adbb066a349e1995f0e1a7b8c3c2d59738"
integrity sha512-vSWYc9ZLX46be5gP+FCzWVn5rvDr4cXC5JBZwSIkYk9xbC7GeV+0kCvB8Q6XLFQJy+a62bbqtmdwS4Ghi9NBlQ==
dependencies:
"@babel/runtime" "^7.26.0"
"@emotion/cache" "^11.13.5"
@@ -735,16 +735,16 @@
csstype "^3.1.3"
prop-types "^15.8.1"
"@mui/system@^6.4.3":
version "6.4.3"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-6.4.3.tgz#f1e093850c8cc23c6605297c8a4134bea6fe290b"
integrity sha512-Q0iDwnH3+xoxQ0pqVbt8hFdzhq1g2XzzR4Y5pVcICTNtoCLJmpJS3vI4y/OIM1FHFmpfmiEC2IRIq7YcZ8nsmg==
"@mui/system@^6.4.6":
version "6.4.6"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-6.4.6.tgz#f7078e403bd11377c539a05829cd71c441c8b6e6"
integrity sha512-FQjWwPec7pMTtB/jw5f9eyLynKFZ6/Ej9vhm5kGdtmts1z5b7Vyn3Rz6kasfYm1j2TfrfGnSXRvvtwVWxjpz6g==
dependencies:
"@babel/runtime" "^7.26.0"
"@mui/private-theming" "^6.4.3"
"@mui/styled-engine" "^6.4.3"
"@mui/private-theming" "^6.4.6"
"@mui/styled-engine" "^6.4.6"
"@mui/types" "^7.2.21"
"@mui/utils" "^6.4.3"
"@mui/utils" "^6.4.6"
clsx "^2.1.1"
csstype "^3.1.3"
prop-types "^15.8.1"
@@ -771,10 +771,10 @@
prop-types "^15.8.1"
react-is "^19.0.0"
"@mui/utils@^6.4.3":
version "6.4.3"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-6.4.3.tgz#e08bc3a5ae1552a48dd13ddc7c65e3eebdb4cd58"
integrity sha512-jxHRHh3BqVXE9ABxDm+Tc3wlBooYz/4XPa0+4AI+iF38rV1/+btJmSUgG4shDtSWVs/I97aDn5jBCt6SF2Uq2A==
"@mui/utils@^6.4.6":
version "6.4.6"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-6.4.6.tgz#307828bee501d30ed5cd1e339ca28c9efcc4e3f9"
integrity sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==
dependencies:
"@babel/runtime" "^7.26.0"
"@mui/types" "^7.2.21"
@@ -783,71 +783,71 @@
prop-types "^15.8.1"
react-is "^19.0.0"
"@mui/x-date-pickers@^7.25.0":
version "7.25.0"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.25.0.tgz#3cbd6733e84061bf1643571248de217f24075027"
integrity sha512-t62OSFAKwj7KYQ8KcwTuKj6OgDuLQPSe4QUJcKDzD9rEhRIJVRUw2x27gBSdcls4l0PTrba19TghvDxCZprriw==
"@mui/x-date-pickers@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.27.1.tgz#5c9769744598b1e10c1919ce0589b8d235131a17"
integrity sha512-2YPhTM9TM39dmIkEQdSB6P6NASePB9LuhXXKQqq0PX4FXGymYEPz/acQXkk617zwfxJJaDhJZ6g8SAv5pklTJQ==
dependencies:
"@babel/runtime" "^7.25.7"
"@mui/utils" "^5.16.6 || ^6.0.0"
"@mui/x-internals" "7.25.0"
"@mui/x-internals" "7.26.0"
"@types/react-transition-group" "^4.4.11"
clsx "^2.1.1"
prop-types "^15.8.1"
react-transition-group "^4.4.5"
"@mui/x-internals@7.25.0":
version "7.25.0"
resolved "https://registry.yarnpkg.com/@mui/x-internals/-/x-internals-7.25.0.tgz#981a5365391190ab3aa0e9c7a346315b3d1f621c"
integrity sha512-tBUN54YznAkmtCIRAOl35Kgl0MjFDIjUbzIrbWRgVSIR3QJ8bXnVSkiRBi+P91SZEl9+ZW0rDj+osq7xFJV0kg==
"@mui/x-internals@7.26.0":
version "7.26.0"
resolved "https://registry.yarnpkg.com/@mui/x-internals/-/x-internals-7.26.0.tgz#e8c3060582c102127ab55b0a93e881930dac107b"
integrity sha512-VxTCYQcZ02d3190pdvys2TDg9pgbvewAVakEopiOgReKAUhLdRlgGJHcOA/eAuGLyK1YIo26A6Ow6ZKlSRLwMg==
dependencies:
"@babel/runtime" "^7.25.7"
"@mui/utils" "^5.16.6 || ^6.0.0"
"@next/env@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.6.tgz#2fa863d8c568a56b1c8328a86e621b8bdd4f2a20"
integrity sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==
"@next/env@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.2.0.tgz#4c3508ca2c0bb2bc324066818bb8d0415f767641"
integrity sha512-eMgJu1RBXxxqqnuRJQh5RozhskoNUDHBFybvi+Z+yK9qzKeG7dadhv/Vp1YooSZmCnegf7JxWuapV77necLZNA==
"@next/swc-darwin-arm64@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz#92f99badab6cb41f4c5c11a3feffa574bd6a9276"
integrity sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==
"@next/swc-darwin-arm64@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.0.tgz#51ebba2162330ee3e8b3412bf31defd94a7b85e7"
integrity sha512-rlp22GZwNJjFCyL7h5wz9vtpBVuCt3ZYjFWpEPBGzG712/uL1bbSkS675rVAUCRZ4hjoTJ26Q7IKhr5DfJrHDA==
"@next/swc-darwin-x64@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz#f56f4f8d5f6cb5d3915912ac95590d387f897da5"
integrity sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==
"@next/swc-darwin-x64@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.0.tgz#90fd6c6cee494d4348342434cfb9ca9506eae895"
integrity sha512-DiU85EqSHogCz80+sgsx90/ecygfCSGl5P3b4XDRVZpgujBm5lp4ts7YaHru7eVTyZMjHInzKr+w0/7+qDrvMA==
"@next/swc-linux-arm64-gnu@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz#0aaffae519c93d1006419d7b98c34ebfd80ecacd"
integrity sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==
"@next/swc-linux-arm64-gnu@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.0.tgz#f10a26cdbacf2e3de2a02a926c72857b3cb613e1"
integrity sha512-VnpoMaGukiNWVxeqKHwi8MN47yKGyki5q+7ql/7p/3ifuU2341i/gDwGK1rivk0pVYbdv5D8z63uu9yMw0QhpQ==
"@next/swc-linux-arm64-musl@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz#e7398d3d31ca60033f708a718cd6c31edcee2e9a"
integrity sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==
"@next/swc-linux-arm64-musl@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.0.tgz#1821c9a1dd17c441d8182f5cefd586f7902fcdb5"
integrity sha512-ka97/ssYE5nPH4Qs+8bd8RlYeNeUVBhcnsNUmFM6VWEob4jfN9FTr0NBhXVi1XEJpj3cMfgSRW+LdE3SUZbPrw==
"@next/swc-linux-x64-gnu@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz#d76c72508f4d79d6016cab0c52640b93e590cffb"
integrity sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==
"@next/swc-linux-x64-gnu@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.0.tgz#522f55c7672346bab43bce0bcb35c4cb668ad20f"
integrity sha512-zY1JduE4B3q0k2ZCE+DAF/1efjTXUsKP+VXRtrt/rJCTgDlUyyryx7aOgYXNc1d8gobys/Lof9P9ze8IyRDn7Q==
"@next/swc-linux-x64-musl@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz#0b8ba80a53e65bf8970ed11ea923001e2512c7cb"
integrity sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==
"@next/swc-linux-x64-musl@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.0.tgz#e23be1d046c9a630a0315588f9d692d9705ac355"
integrity sha512-QqvLZpurBD46RhaVaVBepkVQzh8xtlUN00RlG4Iq1sBheNugamUNPuZEH1r9X1YGQo1KqAe1iiShF0acva3jHQ==
"@next/swc-win32-arm64-msvc@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz#81b5dbbfdada2c05deef688e799af4a24097b65f"
integrity sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==
"@next/swc-win32-arm64-msvc@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.0.tgz#9e2cb008b82c676dad7d632a43549f969cb2194f"
integrity sha512-ODZ0r9WMyylTHAN6pLtvUtQlGXBL9voljv6ujSlcsjOxhtXPI1Ag6AhZK0SE8hEpR1374WZZ5w33ChpJd5fsjw==
"@next/swc-win32-x64-msvc@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz#131993c45ffd124fb4b15258e2f3f9669c143e3c"
integrity sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==
"@next/swc-win32-x64-msvc@15.2.0":
version "15.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.0.tgz#d280f450a5b6dbb7437c3265f81ea62febf4bf3c"
integrity sha512-8+4Z3Z7xa13NdUuUAcpVNA6o76lNPniBd9Xbo02bwXQXnZgFvEopwY2at5+z7yHl47X9qbZpvwatZ2BRo3EdZw==
"@noble/hashes@^1.2.0":
version "1.7.0"
@@ -1114,6 +1114,11 @@
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.3.tgz#0804dfd279a165d5a0ad8b53a5b9e65f338050a4"
integrity sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==
"@types/react-dom@^19.0.4":
version "19.0.4"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.4.tgz#bedba97f9346bd4c0fe5d39e689713804ec9ac89"
integrity sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==
"@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.11", "@types/react-transition-group@^4.4.12":
version "4.4.12"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044"
@@ -1140,6 +1145,13 @@
dependencies:
csstype "^3.0.2"
"@types/react@^19.0.10":
version "19.0.10"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.10.tgz#d0c66dafd862474190fe95ce11a68de69ed2b0eb"
integrity sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==
dependencies:
csstype "^3.0.2"
"@types/react@^19.0.8":
version "19.0.8"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.8.tgz#7098e6159f2a61e4f4cef2c1223c044a9bec590e"
@@ -2972,22 +2984,22 @@ nanoid@^3.3.6, nanoid@^3.3.7:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
nanoid@^5.0.9:
version "5.0.9"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.9.tgz#977dcbaac055430ce7b1e19cf0130cea91a20e50"
integrity sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==
nanoid@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.2.tgz#b87c6cb6941d127a23b24dffc4659bba48b219d7"
integrity sha512-b+CiXQCNMUGe0Ri64S9SXFcP9hogjAJ2Rd6GdVxhPLRm7mhGaM7VgOvCAJ1ZshfHbqVDI3uqTI5C8/GaKuLI7g==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next@^15.1.6:
version "15.1.6"
resolved "https://registry.yarnpkg.com/next/-/next-15.1.6.tgz#ce22fd0a8f36da1fc4aba86e3ec7e98eb248c555"
integrity sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==
next@^15.2.0:
version "15.2.0"
resolved "https://registry.yarnpkg.com/next/-/next-15.2.0.tgz#00f4619ae4322102b08c1a8bf315f7b757525508"
integrity sha512-VaiM7sZYX8KIAHBrRGSFytKknkrexNfGb8GlG6e93JqueCspuGte8i4ybn8z4ww1x3f2uzY4YpTaBEW4/hvsoQ==
dependencies:
"@next/env" "15.1.6"
"@next/env" "15.2.0"
"@swc/counter" "0.1.3"
"@swc/helpers" "0.5.15"
busboy "1.6.0"
@@ -2995,14 +3007,14 @@ next@^15.1.6:
postcss "8.4.31"
styled-jsx "5.1.6"
optionalDependencies:
"@next/swc-darwin-arm64" "15.1.6"
"@next/swc-darwin-x64" "15.1.6"
"@next/swc-linux-arm64-gnu" "15.1.6"
"@next/swc-linux-arm64-musl" "15.1.6"
"@next/swc-linux-x64-gnu" "15.1.6"
"@next/swc-linux-x64-musl" "15.1.6"
"@next/swc-win32-arm64-msvc" "15.1.6"
"@next/swc-win32-x64-msvc" "15.1.6"
"@next/swc-darwin-arm64" "15.2.0"
"@next/swc-darwin-x64" "15.2.0"
"@next/swc-linux-arm64-gnu" "15.2.0"
"@next/swc-linux-arm64-musl" "15.2.0"
"@next/swc-linux-x64-gnu" "15.2.0"
"@next/swc-linux-x64-musl" "15.2.0"
"@next/swc-win32-arm64-msvc" "15.2.0"
"@next/swc-win32-x64-msvc" "15.2.0"
sharp "^0.33.5"
node-releases@^2.0.19:
@@ -3289,10 +3301,10 @@ react-fast-compare@^2.0.1:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
react-i18next@^15.4.0:
version "15.4.0"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.4.0.tgz#87c755fb6d7a567eec134e4759b022a0baacb19e"
integrity sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==
react-i18next@^15.4.1:
version "15.4.1"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.4.1.tgz#33f3e89c2f6c68e2bfcbf9aa59986ad42fe78758"
integrity sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==
dependencies:
"@babel/runtime" "^7.25.0"
html-parse-stringify "^3.0.1"
@@ -4199,10 +4211,10 @@ yup@^1.6.1:
toposort "^2.0.2"
type-fest "^2.19.0"
zod@^3.24.1:
version "3.24.1"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee"
integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==
zod@^3.24.2:
version "3.24.2"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3"
integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==
zxcvbn@^4.4.2:
version "4.4.2"