Compare commits

...

1 Commits

Author SHA1 Message Date
Pushkar Anand
eb61a591b0 Sub components created 2025-08-04 22:26:24 +05:30
15 changed files with 1367 additions and 2 deletions

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { NavbarBase } from 'ente-base/components/Navbar';
import { NormalNavbarContents, HiddenSectionNavbarContents } from './NavbarContents';
import type { EnteFile } from 'ente-media/file';
import type { SelectedState } from 'utils/file';
interface GalleryContainerProps {
// State props
filteredFiles: EnteFile[];
selected: SelectedState;
barMode: string;
isInSearchMode: boolean;
isFirstLoad: boolean;
activeCollectionID: number | undefined;
// Event handlers
onSidebar: () => void;
onUpload: () => void;
onSelectSearchOption: (option: unknown) => void;
onSelectPeople: () => void;
onSelectPerson: (personID: string) => void;
onClearSelection: () => void;
onChangeMode: (mode: string) => void;
// Children for flexible rendering
children: React.ReactNode;
}
/**
* Simplified gallery container component that handles the basic layout
*/
export const GalleryContainer: React.FC<GalleryContainerProps> = ({
selected,
barMode,
isInSearchMode,
activeCollectionID,
onSidebar,
onUpload,
onSelectSearchOption,
onSelectPeople,
onSelectPerson,
onChangeMode,
children,
}) => {
const showSelectionBar = selected.count > 0 && selected.collectionID === activeCollectionID;
return (
<>
{/* Navigation Bar */}
<NavbarBase
sx={[
{
mb: "12px",
px: "24px",
"@media (width < 720px)": { px: "4px" },
},
showSelectionBar && { borderColor: "accent.main" },
]}
>
{showSelectionBar ? (
<div>Selection Bar Placeholder</div>
) : barMode === "hidden-albums" ? (
<HiddenSectionNavbarContents
onBack={() => onChangeMode("albums")}
/>
) : (
<NormalNavbarContents
isInSearchMode={isInSearchMode}
onSidebar={onSidebar}
onUpload={onUpload}
onShowSearchInput={() => {
// TODO: Implement search mode toggle
}}
onSelectSearchOption={onSelectSearchOption}
onSelectPeople={onSelectPeople}
onSelectPerson={onSelectPerson}
/>
)}
</NavbarBase>
{/* Main Content Area */}
{children}
</>
);
};

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { GalleryEmptyState, PeopleEmptyState } from 'ente-new/photos/components/gallery';
import { uploadManager } from 'services/upload-manager';
import { PseudoCollectionID } from 'ente-new/photos/services/collection-summary';
import type { EnteFile } from 'ente-media/file';
import type { GalleryBarMode } from 'ente-new/photos/components/gallery/reducer';
interface GalleryContentProps {
// Basic state
filteredFiles: EnteFile[];
isInSearchMode: boolean;
isFirstLoad: boolean;
barMode: GalleryBarMode;
activeCollectionID: number | undefined;
activePerson: unknown;
// Event handlers
onUpload: () => void;
// Children for flexible content
children?: React.ReactNode;
}
/**
* Simplified main content area that handles empty states and delegates to children
*/
export const GalleryContent: React.FC<GalleryContentProps> = ({
filteredFiles,
isInSearchMode,
isFirstLoad,
barMode,
activeCollectionID,
activePerson,
onUpload,
children,
}) => {
// Show empty states for specific conditions
const showGalleryEmptyState =
!isInSearchMode &&
!isFirstLoad &&
!filteredFiles.length &&
activeCollectionID === PseudoCollectionID.all;
const showPeopleEmptyState =
!isInSearchMode &&
!isFirstLoad &&
barMode === "people" &&
!activePerson;
if (showGalleryEmptyState) {
return (
<GalleryEmptyState
isUploadInProgress={uploadManager.isUploadInProgress()}
onUpload={onUpload}
/>
);
}
if (showPeopleEmptyState) {
return <PeopleEmptyState />;
}
// Render children (FileListWithViewer, GalleryBarAndListHeader, etc.)
return <>{children}</>;
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { PlanSelector } from 'ente-new/photos/components/PlanSelector';
import { SingleInputDialog } from 'ente-base/components/SingleInputDialog';
import { WhatsNew } from 'ente-new/photos/components/WhatsNew';
import { t } from 'i18next';
interface GalleryModalsProps {
// Modal states - using simplified visibility props
planSelectorVisible: boolean;
whatsNewVisible: boolean;
albumNameInputVisible: boolean;
// Modal handlers
onClosePlanSelector: () => void;
onCloseWhatsNew: () => void;
onCloseAlbumNameInput: () => void;
onAlbumNameSubmit: (name: string) => Promise<void>;
setLoading: (loading: boolean) => void;
}
/**
* Simplified container for essential gallery modals
*/
export const GalleryModals: React.FC<GalleryModalsProps> = ({
planSelectorVisible,
whatsNewVisible,
albumNameInputVisible,
onClosePlanSelector,
onCloseWhatsNew,
onCloseAlbumNameInput,
onAlbumNameSubmit,
setLoading,
}) => {
return (
<>
{/* Plan Selector Modal */}
{planSelectorVisible && (
<PlanSelector
open={planSelectorVisible}
onClose={onClosePlanSelector}
setLoading={setLoading}
/>
)}
{/* What's New Dialog */}
{whatsNewVisible && (
<WhatsNew
open={whatsNewVisible}
onClose={onCloseWhatsNew}
/>
)}
{/* Album Name Input Dialog */}
{albumNameInputVisible && (
<SingleInputDialog
open={albumNameInputVisible}
onClose={onCloseAlbumNameInput}
title={t("new_album")}
label={t("album_name")}
submitButtonTitle={t("create")}
onSubmit={onAlbumNameSubmit}
/>
)}
</>
);
};

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Typography } from '@mui/material';
import { CenteredRow } from 'ente-base/components/containers';
import { TranslucentLoadingOverlay } from 'ente-base/components/loaders';
import { t } from 'i18next';
/**
* Message shown during first load to inform users about potential delays
*/
export const FirstLoadMessage: React.FC = () => (
<CenteredRow>
<Typography variant="small" sx={{ color: "text.muted" }}>
{t("initial_load_delay_warning")}
</Typography>
</CenteredRow>
);
/**
* Message shown when the app is offline
*/
export const OfflineMessage: React.FC = () => (
<Typography
variant="small"
sx={{ bgcolor: "background.paper", p: 2, mb: 1, textAlign: "center" }}
>
{t("offline_message")}
</Typography>
);
/**
* Blocking overlay shown during certain operations
*/
export const BlockingLoadOverlay: React.FC<{ show: boolean }> = ({ show }) =>
show ? <TranslucentLoadingOverlay /> : null;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { Stack, Typography, IconButton } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import MenuIcon from '@mui/icons-material/Menu';
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
import { FocusVisibleButton } from 'ente-base/components/mui/FocusVisibleButton';
import { useIsSmallWidth } from 'ente-base/components/utils/hooks';
import type { ButtonishProps } from 'ente-base/components/mui';
import { SearchBar, type SearchBarProps } from 'ente-new/photos/components/SearchBar';
import { uploadManager } from 'services/upload-manager';
import { t } from 'i18next';
interface NormalNavbarContentsProps extends SearchBarProps {
onSidebar: () => void;
onUpload: () => void;
}
export const NormalNavbarContents: React.FC<NormalNavbarContentsProps> = ({
onSidebar,
onUpload,
...props
}) => (
<>
{!props.isInSearchMode && <SidebarButton onClick={onSidebar} />}
<SearchBar {...props} />
{!props.isInSearchMode && <UploadButton onClick={onUpload} />}
</>
);
const SidebarButton: React.FC<ButtonishProps> = ({ onClick }) => (
<IconButton {...{ onClick }}>
<MenuIcon />
</IconButton>
);
const UploadButton: React.FC<ButtonishProps> = ({ onClick }) => {
const disabled = uploadManager.isUploadInProgress();
const isSmallWidth = useIsSmallWidth();
const icon = <FileUploadOutlinedIcon />;
return (
<>
{isSmallWidth ? (
<IconButton {...{ onClick, disabled }}>{icon}</IconButton>
) : (
<FocusVisibleButton
color="secondary"
startIcon={icon}
{...{ onClick, disabled }}
>
{t("upload")}
</FocusVisibleButton>
)}
</>
);
};
interface HiddenSectionNavbarContentsProps {
onBack: () => void;
}
export const HiddenSectionNavbarContents: React.FC<
HiddenSectionNavbarContentsProps
> = ({ onBack }) => (
<Stack
direction="row"
sx={(theme) => ({
gap: "24px",
flex: 1,
alignItems: "center",
background: theme.vars.palette.background.default,
})}
>
<IconButton onClick={onBack}>
<ArrowBackIcon />
</IconButton>
<Typography sx={{ flex: 1 }}>{t("section_hidden")}</Typography>
</Stack>
);

