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

View File

@ -8,12 +8,12 @@
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'", "test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build --webpack" "analyze": "ANALYZE=true next build --webpack"
}, },
"packageManager": "pnpm@10.29.2", "packageManager": "pnpm@10.29.3",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^3.0.26", "@ai-sdk/openai": "^3.0.28",
"@ai-sdk/rsc": "^2.0.78", "@ai-sdk/rsc": "^2.0.85",
"@aws-sdk/client-s3": "3.987.0", "@aws-sdk/client-s3": "3.989.0",
"@aws-sdk/s3-request-presigner": "3.987.0", "@aws-sdk/s3-request-presigner": "3.989.0",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
@ -23,7 +23,7 @@
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/blob": "^2.2.0", "@vercel/blob": "^2.2.0",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.78", "ai": "^6.0.85",
"camelcase-keys": "^10.0.2", "camelcase-keys": "^10.0.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@ -39,7 +39,7 @@
"next": "16.1.6", "next": "16.1.6",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"ol": "^10.7.0", "ol": "^10.8.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"piexifjs": "^1.0.6", "piexifjs": "^1.0.6",
"react": "19.2.4", "react": "19.2.4",
@ -69,7 +69,7 @@
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/piexifjs": "^1.0.0", "@types/piexifjs": "^1.0.0",
"@types/react": "19.2.13", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"baseline-browser-mapping": "^2.9.19", "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 LoaderButton from '@/components/primitives/LoaderButton';
import { photoQuantityText } from '@/photo'; import { photoQuantityText } from '@/photo';
import { deletePhotosAction } from '@/photo/actions'; import { batchPhotoAction } from '@/photo/actions';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { toastSuccess, toastWarning } from '@/toast'; import { toastSuccess, toastWarning } from '@/toast';
import { ComponentProps, useState } from 'react'; import { ComponentProps, useState } from 'react';
@ -43,7 +43,10 @@ export default function DeletePhotosButton({
onClick={() => { onClick={() => {
onClick?.(); onClick?.();
setIsLoading(true); setIsLoading(true);
deletePhotosAction(photoIds) batchPhotoAction({
photoIds,
action: 'delete',
})
.then(() => { .then(() => {
toastSuccess(toastText ?? `${photosText} deleted`); toastSuccess(toastText ?? `${photosText} deleted`);
if (clearLocalState) { if (clearLocalState) {

View File

@ -140,6 +140,7 @@ export default function AdminAppConfigurationClient({
areInternalToolsEnabled, areInternalToolsEnabled,
areAdminDebugToolsEnabled, areAdminDebugToolsEnabled,
isAdminSqlDebugEnabled, isAdminSqlDebugEnabled,
isAdminStorageDebugEnabled,
// Auth // Auth
secret, secret,
// Connection status // Connection status
@ -1018,6 +1019,15 @@ export default function AdminAppConfigurationClient({
console output for all sql queries: console output for all sql queries:
{renderEnvVars(['ADMIN_SQL_DEBUG'])} {renderEnvVars(['ADMIN_SQL_DEBUG'])}
</ChecklistRow> </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 { clsx } from 'clsx/lite';
import { IoCloseSharp } from 'react-icons/io5'; import { IoCloseSharp } from 'react-icons/io5';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { TAG_FAVS, Tags } from '@/tag'; import { Tags } from '@/tag';
import FieldsetTag from '@/tag/FieldsetTag'; import FieldsetTag from '@/tag/FieldsetTag';
import { tagMultiplePhotosAction } from '@/photo/actions'; import { batchPhotoAction } from '@/photo/actions';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import DeletePhotosButton from '@/admin/DeletePhotosButton'; import DeletePhotosButton from '@/admin/DeletePhotosButton';
import { photoQuantityText } from '@/photo'; import { photoQuantityText } from '@/photo';
@ -21,14 +21,17 @@ import { useSelectPhotosState } from './SelectPhotosState';
import { Albums } from '@/album'; import { Albums } from '@/album';
import FieldsetAlbum from '@/album/FieldsetAlbum'; import FieldsetAlbum from '@/album/FieldsetAlbum';
import IconAlbum from '@/components/icons/IconAlbum'; 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({ export default function AdminBatchEditPanelClient({
uniqueAlbums, uniqueAlbums,
uniqueTags, uniqueTags,
showSelectAll,
}: { }: {
uniqueAlbums: Albums uniqueAlbums: Albums
uniqueTags: Tags uniqueTags: Tags
showSelectAll?: boolean
}) { }) {
const refNote = useRef<HTMLDivElement>(null); const refNote = useRef<HTMLDivElement>(null);
@ -36,6 +39,8 @@ export default function AdminBatchEditPanelClient({
canCurrentPageSelectPhotos, canCurrentPageSelectPhotos,
isSelectingPhotos, isSelectingPhotos,
stopSelectingPhotos, stopSelectingPhotos,
isSelectingAllPhotos,
toggleIsSelectingAllPhotos,
selectedPhotoIds, selectedPhotoIds,
isPerformingSelectEdit, isPerformingSelectEdit,
setIsPerformingSelectEdit, setIsPerformingSelectEdit,
@ -98,20 +103,20 @@ export default function AdminBatchEditPanelClient({
onClick={() => { onClick={() => {
setIsPerformingSelectEdit?.(true); setIsPerformingSelectEdit?.(true);
if (isInTagMode) { if (isInTagMode) {
tagMultiplePhotosAction( batchPhotoAction({
tags, photoIds: selectedPhotoIds,
selectedPhotoIds ?? [], tags: convertStringToArray(tags, false),
) })
.then(() => { .then(() => {
toastSuccess(`${photosText} tagged`); toastSuccess(`${photosText} tagged`);
stopSelectingPhotos?.(); stopSelectingPhotos?.();
}) })
.finally(() => setIsPerformingSelectEdit?.(false)); .finally(() => setIsPerformingSelectEdit?.(false));
} else if (isInAlbumMode) { } else if (isInAlbumMode) {
addPhotosToAlbumsAction( batchPhotoAction({
selectedPhotoIds ?? [], photoIds: selectedPhotoIds,
albumTitles.split(','), albumTitles: albumTitles.split(','),
) })
.then(() => { .then(() => {
toastSuccess(`${photosText} added`); toastSuccess(`${photosText} added`);
stopSelectingPhotos?.(); stopSelectingPhotos?.();
@ -146,10 +151,10 @@ export default function AdminBatchEditPanelClient({
confirmText={`Are you sure you want to favorite ${photosText}?`} confirmText={`Are you sure you want to favorite ${photosText}?`}
onClick={() => { onClick={() => {
setIsPerformingSelectEdit?.(true); setIsPerformingSelectEdit?.(true);
tagMultiplePhotosAction( batchPhotoAction({
TAG_FAVS, photoIds: selectedPhotoIds,
selectedPhotoIds ?? [], action: 'favorite',
) })
.then(() => { .then(() => {
toastSuccess(`${photosText} favorited`); toastSuccess(`${photosText} favorited`);
stopSelectingPhotos?.(); stopSelectingPhotos?.();
@ -230,8 +235,17 @@ export default function AdminBatchEditPanelClient({
openOnLoad openOnLoad
hideLabel hideLabel
/> />
: <div className="text-base flex gap-2 items-center"> : <div>
{renderPhotoCTA} <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>} </div>}
</Note> </Note>
{tagErrorMessage && {tagErrorMessage &&

View File

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

View File

@ -2,11 +2,13 @@ import { createContext, Dispatch, SetStateAction, use } from 'react';
export type SelectPhotosState = { export type SelectPhotosState = {
canCurrentPageSelectPhotos?: boolean canCurrentPageSelectPhotos?: boolean
isSelectingPhotos?: boolean; isSelectingPhotos?: boolean
isSelectingAllPhotos?: boolean
toggleIsSelectingAllPhotos?: () => void
startSelectingPhotos?: () => void startSelectingPhotos?: () => void
stopSelectingPhotos?: () => void stopSelectingPhotos?: () => void
selectedPhotoIds?: string[] selectedPhotoIds?: string[]
setSelectedPhotoIds?: (photoIds: string[]) => void togglePhotoSelection?: (photoId: string) => void
isPerformingSelectEdit?: boolean isPerformingSelectEdit?: boolean
setIsPerformingSelectEdit?: Dispatch<SetStateAction<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'; 'use server';
import { runAuthenticatedAdminServerAction } from '@/auth/server'; import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { addPhotoAlbumIds, deleteAlbum, updateAlbum } from './query'; import { deleteAlbum, updateAlbum } from './query';
import { revalidateAllKeysAndPaths } from '@/cache'; import { revalidateAllKeysAndPaths } from '@/cache';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { PATH_ADMIN_ALBUMS, PATH_ROOT, pathForAlbum } from '@/app/path'; import { PATH_ADMIN_ALBUMS, PATH_ROOT, pathForAlbum } from '@/app/path';
import { convertFormDataToAlbum } from './form'; import { convertFormDataToAlbum } from './form';
import { Album } from '.'; import { Album } from '.';
import { createAlbumsAndGetIds } from './server';
export const updateAlbumAction = async (formData: FormData) => export const updateAlbumAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
@ -35,13 +34,3 @@ export const deleteAlbumAction = async (
redirect(PATH_ROOT); 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 = export const ADMIN_SQL_DEBUG_ENABLED =
process.env.ADMIN_SQL_DEBUG === '1' && process.env.ADMIN_SQL_DEBUG === '1' &&
!IS_BUILDING; !IS_BUILDING;
export const ADMIN_STORAGE_DEBUG_ENABLED =
process.env.ADMIN_STORAGE_DEBUG === '1';
export const APP_CONFIGURATION = { export const APP_CONFIGURATION = {
// Storage // Storage
@ -527,6 +529,7 @@ export const APP_CONFIGURATION = {
), ),
areAdminDebugToolsEnabled: ADMIN_DEBUG_TOOLS_ENABLED, areAdminDebugToolsEnabled: ADMIN_DEBUG_TOOLS_ENABLED,
isAdminSqlDebugEnabled: ADMIN_SQL_DEBUG_ENABLED, isAdminSqlDebugEnabled: ADMIN_SQL_DEBUG_ENABLED,
isAdminStorageDebugEnabled: ADMIN_STORAGE_DEBUG_ENABLED,
// Misc // Misc
nextVersion: dependencies.next, nextVersion: dependencies.next,
reactVersion: dependencies.react, reactVersion: dependencies.react,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,11 +14,13 @@ import {
getPhotosNeedingRecipeTitleCount, getPhotosNeedingRecipeTitleCount,
updateColorDataForPhoto, updateColorDataForPhoto,
getColorDataForPhotos, getColorDataForPhotos,
getPhotoIds,
} from '@/photo/query'; } from '@/photo/query';
import { PhotoQueryOptions, areOptionsSensitive } from '@/db'; import { PhotoQueryOptions, areOptionsSensitive } from '@/db';
import { import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC, FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData, PhotoFormData,
convertFormDataToPhotoDbInsert,
convertPhotoToFormData, convertPhotoToFormData,
} from './form'; } from './form';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@ -50,7 +52,7 @@ import {
propagateRecipeTitleIfNecessary, propagateRecipeTitleIfNecessary,
} from './server'; } from './server';
import { TAG_FAVS, Tags, isPhotoFav, isTagFavs } from '@/tag'; import { TAG_FAVS, Tags, isPhotoFav, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert, Photo } from '.'; import { convertPhotoToPhotoDbInsert, Photo, PhotoDbInsert } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth/server'; import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { AiImageQuery, getAiImageQuery, getAiTextFieldsToGenerate } from './ai'; import { AiImageQuery, getAiImageQuery, getAiTextFieldsToGenerate } from './ai';
import { streamOpenAiImageQuery } from '@/platforms/openai'; import { streamOpenAiImageQuery } from '@/platforms/openai';
@ -66,7 +68,6 @@ import {
storeOptimizedPhotosForUrl, storeOptimizedPhotosForUrl,
} from './storage/server'; } from './storage/server';
import { UrlAddStatus } from '@/admin/AdminUploadsClient'; import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
import { after } from 'next/server'; import { after } from 'next/server';
import { import {
getColorFieldsForImageUrl, getColorFieldsForImageUrl,
@ -343,18 +344,6 @@ export const updatePhotoAction = async (formData: FormData) =>
redirect(PATH_ADMIN_PHOTOS); 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 ( export const toggleFavoritePhotoAction = async (
photoId: string, photoId: string,
shouldRedirect?: boolean, shouldRedirect?: boolean,
@ -388,17 +377,6 @@ export const togglePrivatePhotoAction = async (
if (redirectPath) { redirect(redirectPath); } 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 ( export const deletePhotoAction = async (
photoId: string, photoId: string,
photoUrl: string, photoUrl: string,
@ -525,19 +503,46 @@ export const replacePhotoStorageAction = async (
) => ) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId, true); const photo = await getPhoto(photoId, true);
if (photo) { if (photo) {
const { const {
fileExtension: extension, fileExtension: extension,
} = getFileNamePartsFromStorageUrl(updatedStorageUrl); } = getFileNamePartsFromStorageUrl(updatedStorageUrl);
await updatePhoto(convertPhotoToPhotoDbInsert({
...photo, const {
url: updatedStorageUrl, formDataFromExif,
extension, } = 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); await storeOptimizedPhotosForUrl(updatedStorageUrl);
const existingStorageUrls = await getStorageUrlsForPhoto(photo) const existingStorageUrls = await getStorageUrlsForPhoto(photo)
.then(urls => urls.map(({ url }) => url)); .then(urls => urls.map(({ url }) => url));
await Promise.all(existingStorageUrls.map(deleteFile)); await Promise.all(existingStorageUrls.map(deleteFile));
revalidatePhoto(photo.id); revalidatePhoto(photo.id);
} }
}); });
@ -695,6 +700,51 @@ export const streamAiImageQueryAction = async (
export const getImageBlurAction = async (url: string) => export const getImageBlurAction = async (url: string) =>
runAuthenticatedAdminServerAction(() => blurImageFromUrl(url)); 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 // Public/Private actions
export const getPhotosAction = async ( export const getPhotosAction = async (

View File

@ -420,43 +420,93 @@ export const getUniqueFocalLengths = async () =>
}))) })))
, 'getUniqueFocalLengths'); , 'getUniqueFocalLengths');
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);
if (joins) { sql.push(joins); }
const {
wheres,
wheresValues,
lastValuesIndex,
} = getWheresFromOptions(options);
if (wheres) {
sql.push(wheres);
values.push(...wheresValues);
}
sql.push(getOrderByFromOptions(options));
const {
limitAndOffset,
limitAndOffsetValues,
} = getLimitAndOffsetFromOptions(options, lastValuesIndex);
// LIMIT + OFFSET
sql.push(limitAndOffset);
values.push(...limitAndOffsetValues);
return query(sql.join(' '), values)
.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 = {}) => export const getPhotos = async (options: PhotoQueryOptions = {}) =>
safelyQuery(async () => { safelyQuery(
const sql = ['SELECT p.* FROM photos p']; async () => _getPhotos(options).then(({ photos }) => photos),
const values = [] as (string | number)[]; 'getPhotos',
// Seemingly necessary to pass `options` for expected cache behavior
options,
);
const joins = getJoinsFromOptions(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,
);
if (joins) { sql.push(joins); } 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,
);
const { export const getPhotoCount = async (options: PhotoQueryOptions = {}) =>
wheres, safelyQuery(
wheresValues, async () => _getPhotos(options, ['COUNT(*)'], false)
lastValuesIndex, .then(({ count }) => count),
} = getWheresFromOptions(options); 'getPhotoCount',
// Seemingly necessary to pass `options` for expected cache behavior
if (wheres) { options,
sql.push(wheres);
values.push(...wheresValues);
}
sql.push(getOrderByFromOptions(options));
const {
limitAndOffset,
limitAndOffsetValues,
} = getLimitAndOffsetFromOptions(options, lastValuesIndex);
// LIMIT + OFFSET
sql.push(limitAndOffset);
values.push(...limitAndOffsetValues);
return query(sql.join(' '), values)
.then(({ rows }) => rows.map(parsePhotoFromDb));
},
'getPhotos',
// Seemingly necessary to pass `options` for expected cache behavior
options,
); );
export const getPhotosNearId = async ( export const getPhotosNearId = async (
@ -534,14 +584,14 @@ export const getPhotosMeta = (options: PhotoQueryOptions = {}) =>
})); }));
}, 'getPhotosMeta'); }, 'getPhotosMeta');
export const getPublicPhotoIds = async ({ limit }: { limit?: number }) => export const getAllPublicPhotoIds = async ({ limit }: { limit?: number }) =>
safelyQuery(() => (limit safelyQuery(() => (limit
? sql`SELECT id FROM photos WHERE hidden IS NOT TRUE LIMIT ${limit}` ? sql`SELECT id FROM photos WHERE hidden IS NOT TRUE LIMIT ${limit}`
: sql`SELECT id FROM photos WHERE hidden IS NOT TRUE`) : sql`SELECT id FROM photos WHERE hidden IS NOT TRUE`)
.then(({ rows }) => rows.map(({ id }) => id as string)) .then(({ rows }) => rows.map(({ id }) => id as string))
, 'getPublicPhotoIds'); , 'getPublicPhotoIds');
export const getPhotoIdsAndUpdatedAt = async () => export const getAllPhotoIdsWithUpdatedAt = async () =>
safelyQuery(() => safelyQuery(() =>
sql`SELECT id, updated_at FROM photos WHERE hidden IS NOT TRUE` sql`SELECT id, updated_at FROM photos WHERE hidden IS NOT TRUE`
.then(({ rows }) => rows.map(({ id, updated_at }) => .then(({ rows }) => rows.map(({ id, updated_at }) =>