Merge pull request #306 from sambecker/batch-edit-update

Finalize anywhere batch editing
This commit is contained in:
Sam Becker 2025-09-07 16:25:46 -05:00 committed by GitHub
commit 71434c780b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 351 additions and 223 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,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
? <FiXSquare
size={15}
className="translate-x-[-0.75px] translate-y-[0.5px]"
@ -165,16 +160,12 @@ export default function AdminAppMenu({
size={16}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>,
...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,

View File

@ -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<HTMLDivElement>(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)}
/>
<LoaderButton
@ -136,7 +127,7 @@ export default function AdminBatchEditPanelClient({
)
.then(() => {
toastSuccess(`${photosText} favorited`);
resetForm();
stopSelectingPhotos?.();
})
.finally(() => setIsPerformingSelectEdit?.(false));
}}
@ -150,21 +141,20 @@ export default function AdminBatchEditPanelClient({
</LoaderButton>
<LoaderButton
icon={<IoCloseSharp size={19} />}
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
? <AppGrid

View File

@ -0,0 +1,85 @@
'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';
import { pushPathWithEvent } from '@/utility/url';
import { isElementPartiallyInViewport } from '@/utility/dom';
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(false);
const [selectedPhotoIds, setSelectedPhotoIds] =
useState<string[]>([]);
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 (
<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,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: <IconLock narrow />,
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: <span className="flex items-center gap-3">
{appText.admin.appInsights}

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

@ -111,9 +111,8 @@ export const TEXT: I18N = {
manageLenses: 'লেন্স ব্যবস্থাপনা করুন',
manageTags: 'ট্যাগ ব্যবস্থাপনা করুন',
manageRecipes: 'রেসিপি ব্যবস্থাপনা করুন',
batchEdit: 'একসাথে ছবিগুলো এডিট করুন ...',
batchEditShort: 'ব্যাচ এডিট ...',
batchExitEdit: 'ব্যাচ এডিট থেকে বের হোন',
selectPhotos: 'ছবি নির্বাচন করুন ...',
selectPhotosExit: 'নির্বাচন বন্ধ করুন',
appInsights: 'অ্যাপ ইনসাইট',
appConfig: 'অ্যাপ কনফিগারেশন',
edit: 'এডিট',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -111,9 +111,8 @@ export const TEXT: I18N = {
manageLenses: '管理镜头',
manageTags: '管理标签',
manageRecipes: '管理预设',
batchEdit: '批量编辑照片...',
batchEditShort: '批量编辑...',
batchExitEdit: '退出批量编辑',
selectPhotos: '选择照片...',
selectPhotosExit: '停止选择',
appInsights: '应用分析',
appConfig: '应用配置',
edit: '编辑',

View File

@ -10,6 +10,8 @@ import { useAppState } from '@/app/AppState';
import SelectTileOverlay from '@/components/SelectTileOverlay';
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,
@ -21,7 +23,6 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly = true,
additionalTile,
small,
canSelect,
onLastPhotoVisible,
onAnimationComplete,
...categories
@ -35,79 +36,83 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly?: boolean
additionalTile?: ReactNode
small?: boolean
canSelect?: boolean
onLastPhotoVisible?: () => void
onAnimationComplete?: () => void
} & PhotoSetCategory) {
const {
isUserSignedIn,
selectedPhotoIds,
setSelectedPhotoIds,
isGridHighDensity,
} = useAppState();
const {
isSelectingPhotos,
selectedPhotoIds,
setSelectedPhotoIds,
} = useSelectPhotosState();
return (
<AnimateItems
className={clsx(
'grid',
GRID_GAP_CLASSNAME,
small
? 'grid-cols-3 xs:grid-cols-6'
: isGridHighDensity
? 'grid-cols-2 xs:grid-cols-4 lg:grid-cols-6'
: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center',
)}
type={animate === false ? 'none' : undefined}
canStart={canStart}
duration={0.7}
staggerDelay={0.04}
distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationComplete={onAnimationComplete}
items={photos.map((photo, index) =>{
const isSelected = selectedPhotoIds?.includes(photo.id) ?? false;
return <div
key={photo.id}
className={clsx(
'flex relative overflow-hidden',
'group',
)}
style={{
...GRID_ASPECT_RATIO !== 0 && {
aspectRatio: GRID_ASPECT_RATIO,
},
}}
>
<PhotoMedium
<div {...{ [DATA_KEY_PHOTO_GRID]: true }}>
<AnimateItems
className={clsx(
'grid',
GRID_GAP_CLASSNAME,
small
? 'grid-cols-3 xs:grid-cols-6'
: isGridHighDensity
? 'grid-cols-2 xs:grid-cols-4 lg:grid-cols-6'
: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center',
)}
type={animate === false ? 'none' : undefined}
canStart={canStart}
duration={0.7}
staggerDelay={0.04}
distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationComplete={onAnimationComplete}
items={photos.map((photo, index) => {
const isSelected = selectedPhotoIds?.includes(photo.id) ?? false;
return <div
key={photo.id}
className={clsx(
'flex w-full h-full',
// Prevent photo navigation when selecting
selectedPhotoIds?.length !== undefined && 'pointer-events-none',
'flex relative overflow-hidden',
'group',
)}
{...{
photo,
...categories,
selected: photo.id === selectedPhoto?.id,
priority: prioritizeInitialPhotos ? index < 6 : undefined,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
style={{
...GRID_ASPECT_RATIO !== 0 && {
aspectRatio: GRID_ASPECT_RATIO,
},
}}
/>
{isUserSignedIn && canSelect && selectedPhotoIds !== undefined &&
<SelectTileOverlay
isSelected={isSelected}
onSelectChange={() => setSelectedPhotoIds?.(isSelected
? (selectedPhotoIds ?? []).filter(id => id !== photo.id)
: (selectedPhotoIds ?? []).concat(photo.id),
>
<PhotoMedium
className={clsx(
'flex w-full h-full',
// Prevent photo navigation when selecting
isSelectingPhotos && 'pointer-events-none',
)}
/>}
</div>;
}).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 &&
<SelectTileOverlay
isSelected={isSelected}
onSelectChange={() => setSelectedPhotoIds?.(isSelected
? (selectedPhotoIds ?? []).filter(id => id !== photo.id)
: (selectedPhotoIds ?? []).concat(photo.id),
)}
/>}
</div>;
}).concat(additionalTile ? <>{additionalTile}</> : [])}
itemKeys={photos.map(photo => photo.id)
.concat(additionalTile ? ['more'] : [])}
/>
</div>
);
};

View File

@ -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 &&
<PhotoGridInfinite {...{
@ -67,7 +64,6 @@ export default function PhotoGridContainer({
...categories,
canStart: shouldAnimateDynamicItems,
animateOnFirstLoadOnly,
canSelect,
}} />}
</div>
</div>}

View File

@ -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,
}} />}
</InfinitePhotoScroll>
);

View File

@ -60,7 +60,6 @@ export default function PhotoGridPageClient({
}} />
</MaskedScroll>
}
canSelect
/>
);
}

View File

@ -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();

View File

@ -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'));
};

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;
};