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';
|
} 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 = [
|
||||||
|
|||||||
16
package.json
16
package.json
@ -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
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 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) {
|
||||||
|
|||||||
@ -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>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 &&
|
||||||
|
|||||||
@ -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,
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@ -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>>
|
||||||
};
|
};
|
||||||
|
|||||||
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';
|
'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();
|
|
||||||
});
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}</> : [])}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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 }) =>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user