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 799cef2b..1b0a9232 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,32 +41,27 @@ 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(); - useEffect(() => { - if (pathname !== PATH_GRID_INFERRED) { - setSelectedPhotoIds?.(undefined); - } - }, [pathname, setSelectedPhotoIds]); + const { + canCurrentPageSelectPhotos, + isSelectingPhotos, + startSelectingPhotos, + stopSelectingPhotos, + } = useSelectPhotosState(); const appText = useAppText(); - const isSelecting = selectedPhotoIds !== undefined; - const isAltPressed = useIsKeyBeingPressed('alt'); const showAppInsightsLink = photosCountTotal > 0 && !isAltPressed; @@ -153,10 +148,10 @@ export default function AdminAppMenu({ } if (photosCountTotal) { items.push({ - label: isSelecting - ? appText.admin.batchExitEdit - : appText.admin.batchEditShort, - icon: isSelecting + label: isSelectingPhotos + ? appText.admin.selectPhotosExit + : appText.admin.selectPhotos, + icon: isSelectingPhotos ? , - ...pathname !== PATH_GRID_INFERRED && { - href: PATH_GRID_INFERRED, - }, - action: () => { - if (isSelecting) { - setSelectedPhotoIds?.(undefined); - } else { - setSelectedPhotoIds?.([]); - } + ...!canCurrentPageSelectPhotos && { + href: `${PATH_GRID_INFERRED}?batch=true`, }, + action: isSelectingPhotos + ? stopSelectingPhotos + : startSelectingPhotos, }); } items.push({ @@ -192,13 +183,14 @@ export default function AdminAppMenu({ return { items }; }, [ - pathname, 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 87% rename from src/admin/AdminBatchEditPanelClient.tsx rename to src/admin/select/AdminBatchEditPanelClient.tsx index c8894049..24f62bb1 100644 --- a/src/admin/AdminBatchEditPanelClient.tsx +++ b/src/admin/select/AdminBatchEditPanelClient.tsx @@ -3,23 +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 { usePathname } from 'next/navigation'; -import { PATH_GRID_INFERRED } from '@/app/path'; -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, @@ -28,15 +26,14 @@ export default function AdminBatchEditPanelClient({ }) { const refNote = useRef(null); - const pathname = usePathname(); - const { - isUserSignedIn, + canCurrentPageSelectPhotos, + isSelectingPhotos, + stopSelectingPhotos, selectedPhotoIds, - setSelectedPhotoIds, isPerformingSelectEdit, setIsPerformingSelectEdit, - } = useAppState(); + } = useSelectPhotosState(); const appText = useAppText(); @@ -44,12 +41,6 @@ export default function AdminBatchEditPanelClient({ const [tagErrorMessage, setTagErrorMessage] = useState(''); const isInTagMode = tags !== undefined; - const resetForm = () => { - setSelectedPhotoIds?.(undefined); - setTags(undefined); - setTagErrorMessage(''); - }; - const photosText = photoQuantityText( selectedPhotoIds?.length ?? 0, appText, @@ -101,7 +92,7 @@ export default function AdminBatchEditPanelClient({ ) .then(() => { toastSuccess(`${photosText} tagged`); - resetForm(); + stopSelectingPhotos?.(); }) .finally(() => setIsPerformingSelectEdit?.(false)); }} @@ -121,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)); }} @@ -150,21 +141,20 @@ export default function AdminBatchEditPanelClient({ } - onClick={() => setSelectedPhotoIds?.(undefined)} + onClick={stopSelectingPhotos} /> ; const shouldShowPanel = - isUserSignedIn && - pathname === PATH_GRID_INFERRED && - selectedPhotoIds !== undefined; + isSelectingPhotos && + canCurrentPageSelectPhotos; useEffect(() => { // Steal focus from Admin Menu to hide tooltip - if (shouldShowPanel) { + if (isSelectingPhotos) { refNote.current?.focus(); } - }, [shouldShowPanel]); + }, [isSelectingPhotos]); return shouldShowPanel ? ([]); + const [isPerformingSelectEdit, setIsPerformingSelectEdit] = + useState(false); + + const getPhotoGridElements = useCallback(() => + document.querySelectorAll(`[${DATA_KEY_PHOTO_GRID}]`) + , []); + + useEffect(() => { + const doesPageHavePhotoGrids = getPhotoGridElements().length > 0; + setCanCurrentPageSelectPhotos(doesPageHavePhotoGrids); + }, [pathname, getPhotoGridElements]); + + const isSelectingPhotos = useMemo(() => + isUserSignedIn && + searchParamsSelect === 'true' + , [isUserSignedIn, searchParamsSelect]); + + 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(() => + pushPathWithEvent(pathname) + , [pathname]); + + useEffect(() => { + 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, getPhotoGridElements]); + + 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 d79fe6ca..e35ff653 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,12 @@ export default function CommandKClient({ setShouldDebugRecipeOverlays, } = useAppState(); + const { + isSelectingPhotos, + startSelectingPhotos, + stopSelectingPhotos, + } = useSelectPhotosState(); + const { doesPathOfferSort, isSortedByDefault, @@ -640,16 +645,19 @@ export default function CommandKClient({ }); } adminSection.items.push({ - label: selectedPhotoIds === undefined - ? appText.admin.batchEdit - : appText.admin.batchExitEdit, + label: isSelectingPhotos + ? appText.admin.selectPhotosExit + : appText.admin.selectPhotos, annotation: , - path: selectedPhotoIds === undefined - ? PATH_GRID_INFERRED - : undefined, - action: selectedPhotoIds === undefined - ? () => setSelectedPhotoIds?.([]) - : () => setSelectedPhotoIds?.(undefined), + // Search by legacy label + keywords: ['batch', 'edit'], + action: () => { + if (!isSelectingPhotos) { + startSelectingPhotos?.(); + } else { + stopSelectingPhotos?.(); + } + }, }, { label: {appText.admin.appInsights} 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 onAnimationComplete?: () => void } & PhotoSetCategory) { const { - isUserSignedIn, - selectedPhotoIds, - setSelectedPhotoIds, isGridHighDensity, } = useAppState(); + const { + isSelectingPhotos, + selectedPhotoIds, + setSelectedPhotoIds, + } = useSelectPhotosState(); + 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, + }} + /> + {isSelectingPhotos && + 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 /> ); } diff --git a/src/utility/dom.ts b/src/utility/dom.ts index aa778139..398d6cdc 100644 --- a/src/utility/dom.ts +++ b/src/utility/dom.ts @@ -1,5 +1,5 @@ export const isElementEntirelyInViewport = ( - element?: HTMLElement | null, + element?: Element | null, ) => { 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(); 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')); +}; 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; +};