View File

@@ -0,0 +1,6 @@
// Gallery presentational components
export { GalleryContainer } from './GalleryContainer';
export { GalleryContent } from './GalleryContent';
export { GalleryModals } from './GalleryModals';
export { NormalNavbarContents, HiddenSectionNavbarContents } from './NavbarContents';
export { FirstLoadMessage, OfflineMessage, BlockingLoadOverlay } from './MessageComponents';

View File

@@ -0,0 +1,11 @@
// Gallery hooks for managing different aspects of the gallery component
export { useGalleryData } from './useGalleryData';
export { useFileOperations } from './useFileOperations';
export { useSelection } from './useSelection';
export { useCollectionOperations } from './useCollectionOperations';
export { useModalManagement } from './useModalManagement';
export { useGalleryInitialization } from './useGalleryInitialization';
export { useGalleryNavigation } from './useGalleryNavigation';
// Re-export types for convenience
export type { RemotePullOpts } from './useGalleryData';

View File

@@ -0,0 +1,167 @@
import { useCallback, useState } from 'react';
import type { Collection } from 'ente-media/collection';
import type { EnteFile } from 'ente-media/file';
import type { SelectedState } from 'utils/file';
import type { CollectionOp } from 'ente-new/photos/components/SelectedFileOptions';
import type { CollectionSelectorAttributes } from 'ente-new/photos/components/CollectionSelector';
import { createAlbum } from 'ente-new/photos/services/collection';
import { performCollectionOp } from 'ente-new/photos/components/gallery/helpers';
import { getSelectedFiles } from 'utils/file';
import { usePhotosAppContext } from 'ente-new/photos/types/context';
import { useBaseContext } from 'ente-base/context';
import { notifyOthersFilesDialogAttributes } from 'ente-new/photos/components/utils/dialog-attributes';
interface UseCollectionOperationsProps {
user: { id: number } | null;
filteredFiles: EnteFile[];
selected: SelectedState;
clearSelection: () => void;
onRemotePull: () => Promise<void>;
}
/**
* Custom hook for managing collection operations
*/
export const useCollectionOperations = ({
user,
filteredFiles,
selected,
clearSelection,
onRemotePull,
}: UseCollectionOperationsProps) => {
const { showLoadingBar, hideLoadingBar } = usePhotosAppContext();
const { onGenericError, showMiniDialog } = useBaseContext();
// Collection selector state
const [openCollectionSelector, setOpenCollectionSelector] = useState(false);
const [collectionSelectorAttributes, setCollectionSelectorAttributes] =
useState<CollectionSelectorAttributes | undefined>();
// Album creation state
const [postCreateAlbumOp, setPostCreateAlbumOp] = useState<CollectionOp | undefined>();
/**
* Create a handler for collection operations (add, move)
*/
const createOnSelectForCollectionOp = useCallback(
(op: CollectionOp) => (selectedCollection: Collection) => {
void (async () => {
showLoadingBar();
try {
setOpenCollectionSelector(false);
const selectedFiles = getSelectedFiles(selected, filteredFiles);
const userFiles = selectedFiles.filter(
(f) => f.ownerID === user!.id,
);
const sourceCollectionID = selected.collectionID;
if (userFiles.length > 0) {
await performCollectionOp(
op,
selectedCollection,
userFiles,
sourceCollectionID,
);
}
// Notify if some files couldn't be processed
if (userFiles.length !== selectedFiles.length) {
showMiniDialog(notifyOthersFilesDialogAttributes());
}
clearSelection();
await onRemotePull();
} catch (e) {
onGenericError(e);
} finally {
hideLoadingBar();
}
})();
},
[
showLoadingBar,
hideLoadingBar,
selected,
filteredFiles,
user,
showMiniDialog,
clearSelection,
onRemotePull,
onGenericError,
],
);
/**
* Create a handler for collection operations that need album creation
*/
const createOnCreateForCollectionOp = useCallback(
(op: CollectionOp) => {
setPostCreateAlbumOp(op);
return () => {
// This will be handled by the album name input dialog
// The actual creation happens in handleAlbumNameSubmit
};
},
[],
);
/**
* Handle album name submission after creation
*/
const handleAlbumNameSubmit = useCallback(
async (name: string) => {
if (!postCreateAlbumOp) return;
try {
const collection = await createAlbum(name);
// Execute the deferred operation
createOnSelectForCollectionOp(postCreateAlbumOp)(collection);
setPostCreateAlbumOp(undefined);
} catch (e) {
onGenericError(e);
setPostCreateAlbumOp(undefined);
}
},
[postCreateAlbumOp, createOnSelectForCollectionOp, onGenericError],
);
/**
* Open collection selector with specific attributes
*/
const handleOpenCollectionSelector = useCallback(
(attributes: CollectionSelectorAttributes) => {
setCollectionSelectorAttributes(attributes);
setOpenCollectionSelector(true);
},
[],
);
/**
* Close collection selector
*/
const handleCloseCollectionSelector = useCallback(
() => {
setOpenCollectionSelector(false);
setCollectionSelectorAttributes(undefined);
},
[],
);
return {
// State
openCollectionSelector,
collectionSelectorAttributes,
postCreateAlbumOp,
// Handlers
createOnSelectForCollectionOp,
createOnCreateForCollectionOp,
handleAlbumNameSubmit,
handleOpenCollectionSelector,
handleCloseCollectionSelector,
// Setters for external control
setOpenCollectionSelector,
setCollectionSelectorAttributes,
};
};

