Move batch editing to pure url-based state

This commit is contained in:
Sam Becker 2025-09-06 23:49:10 -05:00
parent 91f99508f7
commit 1f499e697e
14 changed files with 224 additions and 133 deletions

View File

@ -50,12 +50,14 @@
"parameterizes",
"presigner",
"Provia",
"pushstate",
"qaub",
"QRSTUVWXYZ",
"ratelimit",
"ratelimiter",
"Reala",
"recents",
"replacestate",
"skippable",
"sonner",
"sslmode",

View File

@ -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({
)}>
<AppStateProvider areAdminDebugToolsEnabled={ADMIN_DEBUG_TOOLS_ENABLED}>
<AppTextProvider>
<ThemeColors />
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
<SwrConfigClient>
<SharedHoverProvider>
<div className={clsx(
'mx-3 mb-3',
'lg:mx-6 lg:mb-6',
)}>
<Nav />
<main>
<ShareModals />
<RecipeModal />
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',
'space-y-5',
)}>
<AdminUploadPanel
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
onLastUpload={async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
}}
/>
<AdminBatchEditPanel
onBatchActionComplete={async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
}}
/>
{children}
</div>
</main>
<Footer />
</div>
<CommandK />
</SharedHoverProvider>
</SwrConfigClient>
<Analytics debug={false} />
<SpeedInsights debug={false} />
<PhotoEscapeHandler />
<ToasterWithThemes />
</ThemeProvider>
<SelectPhotosProvider>
<ThemeColors />
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
<SwrConfigClient>
<SharedHoverProvider>
<div className={clsx(
'mx-3 mb-3',
'lg:mx-6 lg:mb-6',
)}>
<Nav />
<main>
<ShareModals />
<RecipeModal />
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',
'space-y-5',
)}>
<AdminUploadPanel
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
onLastUpload={async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
}}
/>
<AdminBatchEditPanel
onBatchActionComplete={async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
}}
/>
{children}
</div>
</main>
<Footer />
</div>
<CommandK />
</SharedHoverProvider>
</SwrConfigClient>
<Analytics debug={false} />
<SpeedInsights debug={false} />
<PhotoEscapeHandler />
<ToasterWithThemes />
</ThemeProvider>
</SelectPhotosProvider>
</AppTextProvider>
</AppStateProvider>
</body>

View File

@ -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: <IconUpload
@ -157,10 +148,10 @@ export default function AdminAppMenu({
}
if (photosCountTotal) {
items.push({
label: isSelecting
label: isSelectingPhotos
? appText.admin.batchExitEdit
: appText.admin.batchEditShort,
icon: isSelecting
icon: isSelectingPhotos
? <FiXSquare
size={15}
className="translate-x-[-0.75px] translate-y-[0.5px]"
@ -169,16 +160,12 @@ export default function AdminAppMenu({
size={16}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>,
...!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,

View File

@ -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<HTMLDivElement>(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({
</LoaderButton>
<LoaderButton
icon={<IoCloseSharp size={19} />}
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
? <AppGrid

View File

@ -0,0 +1,76 @@
'use client';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { SelectPhotosContext } from './SelectPhotosState';
import { PARAM_SELECT, PATH_GRID_INFERRED } from '@/app/path';
import { usePathname } from 'next/navigation';
import { useAppState } from '@/app/AppState';
import useClientSearchParams from '@/utility/useClientSearchParams';
export const DATA_KEY_PHOTO_GRID = 'data-photo-grid';
export default function SelectPhotosProvider({
children,
}: {
children: ReactNode
}) {
const pathname = usePathname();
const { isUserSignedIn } = useAppState();
const searchParamsSelect = useClientSearchParams(PARAM_SELECT);
const [canCurrentPageSelectPhotos, setCanCurrentPageSelectPhotos] =
useState(true);
useEffect(() => {
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<string[]>([]);
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
useState(false);
return (
<SelectPhotosContext.Provider value={{
canCurrentPageSelectPhotos,
isSelectingPhotos,
startSelectingPhotos,
stopSelectingPhotos,
selectedPhotoIds,
setSelectedPhotoIds,
isPerformingSelectEdit,
setIsPerformingSelectEdit,
}}>
{children}
</SelectPhotosContext.Provider>
);
}

View File

@ -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<SetStateAction<boolean>>
};
export const SelectPhotosContext = createContext<SelectPhotosState>({});
export const useSelectPhotosState = () => use(SelectPhotosContext);

View File

@ -53,10 +53,6 @@ export type AppStateContextType = {
isLoadingAdminData?: boolean
refreshAdminData?: () => void
updateAdminData?: (updatedData: Partial<AdminData>) => void
selectedPhotoIds?: string[]
setSelectedPhotoIds?: Dispatch<SetStateAction<string[] | undefined>>
isPerformingSelectEdit?: boolean
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>
insightsIndicatorStatus?: InsightsIndicatorStatus
// UPLOAD
startUpload?: () => Promise<boolean>

View File

@ -93,13 +93,11 @@ export default function AppStateProvider({
useState<string>();
const [userEmailEager, setUserEmailEager] =
useState<string>();
const isUserSignedIn = Boolean(userEmail);
const isUserSignedInEager = Boolean(userEmailEager);
// ADMIN
const [adminUpdateTimes, setAdminUpdateTimes] =
useState<Date[]>([]);
const [selectedPhotoIds, setSelectedPhotoIds] =
useState<string[] | undefined>();
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
useState(false);
// UPLOAD
const uploadInputRef = useRef<HTMLInputElement>(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,

View File

@ -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 = '-';

View File

@ -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: <IconLock narrow />,
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?.();
}
},
}, {

View File

@ -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 (
<div className={clsx(

View File

@ -8,9 +8,10 @@ import AnimateItems from '@/components/AnimateItems';
import { GRID_ASPECT_RATIO } from '@/app/config';
import { useAppState } from '@/app/AppState';
import SelectTileOverlay from '@/components/SelectTileOverlay';
import { ReactNode, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ReactNode } from 'react';
import { GRID_GAP_CLASSNAME } from '@/components';
import { useSelectPhotosState } from '@/admin/select/SelectPhotosState';
import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider';
export default function PhotoGrid({
photos,
@ -39,28 +40,17 @@ export default function PhotoGrid({
onAnimationComplete?: () => 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 (
<div data-photo-grid>
<div {...{ [DATA_KEY_PHOTO_GRID]: true }}>
<AnimateItems
className={clsx(
'grid',
@ -110,7 +100,7 @@ export default function PhotoGrid({
: undefined,
}}
/>
{isUserSignedIn && selectedPhotoIds !== undefined &&
{isSelectingPhotos &&
<SelectTileOverlay
isSelected={isSelected}
onSelectChange={() => setSelectedPhotoIds?.(isSelected

View File

@ -0,0 +1,24 @@
import { useCallback, useEffect, useState } from 'react';
export default function useClientSearchParams(
paramKey: string,
): string | undefined {
const [paramValue, setParamValue] = useState<string>();
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;
};