Merge pull request #306 from sambecker/batch-edit-update
Finalize anywhere batch editing
This commit is contained in:
commit
71434c780b
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -50,12 +50,14 @@
|
||||
"parameterizes",
|
||||
"presigner",
|
||||
"Provia",
|
||||
"pushstate",
|
||||
"qaub",
|
||||
"QRSTUVWXYZ",
|
||||
"ratelimit",
|
||||
"ratelimiter",
|
||||
"Reala",
|
||||
"recents",
|
||||
"replacestate",
|
||||
"skippable",
|
||||
"sonner",
|
||||
"sslmode",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
85
src/admin/select/SelectPhotosProvider.tsx
Normal file
85
src/admin/select/SelectPhotosProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/admin/select/SelectPhotosState.ts
Normal file
16
src/admin/select/SelectPhotosState.ts
Normal 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);
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = '-';
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -111,9 +111,8 @@ export const TEXT: I18N = {
|
||||
manageLenses: 'লেন্স ব্যবস্থাপনা করুন',
|
||||
manageTags: 'ট্যাগ ব্যবস্থাপনা করুন',
|
||||
manageRecipes: 'রেসিপি ব্যবস্থাপনা করুন',
|
||||
batchEdit: 'একসাথে ছবিগুলো এডিট করুন ...',
|
||||
batchEditShort: 'ব্যাচ এডিট ...',
|
||||
batchExitEdit: 'ব্যাচ এডিট থেকে বের হোন',
|
||||
selectPhotos: 'ছবি নির্বাচন করুন ...',
|
||||
selectPhotosExit: 'নির্বাচন বন্ধ করুন',
|
||||
appInsights: 'অ্যাপ ইনসাইট',
|
||||
appConfig: 'অ্যাপ কনফিগারেশন',
|
||||
edit: 'এডিট',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -111,9 +111,8 @@ export const TEXT: I18N = {
|
||||
manageLenses: '管理镜头',
|
||||
manageTags: '管理标签',
|
||||
manageRecipes: '管理预设',
|
||||
batchEdit: '批量编辑照片...',
|
||||
batchEditShort: '批量编辑...',
|
||||
batchExitEdit: '退出批量编辑',
|
||||
selectPhotos: '选择照片...',
|
||||
selectPhotosExit: '停止选择',
|
||||
appInsights: '应用分析',
|
||||
appConfig: '应用配置',
|
||||
edit: '编辑',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -60,7 +60,6 @@ export default function PhotoGridPageClient({
|
||||
}} />
|
||||
</MaskedScroll>
|
||||
}
|
||||
canSelect
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'));
|
||||
};
|
||||
|
||||
24
src/utility/useClientSearchParams.ts
Normal file
24
src/utility/useClientSearchParams.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user