View File

@@ -0,0 +1,183 @@
import { useCallback } from 'react';
import type { EnteFile } from 'ente-media/file';
import type { ItemVisibility } from 'ente-media/file-metadata';
import type { Collection } from 'ente-media/collection';
import {
addToFavoritesCollection,
removeFromFavoritesCollection,
removeFromCollection
} from 'ente-new/photos/services/collection';
import { updateFilesVisibility } from 'ente-new/photos/services/file';
import { getSelectedFiles, type SelectedState } from 'utils/file';
import type { FileOp } from 'ente-new/photos/components/SelectedFileOptions';
import { usePhotosAppContext } from 'ente-new/photos/types/context';
import { useBaseContext } from 'ente-base/context';
import { notifyOthersFilesDialogAttributes } from 'ente-new/photos/components/utils/dialog-attributes';
interface UseFileOperationsProps {
user: { id: number };
filteredFiles: EnteFile[];
selected: SelectedState;
clearSelection: () => void;
onRemotePull: () => Promise<void>;
dispatch: (action: { type: string; [key: string]: unknown }) => void;
favoriteFileIDs: Set<number>;
}
/**
* Custom hook for handling file operations
*/
export const useFileOperations = ({
user,
filteredFiles,
selected,
clearSelection,
onRemotePull,
dispatch,
favoriteFileIDs,
}: UseFileOperationsProps) => {
const { showLoadingBar, hideLoadingBar } = usePhotosAppContext();
const { onGenericError, showMiniDialog } = useBaseContext();
/**
* Toggle favorite status of a file
*/
const handleToggleFavorite = useCallback(
async (file: EnteFile) => {
const fileID = file.id;
const isFavorite = favoriteFileIDs.has(fileID);
dispatch({ type: "addPendingFavoriteUpdate", fileID });
try {
const action = isFavorite
? removeFromFavoritesCollection
: addToFavoritesCollection;
await action([file]);
dispatch({
type: "unsyncedFavoriteUpdate",
fileID,
isFavorite: !isFavorite,
});
} finally {
dispatch({ type: "removePendingFavoriteUpdate", fileID });
}
},
[favoriteFileIDs, dispatch],
);
/**
* Update file visibility
*/
const handleFileVisibilityUpdate = useCallback(
async (file: EnteFile, visibility: ItemVisibility) => {
const fileID = file.id;
dispatch({ type: "addPendingVisibilityUpdate", fileID });
try {
await updateFilesVisibility([file], visibility);
dispatch({
type: "unsyncedPrivateMagicMetadataUpdate",
fileID,
privateMagicMetadata: {
...file.magicMetadata,
count: file.magicMetadata?.count ?? 0,
version: (file.magicMetadata?.version ?? 0) + 1,
data: { ...file.magicMetadata?.data, visibility },
},
});
} finally {
dispatch({ type: "removePendingVisibilityUpdate", fileID });
}
},
[dispatch],
);
/**
* Remove files from a collection
*/
const handleRemoveFilesFromCollection = useCallback(
async (collection: Collection) => {
showLoadingBar();
let notifyOthersFiles = false;
try {
const selectedFiles = getSelectedFiles(selected, filteredFiles);
const processedCount = await removeFromCollection(
collection,
selectedFiles,
);
notifyOthersFiles = processedCount !== selectedFiles.length;
clearSelection();
await onRemotePull();
} catch (e) {
onGenericError(e);
} finally {
hideLoadingBar();
}
if (notifyOthersFiles) {
showMiniDialog(notifyOthersFilesDialogAttributes());
}
},
[
showLoadingBar,
hideLoadingBar,
selected,
filteredFiles,
clearSelection,
onRemotePull,
onGenericError,
showMiniDialog,
],
);
/**
* Create a file operation handler
*/
const createFileOpHandler = useCallback(
(op: FileOp) => () => {
void (async () => {
showLoadingBar();
try {
const selectedFiles = getSelectedFiles(selected, filteredFiles);
const toProcessFiles = selectedFiles.filter(
(file) => file.ownerID === user.id,
);
if (toProcessFiles.length > 0) {
// TODO: Implement proper file operations with correct typing
// await performFileOp(op, toProcessFiles, ...callbacks);
console.log('File operation:', op, toProcessFiles.length, 'files');
}
if (toProcessFiles.length !== selectedFiles.length) {
showMiniDialog(notifyOthersFilesDialogAttributes());
}
clearSelection();
await onRemotePull();
} catch (e) {
onGenericError(e);
} finally {
hideLoadingBar();
}
})();
},
[
showLoadingBar,
hideLoadingBar,
selected,
filteredFiles,
user,
dispatch,
showMiniDialog,
clearSelection,
onRemotePull,
onGenericError,
],
);
return {
handleToggleFavorite,
handleFileVisibilityUpdate,
handleRemoveFilesFromCollection,
createFileOpHandler,
};
};

