From 91f99508f7d535c67c7806bf4f48eaf83a695c43 Mon Sep 17 00:00:00 2001 From: Aonan Li Date: Sat, 6 Sep 2025 16:11:35 -0700 Subject: [PATCH 1/5] Start batch editing from the current page (#303) * Batch edit from the current page * Redirect to grid when batch editing a page without grid * Always clear selected ids on path change * Only clear ids when selecting * Use data-photo-grid to find grid * Update batch edit from command K * Not mount batch edit panel when not needed * Not clear selecting state when go to /grid * Use search param to force batch editing --- src/admin/AdminAppMenu.tsx | 15 +-- src/admin/AdminBatchEditPanelClient.tsx | 6 +- src/cmdk/CommandKClient.tsx | 18 ++-- src/photo/PhotoGrid.tsx | 137 +++++++++++++----------- src/photo/PhotoGridContainer.tsx | 4 - src/photo/PhotoGridInfinite.tsx | 2 - src/photo/PhotoGridPageClient.tsx | 1 - 7 files changed, 98 insertions(+), 85 deletions(-) diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 799cef2b..27766084 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -57,20 +57,24 @@ export default function AdminAppMenu({ clearAuthStateAndRedirectIfNecessary, } = useAppState(); + const isSelecting = selectedPhotoIds !== undefined; + useEffect(() => { - if (pathname !== PATH_GRID_INFERRED) { + if (isSelecting) { setSelectedPhotoIds?.(undefined); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname, setSelectedPhotoIds]); const appText = useAppText(); - const isSelecting = selectedPhotoIds !== undefined; - const isAltPressed = useIsKeyBeingPressed('alt'); const showAppInsightsLink = photosCountTotal > 0 && !isAltPressed; + const currentPageHasGrid = () => + document.querySelector('[data-photo-grid]') !== null; + const sectionUpload: MoreMenuSection = useMemo(() => ({ items: [{ label: appText.admin.uploadPhotos, icon: , - ...pathname !== PATH_GRID_INFERRED && { - href: PATH_GRID_INFERRED, + ...!currentPageHasGrid() && { + href: `${PATH_GRID_INFERRED}?batch=true`, }, action: () => { if (isSelecting) { @@ -192,7 +196,6 @@ export default function AdminAppMenu({ return { items }; }, [ - pathname, appText, isSelecting, photosCountNeedSync, diff --git a/src/admin/AdminBatchEditPanelClient.tsx b/src/admin/AdminBatchEditPanelClient.tsx index c8894049..88928b08 100644 --- a/src/admin/AdminBatchEditPanelClient.tsx +++ b/src/admin/AdminBatchEditPanelClient.tsx @@ -8,8 +8,6 @@ import { clsx } from 'clsx/lite'; import { IoCloseSharp } from 'react-icons/io5'; import { useEffect, useRef, useState } from 'react'; import { TAG_FAVS, Tags } from '@/tag'; -import { usePathname } from 'next/navigation'; -import { PATH_GRID_INFERRED } from '@/app/path'; import PhotoTagFieldset from './PhotoTagFieldset'; import { tagMultiplePhotosAction } from '@/photo/actions'; import { toastSuccess } from '@/toast'; @@ -28,8 +26,6 @@ export default function AdminBatchEditPanelClient({ }) { const refNote = useRef(null); - const pathname = usePathname(); - const { isUserSignedIn, selectedPhotoIds, @@ -156,7 +152,7 @@ export default function AdminBatchEditPanelClient({ const shouldShowPanel = isUserSignedIn && - pathname === PATH_GRID_INFERRED && + document.querySelector('[data-photo-grid]') !== null && selectedPhotoIds !== undefined; useEffect(() => { diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index d79fe6ca..bce31e16 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -644,12 +644,18 @@ export default function CommandKClient({ ? appText.admin.batchEdit : appText.admin.batchExitEdit, annotation: , - path: selectedPhotoIds === undefined - ? PATH_GRID_INFERRED - : undefined, - action: selectedPhotoIds === undefined - ? () => setSelectedPhotoIds?.([]) - : () => setSelectedPhotoIds?.(undefined), + action: () => { + if (selectedPhotoIds === undefined) { + const hasGrid = document.querySelector('[data-photo-grid]') !== null; + if (!hasGrid) { + router.push(`${PATH_GRID_INFERRED}?batch=true`); + return; + } + setSelectedPhotoIds?.([]); + } else { + setSelectedPhotoIds?.(undefined); + } + }, }, { label: {appText.admin.appInsights} diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index 03b7acda..820f2a14 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -8,7 +8,8 @@ import AnimateItems from '@/components/AnimateItems'; import { GRID_ASPECT_RATIO } from '@/app/config'; import { useAppState } from '@/app/AppState'; import SelectTileOverlay from '@/components/SelectTileOverlay'; -import { ReactNode } from 'react'; +import { ReactNode, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { GRID_GAP_CLASSNAME } from '@/components'; export default function PhotoGrid({ @@ -21,7 +22,6 @@ export default function PhotoGrid({ staggerOnFirstLoadOnly = true, additionalTile, small, - canSelect, onLastPhotoVisible, onAnimationComplete, ...categories @@ -35,7 +35,6 @@ export default function PhotoGrid({ staggerOnFirstLoadOnly?: boolean additionalTile?: ReactNode small?: boolean - canSelect?: boolean onLastPhotoVisible?: () => void onAnimationComplete?: () => void } & PhotoSetCategory) { @@ -46,68 +45,84 @@ export default function PhotoGrid({ isGridHighDensity, } = useAppState(); + const searchParams = useSearchParams(); + const router = useRouter(); + + // Check for batch editing parameter on mount + useEffect(() => { + if (searchParams.get('batch') === 'true') { + setSelectedPhotoIds?.([]); + // Clean up the URL parameter + const url = new URL(window.location.href); + url.searchParams.delete('batch'); + router.replace(url.pathname + url.search); + } + }, [searchParams, setSelectedPhotoIds, router]); + return ( - { - const isSelected = selectedPhotoIds?.includes(photo.id) ?? false; - return
- + { + const isSelected = selectedPhotoIds?.includes(photo.id) ?? false; + return
- {isUserSignedIn && canSelect && selectedPhotoIds !== undefined && - setSelectedPhotoIds?.(isSelected - ? (selectedPhotoIds ?? []).filter(id => id !== photo.id) - : (selectedPhotoIds ?? []).concat(photo.id), + > + } -
; - }).concat(additionalTile ? <>{additionalTile} : [])} - itemKeys={photos.map(photo => photo.id) - .concat(additionalTile ? ['more'] : [])} - /> + {...{ + photo, + ...categories, + selected: photo.id === selectedPhoto?.id, + priority: prioritizeInitialPhotos ? index < 6 : undefined, + onVisible: index === photos.length - 1 + ? onLastPhotoVisible + : undefined, + }} + /> + {isUserSignedIn && selectedPhotoIds !== undefined && + setSelectedPhotoIds?.(isSelected + ? (selectedPhotoIds ?? []).filter(id => id !== photo.id) + : (selectedPhotoIds ?? []).concat(photo.id), + )} + />} +
; + }).concat(additionalTile ? <>{additionalTile} : [])} + itemKeys={photos.map(photo => photo.id) + .concat(additionalTile ? ['more'] : [])} + /> + ); }; diff --git a/src/photo/PhotoGridContainer.tsx b/src/photo/PhotoGridContainer.tsx index 15c69fea..831d613d 100644 --- a/src/photo/PhotoGridContainer.tsx +++ b/src/photo/PhotoGridContainer.tsx @@ -19,7 +19,6 @@ export default function PhotoGridContainer({ animateOnFirstLoadOnly, header, sidebar, - canSelect, ...categories }: { cacheKey: string @@ -34,7 +33,6 @@ export default function PhotoGridContainer({ shouldAnimateDynamicItems, setShouldAnimateDynamicItems, ] = useState(false); - const onAnimationComplete = useCallback(() => setShouldAnimateDynamicItems(true), []); @@ -55,7 +53,6 @@ export default function PhotoGridContainer({ ...categories, animateOnFirstLoadOnly, onAnimationComplete, - canSelect, }} /> {count > photos.length && } } diff --git a/src/photo/PhotoGridInfinite.tsx b/src/photo/PhotoGridInfinite.tsx index cbc083dc..58da5065 100644 --- a/src/photo/PhotoGridInfinite.tsx +++ b/src/photo/PhotoGridInfinite.tsx @@ -14,7 +14,6 @@ export default function PhotoGridInfinite({ excludeFromFeeds, canStart, animateOnFirstLoadOnly, - canSelect, ...categories }: { cacheKey: string @@ -40,7 +39,6 @@ export default function PhotoGridInfinite({ canStart, onLastPhotoVisible, animateOnFirstLoadOnly, - canSelect, }} />} ); diff --git a/src/photo/PhotoGridPageClient.tsx b/src/photo/PhotoGridPageClient.tsx index e3f0c9ac..40062ff6 100644 --- a/src/photo/PhotoGridPageClient.tsx +++ b/src/photo/PhotoGridPageClient.tsx @@ -60,7 +60,6 @@ export default function PhotoGridPageClient({ }} /> } - canSelect /> ); } From 1f499e697e532ef9672ca9cd1e62f1c388089c94 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 6 Sep 2025 23:49:10 -0500 Subject: [PATCH 2/5] Move batch editing to pure url-based state --- .vscode/settings.json | 2 + app/layout.tsx | 95 ++++++++++--------- src/admin/AdminAppMenu.tsx | 47 ++++----- .../{ => select}/AdminBatchEditPanel.tsx | 0 .../AdminBatchEditPanelClient.tsx | 25 ++--- src/admin/select/SelectPhotosProvider.tsx | 76 +++++++++++++++ src/admin/select/SelectPhotosState.ts | 16 ++++ src/app/AppState.ts | 4 - src/app/AppStateProvider.tsx | 12 +-- src/app/path.ts | 1 + src/cmdk/CommandKClient.tsx | 21 ++-- src/components/SelectTileOverlay.tsx | 4 +- src/photo/PhotoGrid.tsx | 30 ++---- src/utility/useClientSearchParams.ts | 24 +++++ 14 files changed, 224 insertions(+), 133 deletions(-) rename src/admin/{ => select}/AdminBatchEditPanel.tsx (100%) rename src/admin/{ => select}/AdminBatchEditPanelClient.tsx (92%) create mode 100644 src/admin/select/SelectPhotosProvider.tsx create mode 100644 src/admin/select/SelectPhotosState.ts create mode 100644 src/utility/useClientSearchParams.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ff71441..60274468 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -50,12 +50,14 @@ "parameterizes", "presigner", "Provia", + "pushstate", "qaub", "QRSTUVWXYZ", "ratelimit", "ratelimiter", "Reala", "recents", + "replacestate", "skippable", "sonner", "sslmode", diff --git a/app/layout.tsx b/app/layout.tsx index 8981abec..47a84e60 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -20,7 +20,6 @@ import Nav from '@/app/Nav'; import Footer from '@/app/Footer'; import CommandK from '@/cmdk/CommandK'; import SwrConfigClient from '@/swr/SwrConfigClient'; -import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel'; import ShareModals from '@/share/ShareModals'; import AdminUploadPanel from '@/admin/upload/AdminUploadPanel'; import { revalidatePath } from 'next/cache'; @@ -29,6 +28,8 @@ import ThemeColors from '@/app/ThemeColors'; import AppTextProvider from '@/i18n/state/AppTextProvider'; import SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider'; import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/path'; +import SelectPhotosProvider from '@/admin/select/SelectPhotosProvider'; +import AdminBatchEditPanel from '@/admin/select/AdminBatchEditPanel'; import '../tailwind.css'; @@ -94,51 +95,53 @@ export default function RootLayout({ )}> - - - - -
-
- -
-
- - - - -
+ + + + + +
+
+ +
+
+ + + + +
+
diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 27766084..08ba2d48 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -15,7 +15,7 @@ import { IoArrowDown, IoArrowUp } from 'react-icons/io5'; import { clsx } from 'clsx/lite'; import AdminAppInfoIcon from './AdminAppInfoIcon'; import { signOutAction } from '@/auth/actions'; -import { ComponentProps, useEffect, useMemo } from 'react'; +import { ComponentProps, useMemo } from 'react'; import useIsKeyBeingPressed from '@/utility/useIsKeyBeingPressed'; import IconPhoto from '@/components/icons/IconPhoto'; import IconUpload from '@/components/icons/IconUpload'; @@ -31,8 +31,8 @@ import Spinner from '@/components/Spinner'; import { useAppText } from '@/i18n/state/client'; import SwitcherItemMenu from '@/components/switcher/SwitcherItemMenu'; import { MoreMenuSection } from '@/components/more/MoreMenu'; -import { usePathname } from 'next/navigation'; import { FiXSquare } from 'react-icons/fi'; +import { useSelectPhotosState } from './select/SelectPhotosState'; export default function AdminAppMenu({ isOpen, @@ -41,30 +41,24 @@ export default function AdminAppMenu({ isOpen?: boolean setIsOpen?: (isOpen: boolean) => void }) { - const pathname = usePathname(); - const { photosCountTotal = 0, photosCountNeedSync = 0, uploadsCount = 0, tagsCount = 0, recipesCount = 0, - selectedPhotoIds, isLoadingAdminData, startUpload, - setSelectedPhotoIds, refreshAdminData, clearAuthStateAndRedirectIfNecessary, } = useAppState(); - const isSelecting = selectedPhotoIds !== undefined; - - useEffect(() => { - if (isSelecting) { - setSelectedPhotoIds?.(undefined); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pathname, setSelectedPhotoIds]); + const { + canCurrentPageSelectPhotos, + isSelectingPhotos, + startSelectingPhotos, + stopSelectingPhotos, + } = useSelectPhotosState(); const appText = useAppText(); @@ -72,9 +66,6 @@ export default function AdminAppMenu({ const showAppInsightsLink = photosCountTotal > 0 && !isAltPressed; - const currentPageHasGrid = () => - document.querySelector('[data-photo-grid]') !== null; - const sectionUpload: MoreMenuSection = useMemo(() => ({ items: [{ label: appText.admin.uploadPhotos, icon: , - ...!currentPageHasGrid() && { + ...!canCurrentPageSelectPhotos && { href: `${PATH_GRID_INFERRED}?batch=true`, }, - action: () => { - if (isSelecting) { - setSelectedPhotoIds?.(undefined); - } else { - setSelectedPhotoIds?.([]); - } - }, + action: isSelectingPhotos + ? stopSelectingPhotos + : startSelectingPhotos, }); } items.push({ @@ -197,11 +184,13 @@ export default function AdminAppMenu({ return { items }; }, [ appText, - isSelecting, + canCurrentPageSelectPhotos, + isSelectingPhotos, + startSelectingPhotos, + stopSelectingPhotos, photosCountNeedSync, photosCountTotal, recipesCount, - setSelectedPhotoIds, showAppInsightsLink, tagsCount, uploadsCount, diff --git a/src/admin/AdminBatchEditPanel.tsx b/src/admin/select/AdminBatchEditPanel.tsx similarity index 100% rename from src/admin/AdminBatchEditPanel.tsx rename to src/admin/select/AdminBatchEditPanel.tsx diff --git a/src/admin/AdminBatchEditPanelClient.tsx b/src/admin/select/AdminBatchEditPanelClient.tsx similarity index 92% rename from src/admin/AdminBatchEditPanelClient.tsx rename to src/admin/select/AdminBatchEditPanelClient.tsx index 88928b08..2de3ff13 100644 --- a/src/admin/AdminBatchEditPanelClient.tsx +++ b/src/admin/select/AdminBatchEditPanelClient.tsx @@ -3,21 +3,21 @@ import Note from '@/components/Note'; import LoaderButton from '@/components/primitives/LoaderButton'; import AppGrid from '@/components/AppGrid'; -import { useAppState } from '@/app/AppState'; import { clsx } from 'clsx/lite'; import { IoCloseSharp } from 'react-icons/io5'; import { useEffect, useRef, useState } from 'react'; import { TAG_FAVS, Tags } from '@/tag'; -import PhotoTagFieldset from './PhotoTagFieldset'; +import PhotoTagFieldset from '@/admin/PhotoTagFieldset'; import { tagMultiplePhotosAction } from '@/photo/actions'; import { toastSuccess } from '@/toast'; -import DeletePhotosButton from './DeletePhotosButton'; +import DeletePhotosButton from '@/admin/DeletePhotosButton'; import { photoQuantityText } from '@/photo'; import { FaArrowDown, FaCheck } from 'react-icons/fa6'; import ResponsiveText from '@/components/primitives/ResponsiveText'; import IconFavs from '@/components/icons/IconFavs'; import IconTag from '@/components/icons/IconTag'; import { useAppText } from '@/i18n/state/client'; +import { useSelectPhotosState } from './SelectPhotosState'; export default function AdminBatchEditPanelClient({ uniqueTags, @@ -27,12 +27,14 @@ export default function AdminBatchEditPanelClient({ const refNote = useRef(null); const { - isUserSignedIn, + canCurrentPageSelectPhotos, + isSelectingPhotos, + stopSelectingPhotos, selectedPhotoIds, setSelectedPhotoIds, isPerformingSelectEdit, setIsPerformingSelectEdit, - } = useAppState(); + } = useSelectPhotosState(); const appText = useAppText(); @@ -41,7 +43,7 @@ export default function AdminBatchEditPanelClient({ const isInTagMode = tags !== undefined; const resetForm = () => { - setSelectedPhotoIds?.(undefined); + setSelectedPhotoIds?.([]); setTags(undefined); setTagErrorMessage(''); }; @@ -146,21 +148,20 @@ export default function AdminBatchEditPanelClient({ } - onClick={() => setSelectedPhotoIds?.(undefined)} + onClick={stopSelectingPhotos} /> ; const shouldShowPanel = - isUserSignedIn && - document.querySelector('[data-photo-grid]') !== null && - selectedPhotoIds !== undefined; + isSelectingPhotos && + canCurrentPageSelectPhotos; useEffect(() => { // Steal focus from Admin Menu to hide tooltip - if (shouldShowPanel) { + if (isSelectingPhotos) { refNote.current?.focus(); } - }, [shouldShowPanel]); + }, [isSelectingPhotos]); return shouldShowPanel ? { + setCanCurrentPageSelectPhotos(document + .querySelector(`[${DATA_KEY_PHOTO_GRID}]`) !== null); + }, [pathname]); + + const isSelectingPhotos = useMemo(() => + isUserSignedIn && + searchParamsSelect === 'true' + , [isUserSignedIn, searchParamsSelect]); + + const startSelectingPhotos = useCallback(() => { + window.history.pushState( + null, + '', + canCurrentPageSelectPhotos + ? `${pathname}?${PARAM_SELECT}=true` + : `${PATH_GRID_INFERRED}?batch=true`, + ); + dispatchEvent(new Event('pushstate')); + }, [canCurrentPageSelectPhotos, pathname]); + + const stopSelectingPhotos = useCallback(() => { + window.history.pushState(null, '', pathname); + dispatchEvent(new Event('pushstate')); + }, [pathname]); + + useEffect(() => { + if (!isSelectingPhotos) { setSelectedPhotoIds([]); } + }, [isSelectingPhotos]); + + const [selectedPhotoIds, setSelectedPhotoIds] = + useState([]); + + const [isPerformingSelectEdit, setIsPerformingSelectEdit] = + useState(false); + + return ( + + {children} + + ); +} diff --git a/src/admin/select/SelectPhotosState.ts b/src/admin/select/SelectPhotosState.ts new file mode 100644 index 00000000..240e8d6f --- /dev/null +++ b/src/admin/select/SelectPhotosState.ts @@ -0,0 +1,16 @@ +import { createContext, Dispatch, SetStateAction, use } from 'react'; + +export type SelectPhotosState = { + canCurrentPageSelectPhotos?: boolean + isSelectingPhotos?: boolean; + startSelectingPhotos?: () => void + stopSelectingPhotos?: () => void + selectedPhotoIds?: string[] + setSelectedPhotoIds?: (photoIds: string[]) => void + isPerformingSelectEdit?: boolean + setIsPerformingSelectEdit?: Dispatch> +}; + +export const SelectPhotosContext = createContext({}); + +export const useSelectPhotosState = () => use(SelectPhotosContext); diff --git a/src/app/AppState.ts b/src/app/AppState.ts index 2c5e29e5..d4ed02a2 100644 --- a/src/app/AppState.ts +++ b/src/app/AppState.ts @@ -53,10 +53,6 @@ export type AppStateContextType = { isLoadingAdminData?: boolean refreshAdminData?: () => void updateAdminData?: (updatedData: Partial) => void - selectedPhotoIds?: string[] - setSelectedPhotoIds?: Dispatch> - isPerformingSelectEdit?: boolean - setIsPerformingSelectEdit?: Dispatch> insightsIndicatorStatus?: InsightsIndicatorStatus // UPLOAD startUpload?: () => Promise diff --git a/src/app/AppStateProvider.tsx b/src/app/AppStateProvider.tsx index f183be48..976dbe4e 100644 --- a/src/app/AppStateProvider.tsx +++ b/src/app/AppStateProvider.tsx @@ -93,13 +93,11 @@ export default function AppStateProvider({ useState(); const [userEmailEager, setUserEmailEager] = useState(); + const isUserSignedIn = Boolean(userEmail); + const isUserSignedInEager = Boolean(userEmailEager); // ADMIN const [adminUpdateTimes, setAdminUpdateTimes] = useState([]); - const [selectedPhotoIds, setSelectedPhotoIds] = - useState(); - const [isPerformingSelectEdit, setIsPerformingSelectEdit] = - useState(false); // UPLOAD const uploadInputRef = useRef(null); const [uploadState, _setUploadState] = useState(INITIAL_UPLOAD_STATE); @@ -161,8 +159,6 @@ export default function AppStateProvider({ setUserEmail(auth?.user?.email ?? undefined); } }, [auth, authError]); - const isUserSignedIn = Boolean(userEmail); - const isUserSignedInEager = Boolean(userEmailEager); const { data: adminData, @@ -260,10 +256,6 @@ export default function AppStateProvider({ isLoadingAdminData, refreshAdminData, updateAdminData, - selectedPhotoIds, - setSelectedPhotoIds, - isPerformingSelectEdit, - setIsPerformingSelectEdit, // UPLOAD uploadInputRef, startUpload, diff --git a/src/app/path.ts b/src/app/path.ts index b6b058be..ab2bf915 100644 --- a/src/app/path.ts +++ b/src/app/path.ts @@ -85,6 +85,7 @@ export const PATH_API_PRESIGNED_URL = `${PATH_API_STORAGE}/presigned-url`; const EDIT = 'edit'; const IMAGE = 'image'; export const PARAM_UPLOAD_TITLE = 'title'; +export const PARAM_SELECT = 'select'; // Special characters export const MISSING_FIELD = '-'; diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index bce31e16..54b8726c 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -93,6 +93,7 @@ import { formatDistanceToNow } from 'date-fns'; import IconCheck from '@/components/icons/IconCheck'; import { getSortStateFromPath } from '@/photo/sort/path'; import IconSort from '@/components/icons/IconSort'; +import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; @@ -161,8 +162,6 @@ export default function CommandKClient({ uploadsCount, tagsCount, recipesCount, - selectedPhotoIds, - setSelectedPhotoIds, insightsIndicatorStatus, isGridHighDensity, areZoomControlsShown, @@ -182,6 +181,13 @@ export default function CommandKClient({ setShouldDebugRecipeOverlays, } = useAppState(); + const { + isSelectingPhotos, + startSelectingPhotos, + stopSelectingPhotos, + selectedPhotoIds, + } = useSelectPhotosState(); + const { doesPathOfferSort, isSortedByDefault, @@ -645,15 +651,10 @@ export default function CommandKClient({ : appText.admin.batchExitEdit, annotation: , action: () => { - if (selectedPhotoIds === undefined) { - const hasGrid = document.querySelector('[data-photo-grid]') !== null; - if (!hasGrid) { - router.push(`${PATH_GRID_INFERRED}?batch=true`); - return; - } - setSelectedPhotoIds?.([]); + if (!isSelectingPhotos) { + startSelectingPhotos?.(); } else { - setSelectedPhotoIds?.(undefined); + stopSelectingPhotos?.(); } }, }, { diff --git a/src/components/SelectTileOverlay.tsx b/src/components/SelectTileOverlay.tsx index 11a2b199..6f9ce954 100644 --- a/src/components/SelectTileOverlay.tsx +++ b/src/components/SelectTileOverlay.tsx @@ -2,8 +2,8 @@ import { clsx } from 'clsx/lite'; import SimpleCheckbox from './primitives/SimpleCheckbox'; -import { useAppState } from '@/app/AppState'; import Spinner from './Spinner'; +import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; export default function SelectTileOverlay({ isSelected, @@ -12,7 +12,7 @@ export default function SelectTileOverlay({ isSelected: boolean onSelectChange: () => void }) { - const { isPerformingSelectEdit } = useAppState(); + const { isPerformingSelectEdit } = useSelectPhotosState(); return (
void } & PhotoSetCategory) { const { - isUserSignedIn, - selectedPhotoIds, - setSelectedPhotoIds, isGridHighDensity, } = useAppState(); - const searchParams = useSearchParams(); - const router = useRouter(); - - // Check for batch editing parameter on mount - useEffect(() => { - if (searchParams.get('batch') === 'true') { - setSelectedPhotoIds?.([]); - // Clean up the URL parameter - const url = new URL(window.location.href); - url.searchParams.delete('batch'); - router.replace(url.pathname + url.search); - } - }, [searchParams, setSelectedPhotoIds, router]); + const { + isSelectingPhotos, + selectedPhotoIds, + setSelectedPhotoIds, + } = useSelectPhotosState(); return ( -
+
- {isUserSignedIn && selectedPhotoIds !== undefined && + {isSelectingPhotos && setSelectedPhotoIds?.(isSelected diff --git a/src/utility/useClientSearchParams.ts b/src/utility/useClientSearchParams.ts new file mode 100644 index 00000000..0f946670 --- /dev/null +++ b/src/utility/useClientSearchParams.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useState } from 'react'; + +export default function useClientSearchParams( + paramKey: string, +): string | undefined { + const [paramValue, setParamValue] = useState(); + + const captureParam = useCallback(() => { + setParamValue(window.location.search.split(`${paramKey}=`)[1]); + }, [paramKey]); + + useEffect(() => { + window.addEventListener('popstate', captureParam); + window.addEventListener('pushstate', captureParam); + window.addEventListener('replacestate', captureParam); + return () => { + window.removeEventListener('popstate', captureParam); + window.removeEventListener('pushstate', captureParam); + window.removeEventListener('replacestate', captureParam); + }; + }, [captureParam]); + + return paramValue; +}; From db5f9ceb3a7ff965d193e060eb3b70dfd02942f5 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 7 Sep 2025 12:18:51 -0500 Subject: [PATCH 3/5] Finalize batch select core behavior --- .../select/AdminBatchEditPanelClient.tsx | 13 ++---- src/admin/select/SelectPhotosProvider.tsx | 40 +++++++++---------- src/cmdk/CommandKClient.tsx | 7 ++-- src/photo/PhotoGrid.tsx | 2 +- src/utility/url.ts | 6 +++ 5 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/admin/select/AdminBatchEditPanelClient.tsx b/src/admin/select/AdminBatchEditPanelClient.tsx index 2de3ff13..24f62bb1 100644 --- a/src/admin/select/AdminBatchEditPanelClient.tsx +++ b/src/admin/select/AdminBatchEditPanelClient.tsx @@ -31,7 +31,6 @@ export default function AdminBatchEditPanelClient({ isSelectingPhotos, stopSelectingPhotos, selectedPhotoIds, - setSelectedPhotoIds, isPerformingSelectEdit, setIsPerformingSelectEdit, } = useSelectPhotosState(); @@ -42,12 +41,6 @@ export default function AdminBatchEditPanelClient({ const [tagErrorMessage, setTagErrorMessage] = useState(''); const isInTagMode = tags !== undefined; - const resetForm = () => { - setSelectedPhotoIds?.([]); - setTags(undefined); - setTagErrorMessage(''); - }; - const photosText = photoQuantityText( selectedPhotoIds?.length ?? 0, appText, @@ -99,7 +92,7 @@ export default function AdminBatchEditPanelClient({ ) .then(() => { toastSuccess(`${photosText} tagged`); - resetForm(); + stopSelectingPhotos?.(); }) .finally(() => setIsPerformingSelectEdit?.(false)); }} @@ -119,7 +112,7 @@ export default function AdminBatchEditPanelClient({ photoIds={selectedPhotoIds} disabled={isFormDisabled} onClick={() => setIsPerformingSelectEdit?.(true)} - onDelete={resetForm} + onDelete={stopSelectingPhotos} onFinish={() => setIsPerformingSelectEdit?.(false)} /> { toastSuccess(`${photosText} favorited`); - resetForm(); + stopSelectingPhotos?.(); }) .finally(() => setIsPerformingSelectEdit?.(false)); }} diff --git a/src/admin/select/SelectPhotosProvider.tsx b/src/admin/select/SelectPhotosProvider.tsx index ae9a6cb7..2a7c099b 100644 --- a/src/admin/select/SelectPhotosProvider.tsx +++ b/src/admin/select/SelectPhotosProvider.tsx @@ -6,6 +6,7 @@ import { PARAM_SELECT, PATH_GRID_INFERRED } from '@/app/path'; import { usePathname } from 'next/navigation'; import { useAppState } from '@/app/AppState'; import useClientSearchParams from '@/utility/useClientSearchParams'; +import { pushPathWithEvent } from '@/utility/url'; export const DATA_KEY_PHOTO_GRID = 'data-photo-grid'; @@ -21,7 +22,11 @@ export default function SelectPhotosProvider({ const searchParamsSelect = useClientSearchParams(PARAM_SELECT); const [canCurrentPageSelectPhotos, setCanCurrentPageSelectPhotos] = - useState(true); + useState(false); + const [selectedPhotoIds, setSelectedPhotoIds] = + useState([]); + const [isPerformingSelectEdit, setIsPerformingSelectEdit] = + useState(false); useEffect(() => { setCanCurrentPageSelectPhotos(document @@ -33,32 +38,23 @@ export default function SelectPhotosProvider({ searchParamsSelect === 'true' , [isUserSignedIn, searchParamsSelect]); - const startSelectingPhotos = useCallback(() => { - window.history.pushState( - null, - '', - canCurrentPageSelectPhotos - ? `${pathname}?${PARAM_SELECT}=true` - : `${PATH_GRID_INFERRED}?batch=true`, - ); - dispatchEvent(new Event('pushstate')); - }, [canCurrentPageSelectPhotos, pathname]); + const startSelectingPhotos = useCallback(() => + pushPathWithEvent(canCurrentPageSelectPhotos + ? `${pathname}?${PARAM_SELECT}=true` + // Redirect to grid if current view does not support photo selection + : `${PATH_GRID_INFERRED}?${PARAM_SELECT}=true`) + , [canCurrentPageSelectPhotos, pathname]); - const stopSelectingPhotos = useCallback(() => { - window.history.pushState(null, '', pathname); - dispatchEvent(new Event('pushstate')); - }, [pathname]); + const stopSelectingPhotos = useCallback(() => + pushPathWithEvent(pathname) + , [pathname]); useEffect(() => { - if (!isSelectingPhotos) { setSelectedPhotoIds([]); } + if (!isSelectingPhotos) { + setSelectedPhotoIds([]); + } }, [isSelectingPhotos]); - const [selectedPhotoIds, setSelectedPhotoIds] = - useState([]); - - const [isPerformingSelectEdit, setIsPerformingSelectEdit] = - useState(false); - return ( , action: () => { if (!isSelectingPhotos) { diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index 902919a3..c19ab00c 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -88,7 +88,7 @@ export default function PhotoGrid({ className={clsx( 'flex w-full h-full', // Prevent photo navigation when selecting - selectedPhotoIds?.length !== undefined && 'pointer-events-none', + isSelectingPhotos && 'pointer-events-none', )} {...{ photo, diff --git a/src/utility/url.ts b/src/utility/url.ts index 63c927ce..5b548918 100644 --- a/src/utility/url.ts +++ b/src/utility/url.ts @@ -41,3 +41,9 @@ export const downloadFileFromBrowser = async ( document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); }; + +// Necessary for useClientSearchParams to see window.location changes +export const pushPathWithEvent = (pathname: string) => { + window.history.pushState(null, '', pathname); + dispatchEvent(new Event('pushstate')); +}; From 2d72f12ddddf695cdd79c2376889e919b8902b08 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 7 Sep 2025 12:52:30 -0500 Subject: [PATCH 4/5] Rename batch edit language --- src/admin/AdminAppMenu.tsx | 4 ++-- src/cmdk/CommandKClient.tsx | 6 ++++-- src/i18n/locales/bd-bn.ts | 5 ++--- src/i18n/locales/en-gb.ts | 5 ++--- src/i18n/locales/en-us.ts | 5 ++--- src/i18n/locales/id-id.ts | 5 ++--- src/i18n/locales/pt-br.ts | 5 ++--- src/i18n/locales/pt-pt.ts | 5 ++--- src/i18n/locales/tr-tr.ts | 5 ++--- src/i18n/locales/zh-cn.ts | 5 ++--- 10 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 08ba2d48..1b0a9232 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -149,8 +149,8 @@ export default function AdminAppMenu({ if (photosCountTotal) { items.push({ label: isSelectingPhotos - ? appText.admin.batchExitEdit - : appText.admin.batchEditShort, + ? appText.admin.selectPhotosExit + : appText.admin.selectPhotos, icon: isSelectingPhotos ? , + // Search by legacy label + keywords: ['batch', 'edit'], action: () => { if (!isSelectingPhotos) { startSelectingPhotos?.(); diff --git a/src/i18n/locales/bd-bn.ts b/src/i18n/locales/bd-bn.ts index d1ac2b59..c416fbfa 100644 --- a/src/i18n/locales/bd-bn.ts +++ b/src/i18n/locales/bd-bn.ts @@ -111,9 +111,8 @@ export const TEXT: I18N = { manageLenses: 'লেন্স ব্যবস্থাপনা করুন', manageTags: 'ট্যাগ ব্যবস্থাপনা করুন', manageRecipes: 'রেসিপি ব্যবস্থাপনা করুন', - batchEdit: 'একসাথে ছবিগুলো এডিট করুন ...', - batchEditShort: 'ব্যাচ এডিট ...', - batchExitEdit: 'ব্যাচ এডিট থেকে বের হোন', + selectPhotos: 'ছবি নির্বাচন করুন ...', + selectPhotosExit: 'নির্বাচন বন্ধ করুন', appInsights: 'অ্যাপ ইনসাইট', appConfig: 'অ্যাপ কনফিগারেশন', edit: 'এডিট', diff --git a/src/i18n/locales/en-gb.ts b/src/i18n/locales/en-gb.ts index 06d33c3c..7454e25e 100644 --- a/src/i18n/locales/en-gb.ts +++ b/src/i18n/locales/en-gb.ts @@ -111,9 +111,8 @@ export const TEXT: I18N = { manageLenses: 'Manage Lenses', manageTags: 'Manage Tags', manageRecipes: 'Manage Recipes', - batchEdit: 'Batch Edit Photos ...', - batchEditShort: 'Batch Edit ...', - batchExitEdit: 'Exit Batch Edit', + selectPhotos: 'Select Photos ...', + selectPhotosExit: 'Stop Selecting', appInsights: 'App Insights', appConfig: 'App Configuration', edit: 'Edit', diff --git a/src/i18n/locales/en-us.ts b/src/i18n/locales/en-us.ts index c92ec7be..68d0dcc3 100644 --- a/src/i18n/locales/en-us.ts +++ b/src/i18n/locales/en-us.ts @@ -110,9 +110,8 @@ export const TEXT = { manageLenses: 'Manage Lenses', manageTags: 'Manage Tags', manageRecipes: 'Manage Recipes', - batchEdit: 'Batch Edit Photos ...', - batchEditShort: 'Batch Edit ...', - batchExitEdit: 'Exit Batch Edit', + selectPhotos: 'Select Photos ...', + selectPhotosExit: 'Stop Selecting', appInsights: 'App Insights', appConfig: 'App Configuration', edit: 'Edit', diff --git a/src/i18n/locales/id-id.ts b/src/i18n/locales/id-id.ts index 7fadeacb..e7a075b1 100644 --- a/src/i18n/locales/id-id.ts +++ b/src/i18n/locales/id-id.ts @@ -111,9 +111,8 @@ export const TEXT: I18N = { manageLenses: 'Kelola Lensa', manageTags: 'Kelola Tag', manageRecipes: 'Kelola Resep', - batchEdit: 'Edit Massal Foto', - batchEditShort: 'Edit Massal', - batchExitEdit: 'Keluar dari Edit Massal', + selectPhotos: 'Pilih Foto ...', + selectPhotosExit: 'Berhenti Memilih', appInsights: 'Wawasan Aplikasi', appConfig: 'Konfigurasi Aplikasi', edit: 'Edit', diff --git a/src/i18n/locales/pt-br.ts b/src/i18n/locales/pt-br.ts index 0e77f221..ae2fef32 100644 --- a/src/i18n/locales/pt-br.ts +++ b/src/i18n/locales/pt-br.ts @@ -111,9 +111,8 @@ export const TEXT: I18N = { manageLenses: 'Gerenciar lentes', manageTags: 'Gerenciar tags', manageRecipes: 'Gerenciar receitas', - batchEdit: 'Editar fotos em massa ...', - batchEditShort: 'Editar em massa ...', - batchExitEdit: 'Sair da edição em massa', + selectPhotos: 'Selecionar Fotos ...', + selectPhotosExit: 'Parar de Selecionar', appInsights: 'Insights do aplicativo', appConfig: 'Configuração da aplicação', edit: 'Editar', diff --git a/src/i18n/locales/pt-pt.ts b/src/i18n/locales/pt-pt.ts index 60f31dfc..28f982a1 100644 --- a/src/i18n/locales/pt-pt.ts +++ b/src/i18n/locales/pt-pt.ts @@ -111,9 +111,8 @@ export const TEXT: I18N = { manageLenses: 'Gerenciar objetivas', manageTags: 'Gerenciar etiquetas', manageRecipes: 'Gerenciar receitas', - batchEdit: 'Editar fotos em massa ...', - batchEditShort: 'Editar em massa ...', - batchExitEdit: 'Sair da edição em massa', + selectPhotos: 'Selecionar Fotografias ...', + selectPhotosExit: 'Parar de Selecionar', appInsights: 'Insights do aplicativo', appConfig: 'Configuração da aplicação', edit: 'Editar', diff --git a/src/i18n/locales/tr-tr.ts b/src/i18n/locales/tr-tr.ts index 9546cfce..b4164b37 100644 --- a/src/i18n/locales/tr-tr.ts +++ b/src/i18n/locales/tr-tr.ts @@ -111,9 +111,8 @@ export const TEXT: I18N = { manageLenses: 'Lensleri Yönet', manageTags: 'Etiketleri Yönet', manageRecipes: 'Tarifleri Yönet', - batchEdit: 'Toplu Fotoğraf Düzenleme ...', - batchEditShort: 'Toplu Düzenleme ...', - batchExitEdit: 'Toplu Düzenlemeyi Kapat', + selectPhotos: 'Fotoğrafları Seç ...', + selectPhotosExit: 'Seçmeyi Durdur', appInsights: 'Uygulama Analizi', appConfig: 'Uygulama Yapılandırması', edit: 'Düzenle', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index c6be195b..59ff9b6c 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -111,9 +111,8 @@ export const TEXT: I18N = { manageLenses: '管理镜头', manageTags: '管理标签', manageRecipes: '管理预设', - batchEdit: '批量编辑照片...', - batchEditShort: '批量编辑...', - batchExitEdit: '退出批量编辑', + selectPhotos: '选择照片...', + selectPhotosExit: '停止选择', appInsights: '应用分析', appConfig: '应用配置', edit: '编辑', From 57879706a262207efd45b43c18949a23f6963e08 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 7 Sep 2025 16:15:55 -0500 Subject: [PATCH 5/5] Scroll photo grid into view when selecting photos --- src/admin/select/SelectPhotosProvider.tsx | 23 ++++++++++++++----- src/utility/dom.ts | 27 +++++++++++++++++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/admin/select/SelectPhotosProvider.tsx b/src/admin/select/SelectPhotosProvider.tsx index 2a7c099b..ff608400 100644 --- a/src/admin/select/SelectPhotosProvider.tsx +++ b/src/admin/select/SelectPhotosProvider.tsx @@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation'; import { useAppState } from '@/app/AppState'; import useClientSearchParams from '@/utility/useClientSearchParams'; import { pushPathWithEvent } from '@/utility/url'; +import { isElementPartiallyInViewport } from '@/utility/dom'; export const DATA_KEY_PHOTO_GRID = 'data-photo-grid'; @@ -28,10 +29,14 @@ export default function SelectPhotosProvider({ const [isPerformingSelectEdit, setIsPerformingSelectEdit] = useState(false); + const getPhotoGridElements = useCallback(() => + document.querySelectorAll(`[${DATA_KEY_PHOTO_GRID}]`) + , []); + useEffect(() => { - setCanCurrentPageSelectPhotos(document - .querySelector(`[${DATA_KEY_PHOTO_GRID}]`) !== null); - }, [pathname]); + const doesPageHavePhotoGrids = getPhotoGridElements().length > 0; + setCanCurrentPageSelectPhotos(doesPageHavePhotoGrids); + }, [pathname, getPhotoGridElements]); const isSelectingPhotos = useMemo(() => isUserSignedIn && @@ -50,10 +55,18 @@ export default function SelectPhotosProvider({ , [pathname]); useEffect(() => { - if (!isSelectingPhotos) { + if (isSelectingPhotos) { + const photoGrids = Array.from(getPhotoGridElements()); + const isSomePhotoGridVisible = photoGrids + .some(element => isElementPartiallyInViewport(element, -20)); + if (!isSomePhotoGridVisible) { + console.log('scrolling to photo grid'); + photoGrids[0]?.scrollIntoView({ behavior: 'smooth' }); + } + } else { setSelectedPhotoIds([]); } - }, [isSelectingPhotos]); + }, [isSelectingPhotos, getPhotoGridElements]); return ( { if (element) { const rect = element.getBoundingClientRect(); @@ -11,7 +11,8 @@ export const isElementEntirelyInViewport = ( document.documentElement.clientHeight ) && rect.right <= ( - window.innerWidth || document.documentElement.clientWidth + window.innerWidth || + document.documentElement.clientWidth ) ); } else { @@ -19,6 +20,28 @@ export const isElementEntirelyInViewport = ( } }; +export function isElementPartiallyInViewport( + element?: Element | null, + // Expand the viewport by `offset` pixels (negative offset = stricter) + offset = 0, +): boolean { + if (element) { + const rect = element.getBoundingClientRect(); + + const vh = window.innerHeight || document.documentElement.clientHeight; + const vw = window.innerWidth || document.documentElement.clientWidth; + + const topVisible = rect.bottom >= -offset; + const leftVisible = rect.right >= -offset; + const bottomVisible = rect.top <= vh + offset; + const rightVisible = rect.left <= vw + offset; + + return topVisible && leftVisible && bottomVisible && rightVisible; + } else { + return false; + } +} + export const clearGlobalFocus = () => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur();