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:
parent
b664b8b203
commit
a63f2c3fe3
19
app/admin/storage/page.tsx
Normal file
19
app/admin/storage/page.tsx
Normal 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>;
|
||||
}
|
||||
@ -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 = [
|
||||
|
||||
16
package.json
16
package.json
@ -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
909
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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) {
|
||||
|
||||
@ -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>
|
||||
</>;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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">
|
||||
{renderPhotoCTA}
|
||||
: <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 &&
|
||||
|
||||
@ -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,
|
||||
}}>
|
||||
|
||||
@ -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>>
|
||||
};
|
||||
|
||||
36
src/admin/storage/AdminPhotoStorageCheck.tsx
Normal file
36
src/admin/storage/AdminPhotoStorageCheck.tsx
Normal 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>;
|
||||
}
|
||||
58
src/admin/storage/AdminStorageTable.tsx
Normal file
58
src/admin/storage/AdminStorageTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx/lite';
|
||||
import { ReactNode } from 'react';
|
||||
import CopyButton from './CopyButton';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}</> : [])}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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({
|
||||
...photo,
|
||||
url: updatedStorageUrl,
|
||||
extension,
|
||||
}));
|
||||
|
||||
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 (
|
||||
|
||||
@ -420,43 +420,93 @@ export const getUniqueFocalLengths = async () =>
|
||||
})))
|
||||
, '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 = {}) =>
|
||||
safelyQuery(async () => {
|
||||
const sql = ['SELECT p.* FROM photos p'];
|
||||
const values = [] as (string | number)[];
|
||||
safelyQuery(
|
||||
async () => _getPhotos(options).then(({ photos }) => photos),
|
||||
'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 {
|
||||
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 }) => rows.map(parsePhotoFromDb));
|
||||
},
|
||||
'getPhotos',
|
||||
// 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 (
|
||||
@ -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 }) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user