Select All Photos (1 of 2) (#372)

* Add 'select all photos' to app state

* Create general purpose bulk photo action

* Fix infinite scroll pagination, temporarily hide "select all"

* Refine batch edit behavior

* Add admin endpoints to check storage

* Add missing storage count

* Refine missing file presentation

* Finalize storage status page

* Store image-dependent photo fields when reuploading

* Move storage checks behind flag
This commit is contained in:
Sam Becker 2026-02-12 22:28:37 -06:00 committed by GitHub
parent b664b8b203
commit a63f2c3fe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 885 additions and 563 deletions

View File

@ -0,0 +1,19 @@
import AdminInfoPage from '@/admin/AdminInfoPage';
import AdminStorageTable from '@/admin/storage/AdminStorageTable';
import { ADMIN_STORAGE_DEBUG_ENABLED } from '@/app/config';
import EnvVar from '@/components/EnvVar';
export default function AdminStoragePage() {
return <AdminInfoPage>
{ADMIN_STORAGE_DEBUG_ENABLED
? <AdminStorageTable />
: <div>
Set
{' '}
<EnvVar variable="ADMIN_STORAGE_DEBUG" />
{' '}
to {'"1"'} to enable
storage checks
</div>}
</AdminInfoPage>;
}

View File

@ -16,7 +16,7 @@ import {
} from '@/app/path';
import { isTagFavs } from '@/tag';
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config';
import { getPhotoIdsAndUpdatedAt } from '@/photo/query';
import { getAllPhotoIdsWithUpdatedAt } from '@/photo/query';
// Cache for 24 hours
export const revalidate = 86_400;
@ -53,7 +53,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
films: [],
focalLengths: [],
})),
getPhotoIdsAndUpdatedAt().catch(() => []),
getAllPhotoIdsWithUpdatedAt().catch(() => []),
]);
const lastModifiedSite = [

View File

@ -8,12 +8,12 @@
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build --webpack"
},
"packageManager": "pnpm@10.29.2",
"packageManager": "pnpm@10.29.3",
"dependencies": {
"@ai-sdk/openai": "^3.0.26",
"@ai-sdk/rsc": "^2.0.78",
"@aws-sdk/client-s3": "3.987.0",
"@aws-sdk/s3-request-presigner": "3.987.0",
"@ai-sdk/openai": "^3.0.28",
"@ai-sdk/rsc": "^2.0.85",
"@aws-sdk/client-s3": "3.989.0",
"@aws-sdk/s3-request-presigner": "3.989.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
@ -23,7 +23,7 @@
"@vercel/analytics": "^1.6.1",
"@vercel/blob": "^2.2.0",
"@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.78",
"ai": "^6.0.85",
"camelcase-keys": "^10.0.2",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -39,7 +39,7 @@
"next": "16.1.6",
"next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6",
"ol": "^10.7.0",
"ol": "^10.8.0",
"pg": "^8.18.0",
"piexifjs": "^1.0.6",
"react": "19.2.4",
@ -69,7 +69,7 @@
"@types/node": "^25.2.3",
"@types/pg": "^8.16.0",
"@types/piexifjs": "^1.0.0",
"@types/react": "19.2.13",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/sanitize-html": "^2.16.0",
"baseline-browser-mapping": "^2.9.19",

909
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
import LoaderButton from '@/components/primitives/LoaderButton';
import { photoQuantityText } from '@/photo';
import { deletePhotosAction } from '@/photo/actions';
import { batchPhotoAction } from '@/photo/actions';
import { useAppState } from '@/app/AppState';
import { toastSuccess, toastWarning } from '@/toast';
import { ComponentProps, useState } from 'react';
@ -43,7 +43,10 @@ export default function DeletePhotosButton({
onClick={() => {
onClick?.();
setIsLoading(true);
deletePhotosAction(photoIds)
batchPhotoAction({
photoIds,
action: 'delete',
})
.then(() => {
toastSuccess(toastText ?? `${photosText} deleted`);
if (clearLocalState) {

View File

@ -140,6 +140,7 @@ export default function AdminAppConfigurationClient({
areInternalToolsEnabled,
areAdminDebugToolsEnabled,
isAdminSqlDebugEnabled,
isAdminStorageDebugEnabled,
// Auth
secret,
// Connection status
@ -1018,6 +1019,15 @@ export default function AdminAppConfigurationClient({
console output for all sql queries:
{renderEnvVars(['ADMIN_SQL_DEBUG'])}
</ChecklistRow>
<ChecklistRow
title="Storage debugging"
status={isAdminStorageDebugEnabled}
optional
>
Set environment variable to {'"1"'} to enable
storage debugging:
{renderEnvVars(['ADMIN_STORAGE_DEBUG'])}
</ChecklistRow>
</>;
}
};

View File

@ -6,9 +6,9 @@ import AppGrid from '@/components/AppGrid';
import { clsx } from 'clsx/lite';
import { IoCloseSharp } from 'react-icons/io5';
import { useEffect, useRef, useState } from 'react';
import { TAG_FAVS, Tags } from '@/tag';
import { Tags } from '@/tag';
import FieldsetTag from '@/tag/FieldsetTag';
import { tagMultiplePhotosAction } from '@/photo/actions';
import { batchPhotoAction } from '@/photo/actions';
import { toastSuccess } from '@/toast';
import DeletePhotosButton from '@/admin/DeletePhotosButton';
import { photoQuantityText } from '@/photo';
@ -21,14 +21,17 @@ import { useSelectPhotosState } from './SelectPhotosState';
import { Albums } from '@/album';
import FieldsetAlbum from '@/album/FieldsetAlbum';
import IconAlbum from '@/components/icons/IconAlbum';
import { addPhotosToAlbumsAction } from '@/album/actions';
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import { convertStringToArray } from '@/utility/string';
export default function AdminBatchEditPanelClient({
uniqueAlbums,
uniqueTags,
showSelectAll,
}: {
uniqueAlbums: Albums
uniqueTags: Tags
showSelectAll?: boolean
}) {
const refNote = useRef<HTMLDivElement>(null);
@ -36,6 +39,8 @@ export default function AdminBatchEditPanelClient({
canCurrentPageSelectPhotos,
isSelectingPhotos,
stopSelectingPhotos,
isSelectingAllPhotos,
toggleIsSelectingAllPhotos,
selectedPhotoIds,
isPerformingSelectEdit,
setIsPerformingSelectEdit,
@ -98,20 +103,20 @@ export default function AdminBatchEditPanelClient({
onClick={() => {
setIsPerformingSelectEdit?.(true);
if (isInTagMode) {
tagMultiplePhotosAction(
tags,
selectedPhotoIds ?? [],
)
batchPhotoAction({
photoIds: selectedPhotoIds,
tags: convertStringToArray(tags, false),
})
.then(() => {
toastSuccess(`${photosText} tagged`);
stopSelectingPhotos?.();
})
.finally(() => setIsPerformingSelectEdit?.(false));
} else if (isInAlbumMode) {
addPhotosToAlbumsAction(
selectedPhotoIds ?? [],
albumTitles.split(','),
)
batchPhotoAction({
photoIds: selectedPhotoIds,
albumTitles: albumTitles.split(','),
})
.then(() => {
toastSuccess(`${photosText} added`);
stopSelectingPhotos?.();
@ -146,10 +151,10 @@ export default function AdminBatchEditPanelClient({
confirmText={`Are you sure you want to favorite ${photosText}?`}
onClick={() => {
setIsPerformingSelectEdit?.(true);
tagMultiplePhotosAction(
TAG_FAVS,
selectedPhotoIds ?? [],
)
batchPhotoAction({
photoIds: selectedPhotoIds,
action: 'favorite',
})
.then(() => {
toastSuccess(`${photosText} favorited`);
stopSelectingPhotos?.();
@ -230,8 +235,17 @@ export default function AdminBatchEditPanelClient({
openOnLoad
hideLabel
/>
: <div className="text-base flex gap-2 items-center">
: <div>
<div className="text-base flex gap-2 items-center">
{renderPhotoCTA}
</div>
{showSelectAll &&
<FieldsetWithStatus
label="Select All Photos"
type="checkbox"
value={isSelectingAllPhotos ? 'true' : 'false'}
onChange={toggleIsSelectingAllPhotos}
/>}
</div>}
</Note>
{tagErrorMessage &&

View File

@ -32,6 +32,8 @@ export default function SelectPhotosProvider({
useState(false);
const [selectedPhotoIds, setSelectedPhotoIds] =
useState<string[]>([]);
const [isSelectingAllPhotos, setIsSelectingAllPhotos] =
useState(false);
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
useState(false);
@ -64,6 +66,22 @@ export default function SelectPhotosProvider({
replacePathWithEvent(pathname)
, [pathname]);
const togglePhotoSelection = useCallback((photoId: string) => {
if (isSelectingAllPhotos) {
setSelectedPhotoIds([photoId]);
setIsSelectingAllPhotos(false);
} else {
setSelectedPhotoIds(selectedPhotoIds.includes(photoId)
? (selectedPhotoIds ?? []).filter(id => id !== photoId)
: (selectedPhotoIds ?? []).concat(photoId));
}
}, [isSelectingAllPhotos, selectedPhotoIds]);
const toggleIsSelectingAllPhotos = useCallback(() => {
setIsSelectingAllPhotos(prev => !prev);
setSelectedPhotoIds([]);
}, []);
useEffect(() => {
if (isSelectingPhotos) {
const photoGrids = Array.from(getPhotoGridElements());
@ -75,6 +93,7 @@ export default function SelectPhotosProvider({
} else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedPhotoIds([]);
setIsSelectingAllPhotos(false);
}
}, [isSelectingPhotos, getPhotoGridElements]);
@ -82,10 +101,12 @@ export default function SelectPhotosProvider({
<SelectPhotosContext.Provider value={{
canCurrentPageSelectPhotos,
isSelectingPhotos,
isSelectingAllPhotos,
toggleIsSelectingAllPhotos,
startSelectingPhotos,
stopSelectingPhotos,
selectedPhotoIds,
setSelectedPhotoIds,
togglePhotoSelection,
isPerformingSelectEdit,
setIsPerformingSelectEdit,
}}>

View File

@ -2,11 +2,13 @@ import { createContext, Dispatch, SetStateAction, use } from 'react';
export type SelectPhotosState = {
canCurrentPageSelectPhotos?: boolean
isSelectingPhotos?: boolean;
isSelectingPhotos?: boolean
isSelectingAllPhotos?: boolean
toggleIsSelectingAllPhotos?: () => void
startSelectingPhotos?: () => void
stopSelectingPhotos?: () => void
selectedPhotoIds?: string[]
setSelectedPhotoIds?: (photoIds: string[]) => void
togglePhotoSelection?: (photoId: string) => void
isPerformingSelectEdit?: boolean
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>
};

View File

@ -0,0 +1,36 @@
'use client';
import { Photo } from '@/photo';
import { getOptimizedUrlsFromPhotoUrl } from '@/photo/storage';
import useVisibility from '@/utility/useVisibility';
import { useCallback, useRef, useState } from 'react';
export default function AdminPhotoStorageCheck({
photo,
}: {
photo: Photo
}) {
const ref = useRef<HTMLDivElement>(null);
const url = getOptimizedUrlsFromPhotoUrl(photo.url)[0];
const [hasImage, setHasImage] = useState<boolean>();
const onVisible = useCallback(() => {
if (hasImage === undefined) {
fetch(url)
.then(res => setHasImage(res.ok))
.catch(() => setHasImage(false));
}
}, [url, hasImage]);
useVisibility({ ref, onVisible });
return <div ref={ref}>
{hasImage === undefined
? 'Checking ...'
: hasImage === false
? '❌'
: '✅'}
</div>;
}

View File

@ -0,0 +1,58 @@
import { pathForPhoto } from '@/app/path';
import LinkWithStatus from '@/components/LinkWithStatus';
import { Photo } from '@/photo';
import { getPhotoUrls } from '@/photo/query';
import { getStorageUrlsForPhoto } from '@/photo/storage';
export default async function AdminStoragePage() {
const _urls = await getPhotoUrls({ limit: 1000, hidden: 'include' });
const urls = await Promise.all(_urls.map(async ({ url, ...partialPhoto }) => {
const urlSet = await getStorageUrlsForPhoto({ url } as Photo);
const status = urlSet.length === 4
? 'complete'
: urlSet.length === 0
? 'missing'
: 'partial';
return { ...partialPhoto, status };
}));
const countComplete = urls
.filter(({ status }) => status === 'complete').length;
const countPartial = urls
.filter(({ status }) => status === 'partial').length;
const countMissing = urls
.filter(({ status }) => status === 'missing').length;
return (
<div className="w-full space-y-4">
<div className="font-bold">
Storage ({countComplete + countPartial}/{urls.length})
</div>
<div>
<div> {countComplete.toString().padStart(3, '0')} Complete</div>
<div> {countPartial.toString().padStart(3, '0')} Partial</div>
<div> {countMissing.toString().padStart(3, '0')} Missing</div>
</div>
<div>
{urls.map(({ id, title, hidden, status }) => (
<div
key={id}
>
<LinkWithStatus
href={pathForPhoto({ photo: { id, hidden } as Photo })}
className="w-full inline-flex items-center gap-1"
>
<span className="w-[15rem] inline-block truncate">{title}</span>
{status === 'complete'
? '✅'
: status === 'partial'
? '⚠️'
: '❌'}
</LinkWithStatus>
</div>
))}
</div>
</div>
);
}

View File

@ -1,13 +1,12 @@
'use server';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { addPhotoAlbumIds, deleteAlbum, updateAlbum } from './query';
import { deleteAlbum, updateAlbum } from './query';
import { revalidateAllKeysAndPaths } from '@/cache';
import { redirect } from 'next/navigation';
import { PATH_ADMIN_ALBUMS, PATH_ROOT, pathForAlbum } from '@/app/path';
import { convertFormDataToAlbum } from './form';
import { Album } from '.';
import { createAlbumsAndGetIds } from './server';
export const updateAlbumAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
@ -35,13 +34,3 @@ export const deleteAlbumAction = async (
redirect(PATH_ROOT);
}
});
export const addPhotosToAlbumsAction = async (
photoIds: string[],
albumTitles: string[],
) =>
runAuthenticatedAdminServerAction(async () => {
const albumIds = await createAlbumsAndGetIds(albumTitles);
await addPhotoAlbumIds(photoIds, albumIds);
revalidateAllKeysAndPaths();
});

View File

@ -399,6 +399,8 @@ export const ADMIN_DEBUG_TOOLS_ENABLED = process.env.ADMIN_DEBUG_TOOLS === '1';
export const ADMIN_SQL_DEBUG_ENABLED =
process.env.ADMIN_SQL_DEBUG === '1' &&
!IS_BUILDING;
export const ADMIN_STORAGE_DEBUG_ENABLED =
process.env.ADMIN_STORAGE_DEBUG === '1';
export const APP_CONFIGURATION = {
// Storage
@ -527,6 +529,7 @@ export const APP_CONFIGURATION = {
),
areAdminDebugToolsEnabled: ADMIN_DEBUG_TOOLS_ENABLED,
isAdminSqlDebugEnabled: ADMIN_SQL_DEBUG_ENABLED,
isAdminStorageDebugEnabled: ADMIN_STORAGE_DEBUG_ENABLED,
// Misc
nextVersion: dependencies.next,
reactVersion: dependencies.react,

View File

@ -8,7 +8,7 @@ import {
STATICALLY_OPTIMIZED_PHOTOS,
} from '@/app/config';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/db';
import { getPublicPhotoIds } from '@/photo/query';
import { getAllPublicPhotoIds } from '@/photo/query';
import { depluralize, pluralize } from '@/utility/string';
type StaticOutput = 'page' | 'image';
@ -25,7 +25,7 @@ export const staticallyGeneratePhotosIfConfigured = (type: StaticOutput) => (
(type === 'image' && STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES)
)
? async () => {
const photoIds = await getPublicPhotoIds({
const photoIds = await getAllPublicPhotoIds({
limit: GENERATE_STATIC_PARAMS_LIMIT,
})
.catch(e => {

View File

@ -1,3 +1,5 @@
'use client';
import clsx from 'clsx/lite';
import { ReactNode } from 'react';
import CopyButton from './CopyButton';

View File

@ -37,6 +37,7 @@ export type PhotoQueryOptions = {
camera?: Partial<Camera>
lens?: Partial<Lens>
album?: Album
photoIds?: string[]
};
export const areOptionsSensitive = (options: PhotoQueryOptions) =>
@ -68,6 +69,7 @@ export const getWheresFromOptions = (
film,
recipe,
focal,
photoIds,
} = options;
const wheres = [] as string[];
@ -157,6 +159,10 @@ export const getWheresFromOptions = (
wheres.push(`focal_length=$${valuesIndex++}`);
wheresValues.push(focal);
}
if (photoIds && photoIds.length > 0) {
wheres.push(`id=ANY($${valuesIndex++})`);
wheresValues.push(convertArrayToPostgresString(photoIds) ?? '');
}
return {
wheres: wheres.length > 0

View File

@ -30,6 +30,8 @@ export default function InfinitePhotoScroll({
sortBy,
sortWithPriority,
excludeFromFeeds,
recent,
year,
camera,
lens,
tag,
@ -79,6 +81,8 @@ export default function InfinitePhotoScroll({
excludeFromFeeds,
limit: itemsPerPage,
hidden: includeHiddenPhotos ? 'include' : 'exclude',
recent,
year,
camera,
lens,
tag,
@ -94,6 +98,8 @@ export default function InfinitePhotoScroll({
initialOffset,
itemsPerPage,
includeHiddenPhotos,
recent,
year,
camera,
lens,
tag,

View File

@ -14,7 +14,10 @@ import PhotoHeader from './PhotoHeader';
import RecipeHeader from '@/recipe/RecipeHeader';
import { ReactNode } from 'react';
import LensHeader from '@/lens/LensHeader';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import {
ADMIN_STORAGE_DEBUG_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
} from '@/app/config';
import YearHeader from '@/year/YearHeader';
import RecentsHeader from '@/recents/RecentsHeader';
import AlbumHeader from '@/album/AlbumHeader';
@ -180,13 +183,13 @@ export default function PhotoDetailPage({
shouldShareFocalLength={focal !== undefined}
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
showAdminKeyCommands
showStorageCheck={ADMIN_STORAGE_DEBUG_ENABLED}
/>,
]}
/>
<AppGrid
contentMain={<PhotoGrid
photos={photosGrid ?? photos}
selectedPhoto={photo}
tag={tag}
camera={camera}
film={film}

View File

@ -1,3 +1,4 @@
import { ADMIN_STORAGE_DEBUG_ENABLED } from '@/app/config';
import {
INFINITE_SCROLL_FULL_MULTIPLE,
Photo,
@ -17,9 +18,10 @@ export default function PhotoFullPage({
sortBy: SortBy
sortWithPriority: boolean
}) {
const showStorageCheck = ADMIN_STORAGE_DEBUG_ENABLED;
return (
<div className="space-y-1">
<PhotosLarge {...{ photos }} />
<PhotosLarge {...{ photos, showStorageCheck }} />
{photosCount > photos.length &&
<PhotosLargeInfinite
initialOffset={photos.length}
@ -27,6 +29,7 @@ export default function PhotoFullPage({
sortBy={sortBy}
sortWithPriority={sortWithPriority}
excludeFromFeeds
showStorageCheck={showStorageCheck}
/>}
</div>
);

View File

@ -15,7 +15,6 @@ import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider';
export default function PhotoGrid({
photos,
selectedPhoto,
prioritizeInitialPhotos,
className,
classNamePhoto,
@ -31,7 +30,6 @@ export default function PhotoGrid({
...categories
}: {
photos: Photo[]
selectedPhoto?: Photo
prioritizeInitialPhotos?: boolean
className?: string
classNamePhoto?: string
@ -51,8 +49,9 @@ export default function PhotoGrid({
const {
isSelectingPhotos,
isSelectingAllPhotos,
selectedPhotoIds,
setSelectedPhotoIds,
togglePhotoSelection,
} = useSelectPhotosState();
return (
@ -79,7 +78,10 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationComplete={onAnimationComplete}
items={photos.map((photo, index) => {
const isSelected = selectedPhotoIds?.includes(photo.id) ?? false;
const isSelected = (
selectedPhotoIds?.includes(photo.id) ||
isSelectingAllPhotos
) ?? false;
return <div
key={photo.id}
className={clsx(
@ -102,7 +104,7 @@ export default function PhotoGrid({
{...{
photo,
...categories,
selected: photo.id === selectedPhoto?.id,
selected: isSelected,
priority: prioritizeInitialPhotos ? index < 6 : undefined,
onVisible: index === photos.length - 1
? onLastPhotoVisible
@ -112,10 +114,7 @@ export default function PhotoGrid({
{isSelectingPhotos &&
<SelectTileOverlay
isSelected={isSelected}
onSelectChange={() => setSelectedPhotoIds?.(isSelected
? (selectedPhotoIds ?? []).filter(id => id !== photo.id)
: (selectedPhotoIds ?? []).concat(photo.id),
)}
onSelectChange={() => togglePhotoSelection?.(photo.id)}
/>}
</div>;
}).concat(additionalTile ? <>{additionalTile}</> : [])}

View File

@ -50,6 +50,7 @@ import { lensFromPhoto } from '@/lens';
import MaskedScroll from '@/components/MaskedScroll';
import { useAppText } from '@/i18n/state/client';
import { Album } from '@/album';
import AdminPhotoStorageCheck from '@/admin/storage/AdminPhotoStorageCheck';
export default function PhotoLarge({
photo,
@ -83,6 +84,7 @@ export default function PhotoLarge({
includeFavoriteInAdminMenu,
onVisible,
showAdminKeyCommands,
showStorageCheck,
}: {
photo: Photo
className?: string
@ -115,6 +117,7 @@ export default function PhotoLarge({
includeFavoriteInAdminMenu?: boolean
onVisible?: () => void
showAdminKeyCommands?: boolean
showStorageCheck?: boolean
}) {
const ref = useRef<HTMLDivElement>(null);
const refZoomControls = useRef<ZoomControlsRef>(null);
@ -481,6 +484,8 @@ export default function PhotoLarge({
photo={photo}
/>}
</div>
{showStorageCheck &&
<AdminPhotoStorageCheck photo={photo} />}
</div>
</div>
</DivDebugBaselineGrid>

View File

@ -9,12 +9,14 @@ export default function PhotosLarge({
prefetchFirstPhotoLinks,
onLastPhotoVisible,
revalidatePhoto,
showStorageCheck,
}: {
photos: Photo[]
animate?: boolean
prefetchFirstPhotoLinks?: boolean
onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto
showStorageCheck?: boolean
}) {
return (
<AnimateItems
@ -35,6 +37,7 @@ export default function PhotosLarge({
onVisible={index === photos.length - 1
? onLastPhotoVisible
: undefined}
showStorageCheck={showStorageCheck}
/>)}
itemKeys={photos.map(photo => photo.id)}
/>

View File

@ -10,12 +10,14 @@ export default function PhotosLargeInfinite({
itemsPerPage,
sortBy,
excludeFromFeeds,
showStorageCheck,
}: {
initialOffset: number
itemsPerPage: number
sortBy: SortBy
sortWithPriority: boolean
excludeFromFeeds?: boolean
showStorageCheck?: boolean
}) {
return (
<InfinitePhotoScroll
@ -29,9 +31,12 @@ export default function PhotosLargeInfinite({
{({ key, photos, onLastPhotoVisible, revalidatePhoto }) =>
<PhotosLarge
key={key}
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}
{...{
photos,
onLastPhotoVisible,
revalidatePhoto,
showStorageCheck,
}}
/>}
</InfinitePhotoScroll>
);

View File

@ -14,11 +14,13 @@ import {
getPhotosNeedingRecipeTitleCount,
updateColorDataForPhoto,
getColorDataForPhotos,
getPhotoIds,
} from '@/photo/query';
import { PhotoQueryOptions, areOptionsSensitive } from '@/db';
import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData,
convertFormDataToPhotoDbInsert,
convertPhotoToFormData,
} from './form';
import { redirect } from 'next/navigation';
@ -50,7 +52,7 @@ import {
propagateRecipeTitleIfNecessary,
} from './server';
import { TAG_FAVS, Tags, isPhotoFav, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert, Photo } from '.';
import { convertPhotoToPhotoDbInsert, Photo, PhotoDbInsert } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { AiImageQuery, getAiImageQuery, getAiTextFieldsToGenerate } from './ai';
import { streamOpenAiImageQuery } from '@/platforms/openai';
@ -66,7 +68,6 @@ import {
storeOptimizedPhotosForUrl,
} from './storage/server';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
import { after } from 'next/server';
import {
getColorFieldsForImageUrl,
@ -343,18 +344,6 @@ export const updatePhotoAction = async (formData: FormData) =>
redirect(PATH_ADMIN_PHOTOS);
});
export const tagMultiplePhotosAction = async (
tags: string,
photoIds: string[],
) =>
runAuthenticatedAdminServerAction(async () => {
await addTagsToPhotos(
convertStringToArray(tags, false) ?? [],
photoIds,
);
revalidateAllKeysAndPaths();
});
export const toggleFavoritePhotoAction = async (
photoId: string,
shouldRedirect?: boolean,
@ -388,17 +377,6 @@ export const togglePrivatePhotoAction = async (
if (redirectPath) { redirect(redirectPath); }
});
export const deletePhotosAction = async (photoIds: string[]) =>
runAuthenticatedAdminServerAction(async () => {
for (const photoId of photoIds) {
const photo = await getPhoto(photoId, true);
if (photo) {
await deletePhotoAndFiles(photoId, photo.url);
}
}
revalidateAllKeysAndPaths();
});
export const deletePhotoAction = async (
photoId: string,
photoUrl: string,
@ -525,19 +503,46 @@ export const replacePhotoStorageAction = async (
) =>
runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId, true);
if (photo) {
const {
fileExtension: extension,
} = getFileNamePartsFromStorageUrl(updatedStorageUrl);
await updatePhoto(convertPhotoToPhotoDbInsert({
const {
formDataFromExif,
} = await extractImageDataFromBlobPath(updatedStorageUrl, {
generateBlurData: BLUR_ENABLED,
});
let imageFields: Partial<PhotoDbInsert> = {};
if (formDataFromExif) {
const photoDbInsert = convertFormDataToPhotoDbInsert(formDataFromExif);
imageFields = {
blurData: photoDbInsert.blurData,
width: photoDbInsert.width,
height: photoDbInsert.height,
aspectRatio: photoDbInsert.aspectRatio,
colorData: photoDbInsert.colorData,
colorSort: photoDbInsert.colorSort,
};
}
await updatePhoto({
...convertPhotoToPhotoDbInsert({
...photo,
url: updatedStorageUrl,
extension,
}));
}),
...imageFields,
});
await storeOptimizedPhotosForUrl(updatedStorageUrl);
const existingStorageUrls = await getStorageUrlsForPhoto(photo)
.then(urls => urls.map(({ url }) => url));
await Promise.all(existingStorageUrls.map(deleteFile));
revalidatePhoto(photo.id);
}
});
@ -695,6 +700,51 @@ export const streamAiImageQueryAction = async (
export const getImageBlurAction = async (url: string) =>
runAuthenticatedAdminServerAction(() => blurImageFromUrl(url));
// Batch actions
export const batchPhotoAction = async ({
photoIds: _photoIds = [],
photoOptions,
tags = [],
albumTitles = [],
action,
}: {
photoIds?: string[]
photoOptions?: PhotoQueryOptions
tags?: string[]
albumTitles?: string[]
action?: 'favorite' | 'delete'
}) => runAuthenticatedAdminServerAction(async () => {
const photoIds = _photoIds.length > 0
? _photoIds
: photoOptions !== undefined
? await getPhotoIds(photoOptions)
: [];
if (tags.length > 0) {
await addTagsToPhotos(tags, photoIds);
}
if (albumTitles.length > 0) {
const albumIds = await createAlbumsAndGetIds(albumTitles);
await addPhotoAlbumIds(photoIds, albumIds);
}
switch (action) {
case 'favorite':
await addTagsToPhotos([TAG_FAVS], photoIds);
break;
case 'delete':
for (const photoId of photoIds) {
const photo = await getPhoto(photoId, true);
if (photo) {
await deletePhotoAndFiles(photoId, photo.url);
}
}
break;
}
revalidateAllKeysAndPaths();
});
// Public/Private actions
export const getPhotosAction = async (

View File

@ -420,9 +420,15 @@ export const getUniqueFocalLengths = async () =>
})))
, 'getUniqueFocalLengths');
export const getPhotos = async (options: PhotoQueryOptions = {}) =>
safelyQuery(async () => {
const sql = ['SELECT p.* FROM photos p'];
const _getPhotos = async (
options: PhotoQueryOptions = {},
fields = ['*'],
shouldParse = true,
) => {
const sql = [
`SELECT ${fields.map(field => `p.${field}`).join(', ')} FROM photos p`,
];
const values = [] as (string | number)[];
const joins = getJoinsFromOptions(options);
@ -452,13 +458,57 @@ export const getPhotos = async (options: PhotoQueryOptions = {}) =>
values.push(...limitAndOffsetValues);
return query(sql.join(' '), values)
.then(({ rows }) => rows.map(parsePhotoFromDb));
},
.then(({ rows, rowCount }) => ({
// Only parse results if there's at least one photo
photos: shouldParse ? rows.map(parsePhotoFromDb) : rows,
// Prefer explicit count before falling back to row count
count: rows[0]?.count !== undefined
? parseInt(rows[0]?.count ?? '0', 10)
: rowCount ?? 0,
}));
};
export const getPhotos = async (options: PhotoQueryOptions = {}) =>
safelyQuery(
async () => _getPhotos(options).then(({ photos }) => photos),
'getPhotos',
// Seemingly necessary to pass `options` for expected cache behavior
options,
);
export const getPhotoIds = async (options: PhotoQueryOptions = {}) =>
safelyQuery(
async () => _getPhotos(options, ['id'], false)
.then(({ photos }) => photos.map(photo => photo.id)),
'getPhotoIds',
// Seemingly necessary to pass `options` for expected cache behavior
options,
);
export const getPhotoUrls = async (options: PhotoQueryOptions = {}) =>
safelyQuery(
async () => _getPhotos(options, ['id', 'title', 'url', 'hidden'], false)
.then(({ photos }) =>
photos as {
id: string,
title: string,
url: string,
hidden?: boolean,
}[]),
'getPhotoUrls',
// Seemingly necessary to pass `options` for expected cache behavior
options,
);
export const getPhotoCount = async (options: PhotoQueryOptions = {}) =>
safelyQuery(
async () => _getPhotos(options, ['COUNT(*)'], false)
.then(({ count }) => count),
'getPhotoCount',
// Seemingly necessary to pass `options` for expected cache behavior
options,
);
export const getPhotosNearId = async (
photoId: string,
options: PhotoQueryOptions,
@ -534,14 +584,14 @@ export const getPhotosMeta = (options: PhotoQueryOptions = {}) =>
}));
}, 'getPhotosMeta');
export const getPublicPhotoIds = async ({ limit }: { limit?: number }) =>
export const getAllPublicPhotoIds = async ({ limit }: { limit?: number }) =>
safelyQuery(() => (limit
? sql`SELECT id FROM photos WHERE hidden IS NOT TRUE LIMIT ${limit}`
: sql`SELECT id FROM photos WHERE hidden IS NOT TRUE`)
.then(({ rows }) => rows.map(({ id }) => id as string))
, 'getPublicPhotoIds');
export const getPhotoIdsAndUpdatedAt = async () =>
export const getAllPhotoIdsWithUpdatedAt = async () =>
safelyQuery(() =>
sql`SELECT id, updated_at FROM photos WHERE hidden IS NOT TRUE`
.then(({ rows }) => rows.map(({ id, updated_at }) =>