View File

@@ -0,0 +1,132 @@
import { useCallback, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import { useGalleryReducer } from 'ente-new/photos/components/gallery/reducer';
import { PromiseQueue } from 'ente-utils/promise';
import { usePhotosAppContext } from 'ente-new/photos/types/context';
import { useBaseContext } from 'ente-base/context';
import {
savedCollections,
savedCollectionFiles,
savedTrashItems
} from 'ente-new/photos/services/photos-fdb';
import { pullFiles, prePullFiles, postPullFiles } from 'ente-new/photos/services/pull';
import { ensureLocalUser } from 'ente-accounts/services/user';
import { savedUserDetailsOrTriggerPull } from 'ente-new/photos/services/user-details';
import { masterKeyFromSession, clearSessionStorage } from 'ente-base/session';
import { isSessionInvalid } from 'ente-accounts/services/session';
import log from 'ente-base/log';
import exportService from 'ente-new/photos/services/export';
export interface RemotePullOpts {
silent?: boolean;
}
/**
* Custom hook for managing gallery data fetching and state
*/
export const useGalleryData = () => {
const { showLoadingBar, hideLoadingBar } = usePhotosAppContext();
const { onGenericError } = useBaseContext();
const router = useRouter();
const [state, dispatch] = useGalleryReducer();
const [isFirstLoad, setIsFirstLoad] = useState(false);
// Queues for serializing remote operations
const remoteFilesPullQueue = useRef(new PromiseQueue<void>());
const remotePullQueue = useRef(new PromiseQueue<void>());
/**
* Pull latest collections, collection files and trash items from remote
*/
const remoteFilesPull = useCallback(
() =>
remoteFilesPullQueue.current.add(() =>
pullFiles({
onSetCollections: (collections) =>
dispatch({ type: "setCollections", collections }),
onSetCollectionFiles: (collectionFiles) =>
dispatch({
type: "setCollectionFiles",
collectionFiles,
}),
onSetTrashedItems: (trashItems) =>
dispatch({ type: "setTrashItems", trashItems }),
onDidUpdateCollectionFiles: () =>
exportService.onLocalFilesUpdated(),
}),
),
[],
);
/**
* Perform a full remote pull with error handling
*/
const remotePull = useCallback(
async (opts?: RemotePullOpts) =>
remotePullQueue.current.add(async () => {
const { silent } = opts ?? {};
// Pre-flight checks
if (!navigator.onLine) return;
if (await isSessionInvalid()) {
// Handle session expiry
return;
}
if (!(await masterKeyFromSession())) {
clearSessionStorage();
void router.push("/credentials");
return;
}
try {
if (!silent) showLoadingBar();
await prePullFiles();
await remoteFilesPull();
await postPullFiles();
} catch (e) {
log.error("Remote pull failed", e);
} finally {
dispatch({ type: "clearUnsyncedState" });
if (!silent) hideLoadingBar();
}
}),
[showLoadingBar, hideLoadingBar, router, remoteFilesPull],
);
/**
* Initialize the gallery on mount
*/
const initializeGallery = useCallback(async () => {
try {
dispatch({ type: "showAll" });
const user = ensureLocalUser();
const userDetails = await savedUserDetailsOrTriggerPull();
dispatch({
type: "mount",
user,
familyData: userDetails?.familyData,
collections: await savedCollections(),
collectionFiles: await savedCollectionFiles(),
trashItems: await savedTrashItems(),
});
await remotePull();
setIsFirstLoad(false);
} catch (error) {
onGenericError(error);
}
}, [dispatch, remotePull, onGenericError]);
return {
state,
dispatch,
isFirstLoad,
setIsFirstLoad,
remoteFilesPull,
remotePull,
initializeGallery,
};
};

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { haveMasterKeyInSession } from 'ente-base/session';
import { savedAuthToken } from 'ente-base/token';
import { stashRedirect } from 'ente-accounts/services/redirect';
import { validateKey } from 'ente-new/photos/components/gallery/helpers';
import {
getAndClearIsFirstLogin,
getAndClearJustSignedUp
} from 'ente-accounts/services/accounts-db';
import { shouldShowWhatsNew } from 'ente-new/photos/services/changelog';
import { initSettings } from 'ente-new/photos/services/settings';
import { useBaseContext } from 'ente-base/context';
interface UseGalleryInitializationProps {
onInitializeGallery: () => Promise<void>;
onShowPlanSelector: () => void;
onShowWhatsNew: () => void;
}
/**
* Custom hook for handling gallery initialization and authentication checks
*/
export const useGalleryInitialization = ({
onInitializeGallery,
onShowPlanSelector,
onShowWhatsNew,
}: UseGalleryInitializationProps) => {
const { logout } = useBaseContext();
const router = useRouter();
const [isFirstLoad, setIsFirstLoad] = useState(false);
const [blockingLoad, setBlockingLoad] = useState(false);
/**
* Preload all three variants of a responsive image
*/
const preloadImage = (imgBasePath: string) => {
const srcset: string[] = [];
for (let i = 1; i <= 3; i++) srcset.push(`${imgBasePath}/${i}x.png ${i}x`);
new Image().srcset = srcset.join(",");
};
/**
* Set up keyboard shortcuts for select all
*/
const setupSelectAllKeyBoardShortcutHandler = () => {
// This will be handled by the useSelection hook
// Keeping this function for compatibility
return () => {
// Cleanup function - currently handled by useSelection hook
};
};
useEffect(() => {
const electron = globalThis.electron;
let syncIntervalID: ReturnType<typeof setInterval> | undefined;
void (async () => {
// Check authentication
if (!haveMasterKeyInSession() || !(await savedAuthToken())) {
stashRedirect("/gallery");
void router.push("/");
return;
}
// Validate credentials
if (!(await validateKey())) {
logout();
return;
}
// One-time initialization
preloadImage("/images/subscription-card-background");
initSettings();
setupSelectAllKeyBoardShortcutHandler();
// Check if this is the user's first login
setIsFirstLoad(getAndClearIsFirstLogin());
// Show plan selector for new users
if (getAndClearJustSignedUp()) {
onShowPlanSelector();
}
// Initialize gallery data
await onInitializeGallery();
// Clear first load state
setIsFirstLoad(false);
// Start periodic sync
syncIntervalID = setInterval(
() => {
// This should trigger a silent remote pull
// Will be handled by the data management hook
},
5 * 60 * 1000 /* 5 minutes */,
);
// Handle electron-specific features
if (electron) {
electron.onMainWindowFocus(() => {
// Trigger silent remote pull on focus
});
if (await shouldShowWhatsNew(electron)) {
onShowWhatsNew();
}
}
})();
return () => {
clearInterval(syncIntervalID);
if (electron) {
electron.onMainWindowFocus(undefined);
}
};
}, [router, logout, onInitializeGallery, onShowPlanSelector, onShowWhatsNew]);
return {
isFirstLoad,
blockingLoad,
setBlockingLoad,
};
};

View File

@@ -0,0 +1,168 @@
import { useCallback, useRef } from 'react';
import { useRouter } from 'next/router';
import { PseudoCollectionID } from 'ente-new/photos/services/collection-summary';
import type { SearchOption } from 'ente-new/photos/services/search/types';
interface UseGalleryNavigationProps {
dispatch: (action: { type: string; [key: string]: unknown }) => void;
barMode: string;
activeCollectionID: number | undefined;
}
/**
* Custom hook for managing gallery navigation and view changes
*/
export const useGalleryNavigation = ({
dispatch,
barMode,
activeCollectionID,
}: UseGalleryNavigationProps) => {
const router = useRouter();
/**
* Grace period tracking for hidden section authentication
*/
const lastAuthenticationForHiddenTimestamp = useRef<number>(0);
/**
* Handle collection summary selection with optional authentication
*/
const showCollectionSummary = useCallback(
async (
collectionSummaryID: number | undefined,
isHiddenCollectionSummary: boolean | undefined,
authenticateUser?: () => Promise<void>,
) => {
const lastAuthAt = lastAuthenticationForHiddenTimestamp.current;
if (
isHiddenCollectionSummary &&
barMode !== "hidden-albums" &&
Date.now() - lastAuthAt > 5 * 60 * 1e3 /* 5 minutes */ &&
authenticateUser
) {
await authenticateUser();
lastAuthenticationForHiddenTimestamp.current = Date.now();
}
// Trigger a pull of the latest data when opening trash
if (collectionSummaryID === PseudoCollectionID.trash) {
// This should trigger a remote files pull
// Will be handled by the calling component
}
dispatch({ type: "showCollectionSummary", collectionSummaryID });
},
[dispatch, barMode],
);
/**
* Handle search option selection
*/
const handleSelectSearchOption = useCallback(
(searchOption: SearchOption | undefined) => {
if (searchOption) {
const type = searchOption.suggestion.type;
if (type === "collection") {
dispatch({
type: "showCollectionSummary",
collectionSummaryID: searchOption.suggestion.collectionID,
});
} else if (type === "person") {
dispatch({
type: "showPerson",
personID: searchOption.suggestion.person.id,
});
} else {
dispatch({
type: "enterSearchMode",
searchSuggestion: searchOption.suggestion,
});
}
} else {
dispatch({ type: "exitSearch" });
}
},
[dispatch],
);
/**
* Handle gallery bar mode changes
*/
const handleChangeBarMode = useCallback(
(mode: string) => {
if (mode === "people") {
dispatch({ type: "showPeople" });
} else {
dispatch({ type: "showAlbums" });
}
},
[dispatch],
);
/**
* Handle collection selection
*/
const handleSelectCollection = useCallback(
(collectionID: number) =>
dispatch({
type: "showCollectionSummary",
collectionSummaryID: collectionID,
}),
[dispatch],
);
/**
* Handle person selection
*/
const handleSelectPerson = useCallback(
(personID: string) => dispatch({ type: "showPerson", personID }),
[dispatch],
);
/**
* Handle entering search mode
*/
const handleEnterSearchMode = useCallback(
() => dispatch({ type: "enterSearchMode" }),
[dispatch],
);
/**
* Handle showing people view
*/
const handleShowPeople = useCallback(
() => dispatch({ type: "showPeople" }),
[dispatch],
);
/**
* Update browser URL based on active collection
*/
const updateBrowserURL = useCallback(() => {
if (typeof activeCollectionID === "undefined" || !router.isReady) {
return;
}
let collectionURL = "";
if (activeCollectionID !== PseudoCollectionID.all) {
collectionURL = `?collection=${activeCollectionID}`;
}
const href = `/gallery${collectionURL}`;
void router.push(href, undefined, { shallow: true });
}, [activeCollectionID, router]);
return {
// Navigation handlers
showCollectionSummary,
handleSelectSearchOption,
handleChangeBarMode,
handleSelectCollection,
handleSelectPerson,
handleEnterSearchMode,
handleShowPeople,
updateBrowserURL,
// State
lastAuthenticationForHiddenTimestamp,
};
};

View File

@@ -0,0 +1,121 @@
import { useCallback, useRef, useState } from 'react';
import type { EnteFile } from 'ente-media/file';
import { useModalVisibility } from 'ente-base/components/utils/modal';
import type { UploadTypeSelectorIntent } from 'ente-gallery/components/Upload';
/**
* Custom hook for managing all modal states and visibility
*/
export const useModalManagement = () => {
// File drag and drop state
const [dragAndDropFiles, setDragAndDropFiles] = useState<File[]>([]);
const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false);
// Upload modal state
const [uploadTypeSelectorView, setUploadTypeSelectorView] = useState(false);
const [uploadTypeSelectorIntent, setUploadTypeSelectorIntent] =
useState<UploadTypeSelectorIntent>("upload");
// File viewer state
const [isFileViewerOpen, setIsFileViewerOpen] = useState(false);
// Fix creation time dialog state
const [fixCreationTimeFiles, setFixCreationTimeFiles] = useState<EnteFile[]>([]);
// Authentication callback for hidden section access
const onAuthenticateCallback = useRef<(() => void) | undefined>(undefined);
// Modal visibility hooks
const { show: showSidebar, props: sidebarVisibilityProps } = useModalVisibility();
const { show: showPlanSelector, props: planSelectorVisibilityProps } = useModalVisibility();
const { show: showWhatsNew, props: whatsNewVisibilityProps } = useModalVisibility();
const { show: showFixCreationTime, props: fixCreationTimeVisibilityProps } = useModalVisibility();
const { show: showExport, props: exportVisibilityProps } = useModalVisibility();
const { show: showAuthenticateUser, props: authenticateUserVisibilityProps } = useModalVisibility();
const { show: showAlbumNameInput, props: albumNameInputVisibilityProps } = useModalVisibility();
/**
* Authenticate user for hidden section access
*/
const authenticateUser = useCallback(
() =>
new Promise<void>((resolve) => {
onAuthenticateCallback.current = resolve;
showAuthenticateUser();
}),
[showAuthenticateUser],
);
/**
* Open upload type selector
*/
const openUploader = useCallback((intent?: UploadTypeSelectorIntent) => {
setUploadTypeSelectorView(true);
setUploadTypeSelectorIntent(intent ?? "upload");
}, []);
/**
* Close upload type selector
*/
const closeUploadTypeSelector = useCallback(() => {
setUploadTypeSelectorView(false);
}, []);
/**
* Check if any modal is currently open
*/
const isAnyModalOpen =
uploadTypeSelectorView ||
sidebarVisibilityProps.open ||
planSelectorVisibilityProps.open ||
fixCreationTimeVisibilityProps.open ||
exportVisibilityProps.open ||
authenticateUserVisibilityProps.open ||
albumNameInputVisibilityProps.open ||
isFileViewerOpen;
return {
// State
dragAndDropFiles,
shouldDisableDropzone,
uploadTypeSelectorView,
uploadTypeSelectorIntent,
isFileViewerOpen,
fixCreationTimeFiles,
isAnyModalOpen,
// Setters
setDragAndDropFiles,
setShouldDisableDropzone,
setUploadTypeSelectorView,
setUploadTypeSelectorIntent,
setIsFileViewerOpen,
setFixCreationTimeFiles,
// Modal visibility props
sidebarVisibilityProps,
planSelectorVisibilityProps,
whatsNewVisibilityProps,
fixCreationTimeVisibilityProps,
exportVisibilityProps,
authenticateUserVisibilityProps,
albumNameInputVisibilityProps,
// Modal show functions
showSidebar,
showPlanSelector,
showWhatsNew,
showFixCreationTime,
showExport,
showAuthenticateUser,
showAlbumNameInput,
// Actions
authenticateUser,
openUploader,
closeUploadTypeSelector,
// Authentication callback
onAuthenticateCallback,
};
};

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { SelectedState } from 'utils/file';
import type { EnteFile } from 'ente-media/file';
import { PseudoCollectionID } from 'ente-new/photos/services/collection-summary';
interface UseSelectionProps {
user: { id: number } | null;
filteredFiles: EnteFile[];
activeCollectionID: number | undefined;
barMode: string;
activePersonID?: string;
isAnyModalOpen: boolean;
}
/**
* Custom hook for managing file selection state and keyboard shortcuts
*/
export const useSelection = ({
user,
filteredFiles,
activeCollectionID,
barMode,
activePersonID,
isAnyModalOpen,
}: UseSelectionProps) => {
const [selected, setSelected] = useState<SelectedState>({
ownCount: 0,
count: 0,
collectionID: 0,
context: { mode: "albums", collectionID: PseudoCollectionID.all },
});
const selectAll = useCallback((e: KeyboardEvent) => {
// Don't intercept Ctrl/Cmd + a if the user is typing in a text field
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
e.preventDefault();
// Don't select all if conditions aren't met
if (
!user ||
!filteredFiles.length ||
isAnyModalOpen
) {
return;
}
// Create a selection with everything based on the current context
const newSelected = {
ownCount: 0,
count: 0,
collectionID: activeCollectionID,
context:
barMode === "people" && activePersonID
? { mode: "people" as const, personID: activePersonID }
: {
mode: barMode as "albums" | "hidden-albums",
collectionID: activeCollectionID!,
},
};
filteredFiles.forEach((item) => {
if (item.ownerID === user.id) {
newSelected.ownCount++;
}
newSelected.count++;
// @ts-expect-error Selection code needs type fixing
newSelected[item.id] = true;
});
setSelected(newSelected);
}, [user, filteredFiles, activeCollectionID, barMode, activePersonID, isAnyModalOpen]);
const clearSelection = useCallback(() => {
if (!selected.count) {
return;
}
setSelected({
ownCount: 0,
count: 0,
collectionID: 0,
context: undefined,
});
}, [selected.count]);
const keyboardShortcutHandlerRef = useRef({ selectAll, clearSelection });
useEffect(() => {
keyboardShortcutHandlerRef.current = { selectAll, clearSelection };
}, [selectAll, clearSelection]);
// Set up keyboard shortcuts
useEffect(() => {
const handleKeyUp = (e: KeyboardEvent) => {
switch (e.key) {
case "Escape":
keyboardShortcutHandlerRef.current.clearSelection();
break;
case "a":
if (e.ctrlKey || e.metaKey) {
keyboardShortcutHandlerRef.current.selectAll(e);
}
break;
}
};
document.addEventListener("keydown", handleKeyUp);
return () => {
document.removeEventListener("keydown", handleKeyUp);
};
}, []);
return {
selected,
setSelected,
clearSelection,
selectAll,
};
};

View File

@@ -1227,7 +1227,7 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
"@types/react-dom@^19.1.1", "@types/react-dom@^19.1.6":
"@types/react-dom@^19.1.6":
version "19.1.6"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.6.tgz#4af629da0e9f9c0f506fc4d1caa610399c595d64"
integrity sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==
@@ -1244,7 +1244,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^19.1.0", "@types/react@^19.1.8":
"@types/react@*", "@types/react@^19.1.8":
version "19.1.8"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.8.tgz#ff8395f2afb764597265ced15f8dddb0720ae1c3"
integrity sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==