From 1f499e697e532ef9672ca9cd1e62f1c388089c94 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 6 Sep 2025 23:49:10 -0500 Subject: [PATCH] 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; +};