Merge pull request #109 from sambecker/batch-migrate
Add tools to update outdated photos
This commit is contained in:
commit
3616ee1c8c
@ -214,6 +214,9 @@ FAQ
|
|||||||
#### Why don't my photo changes show up immediately?
|
#### Why don't my photo changes show up immediately?
|
||||||
> This template statically optimizes core views such as `/` and `/grid` to minimize visitor load times. Consequently, when photos are added, edited, or removed, it might take several minutes for those changes to propagate. If it seems like a change is not taking effect, try navigating to `/admin/configuration` and clicking "Clear Cache."
|
> This template statically optimizes core views such as `/` and `/grid` to minimize visitor load times. Consequently, when photos are added, edited, or removed, it might take several minutes for those changes to propagate. If it seems like a change is not taking effect, try navigating to `/admin/configuration` and clicking "Clear Cache."
|
||||||
|
|
||||||
|
#### Why don't my older photos look right?
|
||||||
|
> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or use the outdated photo page (`/admin/outdated`) to make batch updates.
|
||||||
|
|
||||||
#### Why don’t my OG images load when I share a link?
|
#### Why don’t my OG images load when I share a link?
|
||||||
> Many services such as iMessage, Slack, and X, require near-instant responses when unfurling link-based content. In order to guarantee sufficient responsiveness, consider rendering pages and image assets ahead of time by enabling static optimization by setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES = 1` and `NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES = 1`. Keep in mind that this will increase platform usage.
|
> Many services such as iMessage, Slack, and X, require near-instant responses when unfurling link-based content. In order to guarantee sufficient responsiveness, consider rendering pages and image assets ahead of time by enabling static optimization by setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES = 1` and `NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES = 1`. Keep in mind that this will increase platform usage.
|
||||||
|
|
||||||
|
|||||||
10
package.json
10
package.json
@ -9,9 +9,9 @@
|
|||||||
"analyze": "ANALYZE=true next build"
|
"analyze": "ANALYZE=true next build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^0.0.28",
|
"@ai-sdk/openai": "^0.0.29",
|
||||||
"@aws-sdk/client-s3": "3.596.0",
|
"@aws-sdk/client-s3": "3.598.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.596.0",
|
"@aws-sdk/s3-request-presigner": "3.598.0",
|
||||||
"@next/bundle-analyzer": "14.2.4",
|
"@next/bundle-analyzer": "14.2.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@ -30,7 +30,7 @@
|
|||||||
"@vercel/blob": "^0.23.3",
|
"@vercel/blob": "^0.23.3",
|
||||||
"@vercel/kv": "^2.0.0",
|
"@vercel/kv": "^2.0.0",
|
||||||
"@vercel/speed-insights": "^1.0.12",
|
"@vercel/speed-insights": "^1.0.12",
|
||||||
"ai": "^3.1.35",
|
"ai": "^3.1.36",
|
||||||
"autoprefixer": "10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
"camelcase-keys": "^9.1.3",
|
"camelcase-keys": "^9.1.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -57,7 +57,7 @@
|
|||||||
"tailwindcss": "3.4.4",
|
"tailwindcss": "3.4.4",
|
||||||
"ts-exif-parser": "^0.2.2",
|
"ts-exif-parser": "^0.2.2",
|
||||||
"typescript": "5.4.5",
|
"typescript": "5.4.5",
|
||||||
"undici": "^6.18.2",
|
"undici": "^6.19.0",
|
||||||
"use-debounce": "^10.0.1"
|
"use-debounce": "^10.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1437
pnpm-lock.yaml
generated
1437
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -4,15 +4,22 @@ import PhotoUpload from '@/photo/PhotoUpload';
|
|||||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
import { FaArrowRight } from 'react-icons/fa';
|
import { FaArrowRight } from 'react-icons/fa';
|
||||||
|
|
||||||
export default function AdminCTA() {
|
export default function AdminCTA() {
|
||||||
const { isUserSignedIn } = useAppState();
|
const { isUserSignedIn } = useAppState();
|
||||||
|
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-4">
|
||||||
{isUserSignedIn
|
{isUserSignedIn
|
||||||
? <PhotoUpload showUploadStatus={false} />
|
? <PhotoUpload
|
||||||
|
showUploadStatus={false}
|
||||||
|
isUploading={isUploading}
|
||||||
|
setIsUploading={setIsUploading}
|
||||||
|
/>
|
||||||
: <Link
|
: <Link
|
||||||
href={PATH_ADMIN_PHOTOS}
|
href={PATH_ADMIN_PHOTOS}
|
||||||
className="button primary"
|
className="button primary"
|
||||||
|
|||||||
@ -31,28 +31,26 @@ export default async function AdminNav() {
|
|||||||
getPhotosMostRecentUpdateCached().catch(() => undefined),
|
getPhotosMostRecentUpdateCached().catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const navItemPhotos = {
|
// Photos
|
||||||
|
const items = [{
|
||||||
label: 'Photos',
|
label: 'Photos',
|
||||||
href: PATH_ADMIN_PHOTOS,
|
href: PATH_ADMIN_PHOTOS,
|
||||||
count: countPhotos,
|
count: countPhotos,
|
||||||
};
|
}];
|
||||||
|
|
||||||
const navItemUploads = {
|
// Uploads
|
||||||
|
if (countUploads > 0) { items.push({
|
||||||
label: 'Uploads',
|
label: 'Uploads',
|
||||||
href: PATH_ADMIN_UPLOADS,
|
href: PATH_ADMIN_UPLOADS,
|
||||||
count: countUploads,
|
count: countUploads,
|
||||||
};
|
}); }
|
||||||
|
|
||||||
const navItemTags = {
|
// Tags
|
||||||
|
if (countTags > 0) { items.push({
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
href: PATH_ADMIN_TAGS,
|
href: PATH_ADMIN_TAGS,
|
||||||
count: countTags,
|
count: countTags,
|
||||||
};
|
}); }
|
||||||
|
|
||||||
const items = [navItemPhotos];
|
|
||||||
|
|
||||||
if (countUploads > 0) { items.push(navItemUploads); }
|
|
||||||
if (countTags > 0) { items.push(navItemTags); }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminNavClient {...{ items, mostRecentPhotoUpdateTime }} />
|
<AdminNavClient {...{ items, mostRecentPhotoUpdateTime }} />
|
||||||
|
|||||||
104
src/admin/AdminOutdatedClient.tsx
Normal file
104
src/admin/AdminOutdatedClient.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { OUTDATED_THRESHOLD, Photo } from '@/photo';
|
||||||
|
import AdminPhotosTable from '@/admin/AdminPhotosTable';
|
||||||
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
|
import IconGrSync from '@/site/IconGrSync';
|
||||||
|
import Banner from '@/components/Banner';
|
||||||
|
import AdminChildPage from '@/components/AdminChildPage';
|
||||||
|
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { syncPhotosAction } from '@/photo/actions';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
const UPDATE_BATCH_SIZE = 4;
|
||||||
|
|
||||||
|
export default function AdminOutdatedClient({
|
||||||
|
photos,
|
||||||
|
hasAiTextGeneration,
|
||||||
|
}: {
|
||||||
|
photos: Photo[]
|
||||||
|
hasAiTextGeneration: boolean
|
||||||
|
}) {
|
||||||
|
const [photoIdsSyncing, setPhotoIdsSyncing] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const arePhotoIdsSyncing = photoIdsSyncing.length > 0;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminChildPage
|
||||||
|
backLabel="Photos"
|
||||||
|
backPath={PATH_ADMIN_PHOTOS}
|
||||||
|
breadcrumb={<>
|
||||||
|
<span className="hidden sm:inline-block">
|
||||||
|
Outdated ({photos.length})
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden">
|
||||||
|
Outdated
|
||||||
|
</span>
|
||||||
|
</>}
|
||||||
|
accessory={<LoaderButton
|
||||||
|
icon={<IconGrSync className="translate-y-[1px]" />}
|
||||||
|
hideTextOnMobile={false}
|
||||||
|
className="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
if (window.confirm(
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
`Are you sure you want to sync the oldest ${UPDATE_BATCH_SIZE} photos? This action cannot be undone.`
|
||||||
|
)) {
|
||||||
|
const photosToSync = photos
|
||||||
|
.slice(0, UPDATE_BATCH_SIZE)
|
||||||
|
.map(photo => photo.id);
|
||||||
|
setPhotoIdsSyncing(photosToSync);
|
||||||
|
syncPhotosAction(photosToSync)
|
||||||
|
.finally(() => {
|
||||||
|
setPhotoIdsSyncing([]);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={arePhotoIdsSyncing}
|
||||||
|
>
|
||||||
|
{arePhotoIdsSyncing
|
||||||
|
? 'Syncing'
|
||||||
|
: <>
|
||||||
|
<span className="hidden sm:inline-block">
|
||||||
|
Sync {UPDATE_BATCH_SIZE} Oldest Photos
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden">
|
||||||
|
Sync {UPDATE_BATCH_SIZE} Oldest
|
||||||
|
</span>
|
||||||
|
</>}
|
||||||
|
</LoaderButton>}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Banner>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{photos.length}
|
||||||
|
{' '}
|
||||||
|
{photos.length === 1 ? 'photo' : 'photos'}
|
||||||
|
{' ('}uploaded before
|
||||||
|
{' '}
|
||||||
|
{new Date(OUTDATED_THRESHOLD).toLocaleDateString()}{')'}
|
||||||
|
{' '}
|
||||||
|
may have: missing EXIF fields, inaccurate blur data,
|
||||||
|
{' '}
|
||||||
|
undesired privacy settings
|
||||||
|
{hasAiTextGeneration && ', missing AI-generated text'}
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AdminPhotosTable
|
||||||
|
photos={photos}
|
||||||
|
photoIdsSyncing={photoIdsSyncing}
|
||||||
|
hasAiTextGeneration={hasAiTextGeneration}
|
||||||
|
canEdit={false}
|
||||||
|
canDelete={false}
|
||||||
|
showUpdatedAt
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminChildPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/admin/AdminPhotosClient.tsx
Normal file
86
src/admin/AdminPhotosClient.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import PhotoUpload from '@/photo/PhotoUpload';
|
||||||
|
import { clsx } from 'clsx/lite';
|
||||||
|
import SiteGrid from '@/components/SiteGrid';
|
||||||
|
import AdminUploadsTable from '@/admin/AdminUploadsTable';
|
||||||
|
import { AI_TEXT_GENERATION_ENABLED, PRO_MODE_ENABLED } from '@/site/config';
|
||||||
|
import AdminPhotosTable from '@/admin/AdminPhotosTable';
|
||||||
|
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
|
||||||
|
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
|
||||||
|
import { PATH_ADMIN_OUTDATED } from '@/site/paths';
|
||||||
|
import { Photo } from '@/photo';
|
||||||
|
import { StorageListResponse } from '@/services/storage';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { LiaBroomSolid } from 'react-icons/lia';
|
||||||
|
|
||||||
|
export default function AdminPhotosClient({
|
||||||
|
photos,
|
||||||
|
photosCount,
|
||||||
|
photosCountOutdated,
|
||||||
|
onLastPhotoUpload,
|
||||||
|
blobPhotoUrls,
|
||||||
|
infiniteScrollInitial,
|
||||||
|
infiniteScrollMultiple,
|
||||||
|
}: {
|
||||||
|
photos: Photo[]
|
||||||
|
photosCount: number
|
||||||
|
photosCountOutdated: number
|
||||||
|
onLastPhotoUpload: () => Promise<void>
|
||||||
|
blobPhotoUrls: StorageListResponse
|
||||||
|
infiniteScrollInitial: number
|
||||||
|
infiniteScrollMultiple: number
|
||||||
|
}) {
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteGrid
|
||||||
|
contentMain={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="grow">
|
||||||
|
<PhotoUpload
|
||||||
|
shouldResize={!PRO_MODE_ENABLED}
|
||||||
|
isUploading={isUploading}
|
||||||
|
setIsUploading={setIsUploading}
|
||||||
|
onLastUpload={onLastPhotoUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{photosCountOutdated > 0 && <PathLoaderButton
|
||||||
|
path={PATH_ADMIN_OUTDATED}
|
||||||
|
icon={<LiaBroomSolid size={18} className="translate-y-[-1px]" />}
|
||||||
|
title={`${photosCountOutdated} Outdated Photos`}
|
||||||
|
className={clsx(
|
||||||
|
isUploading && 'hidden md:inline-flex',
|
||||||
|
)}
|
||||||
|
hideTextOnMobile={false}
|
||||||
|
>
|
||||||
|
{photosCountOutdated}
|
||||||
|
</PathLoaderButton>}
|
||||||
|
</div>
|
||||||
|
{!isUploading && blobPhotoUrls.length > 0 &&
|
||||||
|
<div className={clsx(
|
||||||
|
'border-b pb-6',
|
||||||
|
'border-gray-200 dark:border-gray-700',
|
||||||
|
)}>
|
||||||
|
<AdminUploadsTable
|
||||||
|
title={`Photo Blobs (${blobPhotoUrls.length})`}
|
||||||
|
urls={blobPhotoUrls}
|
||||||
|
/>
|
||||||
|
</div>}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AdminPhotosTable
|
||||||
|
photos={photos}
|
||||||
|
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
||||||
|
/>
|
||||||
|
{photosCount > photos.length &&
|
||||||
|
<AdminPhotosTableInfinite
|
||||||
|
initialOffset={infiniteScrollInitial}
|
||||||
|
itemsPerPage={infiniteScrollMultiple}
|
||||||
|
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,10 +12,7 @@ import PhotoDate from '@/photo/PhotoDate';
|
|||||||
import FormWithConfirm from '@/components/FormWithConfirm';
|
import FormWithConfirm from '@/components/FormWithConfirm';
|
||||||
import EditButton from './EditButton';
|
import EditButton from './EditButton';
|
||||||
import DeleteButton from './DeleteButton';
|
import DeleteButton from './DeleteButton';
|
||||||
import {
|
import { deletePhotoFormAction } from '@/photo/actions';
|
||||||
deletePhotoFormAction,
|
|
||||||
syncPhotoAction,
|
|
||||||
} from '@/photo/actions';
|
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||||
import PhotoSyncButton from './PhotoSyncButton';
|
import PhotoSyncButton from './PhotoSyncButton';
|
||||||
@ -24,15 +21,28 @@ export default function AdminPhotosTable({
|
|||||||
photos,
|
photos,
|
||||||
onLastPhotoVisible,
|
onLastPhotoVisible,
|
||||||
revalidatePhoto,
|
revalidatePhoto,
|
||||||
|
photoIdsSyncing = [],
|
||||||
hasAiTextGeneration,
|
hasAiTextGeneration,
|
||||||
|
showUpdatedAt,
|
||||||
|
canEdit = true,
|
||||||
|
canDelete = true,
|
||||||
}: {
|
}: {
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
onLastPhotoVisible?: () => void
|
onLastPhotoVisible?: () => void
|
||||||
revalidatePhoto?: RevalidatePhoto
|
revalidatePhoto?: RevalidatePhoto
|
||||||
hasAiTextGeneration?: boolean
|
photoIdsSyncing?: string[]
|
||||||
|
hasAiTextGeneration: boolean
|
||||||
|
showUpdatedAt?: boolean
|
||||||
|
canEdit?: boolean
|
||||||
|
canDelete?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { invalidateSwr } = useAppState();
|
const { invalidateSwr } = useAppState();
|
||||||
|
|
||||||
|
const opacityForPhotoId = (photoId: string) =>
|
||||||
|
photoIdsSyncing.length > 0 && !photoIdsSyncing.includes(photoId)
|
||||||
|
? 'opacity-40'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminTable>
|
<AdminTable>
|
||||||
{photos.map((photo, index) =>
|
{photos.map((photo, index) =>
|
||||||
@ -42,8 +52,12 @@ export default function AdminPhotosTable({
|
|||||||
onVisible={index === photos.length - 1
|
onVisible={index === photos.length - 1
|
||||||
? onLastPhotoVisible
|
? onLastPhotoVisible
|
||||||
: undefined}
|
: undefined}
|
||||||
|
className={opacityForPhotoId(photo.id)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className={clsx(
|
||||||
|
'flex flex-col lg:flex-row',
|
||||||
|
opacityForPhotoId(photo.id),
|
||||||
|
)}>
|
||||||
<Link
|
<Link
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
href={pathForPhoto({ photo })}
|
href={pathForPhoto({ photo })}
|
||||||
@ -75,32 +89,39 @@ export default function AdminPhotosTable({
|
|||||||
'lg:w-[50%] uppercase',
|
'lg:w-[50%] uppercase',
|
||||||
'text-dim',
|
'text-dim',
|
||||||
)}>
|
)}>
|
||||||
<PhotoDate {...{ photo }} />
|
<PhotoDate {...{
|
||||||
|
photo,
|
||||||
|
dateType: showUpdatedAt ? 'updatedAt' : undefined,
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex flex-nowrap',
|
'flex flex-nowrap',
|
||||||
'gap-2 sm:gap-3 items-center',
|
'gap-2 sm:gap-3 items-center',
|
||||||
)}>
|
)}>
|
||||||
<EditButton path={pathForAdminPhotoEdit(photo)} />
|
{canEdit &&
|
||||||
|
<EditButton path={pathForAdminPhotoEdit(photo)} />}
|
||||||
<PhotoSyncButton
|
<PhotoSyncButton
|
||||||
action={syncPhotoAction}
|
photoId={photo.id}
|
||||||
photoTitle={titleForPhoto(photo)}
|
photoTitle={titleForPhoto(photo)}
|
||||||
formData={{ photoId: photo.id }}
|
onSyncComplete={invalidateSwr}
|
||||||
onFormSubmit={invalidateSwr}
|
isSyncingExternal={photoIdsSyncing.includes(photo.id)}
|
||||||
hasAiTextGeneration={hasAiTextGeneration}
|
hasAiTextGeneration={hasAiTextGeneration}
|
||||||
|
disabled={photoIdsSyncing.length > 0}
|
||||||
|
className={opacityForPhotoId(photo.id)}
|
||||||
shouldConfirm
|
shouldConfirm
|
||||||
shouldToast
|
shouldToast
|
||||||
/>
|
/>
|
||||||
<FormWithConfirm
|
{canDelete &&
|
||||||
action={deletePhotoFormAction}
|
<FormWithConfirm
|
||||||
confirmText={deleteConfirmationTextForPhoto(photo)}
|
action={deletePhotoFormAction}
|
||||||
onSubmit={() => revalidatePhoto?.(photo.id, true)}
|
confirmText={deleteConfirmationTextForPhoto(photo)}
|
||||||
>
|
onSubmit={() => revalidatePhoto?.(photo.id, true)}
|
||||||
<input type="hidden" name="id" value={photo.id} />
|
>
|
||||||
<input type="hidden" name="url" value={photo.url} />
|
<input type="hidden" name="id" value={photo.id} />
|
||||||
<DeleteButton clearLocalState />
|
<input type="hidden" name="url" value={photo.url} />
|
||||||
</FormWithConfirm>
|
<DeleteButton clearLocalState />
|
||||||
|
</FormWithConfirm>}
|
||||||
</div>
|
</div>
|
||||||
</Fragment>)}
|
</Fragment>)}
|
||||||
</AdminTable>
|
</AdminTable>
|
||||||
|
|||||||
@ -3,16 +3,18 @@
|
|||||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||||
import InfinitePhotoScroll from '../photo/InfinitePhotoScroll';
|
import InfinitePhotoScroll from '../photo/InfinitePhotoScroll';
|
||||||
import AdminPhotosTable from './AdminPhotosTable';
|
import AdminPhotosTable from './AdminPhotosTable';
|
||||||
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
export default function AdminPhotosTableInfinite({
|
export default function AdminPhotosTableInfinite({
|
||||||
initialOffset,
|
initialOffset,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
hasAiTextGeneration,
|
hasAiTextGeneration,
|
||||||
|
canEdit,
|
||||||
|
canDelete,
|
||||||
}: {
|
}: {
|
||||||
initialOffset: number
|
initialOffset: number
|
||||||
itemsPerPage: number
|
itemsPerPage: number
|
||||||
hasAiTextGeneration?: boolean
|
} & Omit<ComponentProps<typeof AdminPhotosTable>, 'photos'>) {
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<InfinitePhotoScroll
|
<InfinitePhotoScroll
|
||||||
cacheKey={`page-${PATH_ADMIN_PHOTOS}`}
|
cacheKey={`page-${PATH_ADMIN_PHOTOS}`}
|
||||||
@ -27,6 +29,8 @@ export default function AdminPhotosTableInfinite({
|
|||||||
onLastPhotoVisible={onLastPhotoVisible}
|
onLastPhotoVisible={onLastPhotoVisible}
|
||||||
revalidatePhoto={revalidatePhoto}
|
revalidatePhoto={revalidatePhoto}
|
||||||
hasAiTextGeneration={hasAiTextGeneration}
|
hasAiTextGeneration={hasAiTextGeneration}
|
||||||
|
canEdit={canEdit}
|
||||||
|
canDelete={canDelete}
|
||||||
/>}
|
/>}
|
||||||
</InfinitePhotoScroll>
|
</InfinitePhotoScroll>
|
||||||
);
|
);
|
||||||
|
|||||||
37
src/admin/ExifSyncButton.tsx
Normal file
37
src/admin/ExifSyncButton.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import FormWithConfirm from '@/components/FormWithConfirm';
|
||||||
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
|
import IconGrSync from '@/site/IconGrSync';
|
||||||
|
import { clsx } from 'clsx/lite';
|
||||||
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
export default function ExifSyncButton({
|
||||||
|
action,
|
||||||
|
label,
|
||||||
|
onFormSubmit,
|
||||||
|
photoUrl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
action: (formData: FormData) => void
|
||||||
|
label?: string
|
||||||
|
photoUrl?: string
|
||||||
|
} & ComponentProps<typeof SubmitButtonWithStatus>) {
|
||||||
|
return (
|
||||||
|
<FormWithConfirm
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<input name="photoUrl" value={photoUrl} hidden readOnly />
|
||||||
|
<SubmitButtonWithStatus
|
||||||
|
title="Update photo from original file"
|
||||||
|
icon={<IconGrSync
|
||||||
|
className={clsx(
|
||||||
|
'translate-y-[0.5px] translate-x-[0.5px]',
|
||||||
|
label && 'sm:translate-x-[-0.5px]',
|
||||||
|
)} />}
|
||||||
|
onFormSubmit={onFormSubmit}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</SubmitButtonWithStatus>
|
||||||
|
</FormWithConfirm>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,61 +1,61 @@
|
|||||||
import FormWithConfirm from '@/components/FormWithConfirm';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
import { syncPhotoAction } from '@/photo/actions';
|
||||||
import IconGrSync from '@/site/IconGrSync';
|
import IconGrSync from '@/site/IconGrSync';
|
||||||
import { clsx } from 'clsx/lite';
|
import { toastSuccess } from '@/toast';
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps, useState } from 'react';
|
||||||
|
|
||||||
export default function PhotoSyncButton({
|
export default function PhotoSyncButton({
|
||||||
action,
|
photoId,
|
||||||
label,
|
|
||||||
onFormSubmit,
|
|
||||||
formData: { photoId, photoUrl } = {},
|
|
||||||
photoTitle,
|
photoTitle,
|
||||||
|
onSyncComplete,
|
||||||
|
className,
|
||||||
|
isSyncingExternal,
|
||||||
hasAiTextGeneration,
|
hasAiTextGeneration,
|
||||||
|
disabled,
|
||||||
shouldConfirm,
|
shouldConfirm,
|
||||||
shouldToast,
|
shouldToast,
|
||||||
}: {
|
}: {
|
||||||
action: (formData: FormData) => void
|
photoId: string
|
||||||
label?: string
|
|
||||||
formData?: {
|
|
||||||
photoId?: string
|
|
||||||
photoUrl?: string
|
|
||||||
}
|
|
||||||
photoTitle?: string
|
photoTitle?: string
|
||||||
|
onSyncComplete?: () => void
|
||||||
|
isSyncingExternal?: boolean
|
||||||
hasAiTextGeneration?: boolean
|
hasAiTextGeneration?: boolean
|
||||||
shouldConfirm?: boolean
|
shouldConfirm?: boolean
|
||||||
shouldToast?: boolean
|
shouldToast?: boolean
|
||||||
} & ComponentProps<typeof SubmitButtonWithStatus>) {
|
} & ComponentProps<typeof LoaderButton>) {
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
|
||||||
const confirmText = ['Overwrite'];
|
const confirmText = ['Overwrite'];
|
||||||
if (photoTitle) { confirmText.push(`"${photoTitle}"`); }
|
if (photoTitle) { confirmText.push(`"${photoTitle}"`); }
|
||||||
confirmText.push('data from original file?');
|
confirmText.push('data from original file?');
|
||||||
if (hasAiTextGeneration) { confirmText.push(
|
if (hasAiTextGeneration) { confirmText.push(
|
||||||
'AI text will be generated for undefined fields.'); }
|
'AI text will be generated for undefined fields.'); }
|
||||||
confirmText.push('This action cannot be undone.');
|
confirmText.push('This action cannot be undone.');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormWithConfirm
|
<LoaderButton
|
||||||
action={action}
|
title="Update photo from original file"
|
||||||
confirmText={shouldConfirm ? confirmText.join(' ') : undefined}
|
className={className}
|
||||||
>
|
icon={<IconGrSync
|
||||||
{photoId &&
|
className="translate-y-[0.5px] translate-x-[0.5px]"
|
||||||
<input name="photoId" value={photoId} hidden readOnly />}
|
/>}
|
||||||
{photoUrl &&
|
onClick={() => {
|
||||||
<input name="photoUrl" value={photoUrl} hidden readOnly />}
|
if (!shouldConfirm || window.confirm(confirmText.join(' '))) {
|
||||||
<SubmitButtonWithStatus
|
setIsSyncing(true);
|
||||||
title="Update photo from original file"
|
syncPhotoAction(photoId)
|
||||||
icon={<IconGrSync
|
.then(() => {
|
||||||
className={clsx(
|
onSyncComplete?.();
|
||||||
'translate-y-[0.5px] translate-x-[0.5px]',
|
if (shouldToast) {
|
||||||
label && 'sm:translate-x-[-0.5px]',
|
toastSuccess(photoTitle
|
||||||
)} />}
|
? `"${photoTitle}" data synced`
|
||||||
onFormSubmitToastMessage={shouldToast
|
: 'Data synced');
|
||||||
? photoTitle
|
}
|
||||||
? `"${photoTitle}" data synced`
|
})
|
||||||
: 'Data synced'
|
.finally(() => setIsSyncing(false));
|
||||||
: undefined}
|
}
|
||||||
onFormSubmit={onFormSubmit}
|
}}
|
||||||
>
|
isLoading={isSyncing || isSyncingExternal}
|
||||||
{label}
|
disabled={disabled}
|
||||||
</SubmitButtonWithStatus>
|
/>
|
||||||
</FormWithConfirm>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export default async function AdminLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-5">
|
<div className="mt-4 space-y-4">
|
||||||
<AdminNav />
|
<AdminNav />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
20
src/app/admin/outdated/page.tsx
Normal file
20
src/app/admin/outdated/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getPhotos } from '@/photo/db/query';
|
||||||
|
import { OUTDATED_THRESHOLD } from '@/photo';
|
||||||
|
import AdminOutdatedClient from '@/admin/AdminOutdatedClient';
|
||||||
|
import { AI_TEXT_GENERATION_ENABLED } from '@/site/config';
|
||||||
|
|
||||||
|
export default async function AdminOutdatedPage() {
|
||||||
|
const photos = await getPhotos({
|
||||||
|
hidden: 'include',
|
||||||
|
sortBy: 'createdAtAsc',
|
||||||
|
updatedBefore: OUTDATED_THRESHOLD,
|
||||||
|
limit: 1_000,
|
||||||
|
}).catch(() => []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminOutdatedClient {...{
|
||||||
|
photos,
|
||||||
|
hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,9 @@
|
|||||||
import PhotoUpload from '@/photo/PhotoUpload';
|
|
||||||
import { clsx } from 'clsx/lite';
|
|
||||||
import SiteGrid from '@/components/SiteGrid';
|
|
||||||
import AdminUploadsTable from '@/admin/AdminUploadsTable';
|
|
||||||
import { AI_TEXT_GENERATION_ENABLED, PRO_MODE_ENABLED } from '@/site/config';
|
|
||||||
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
|
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
|
||||||
import { getPhotos } from '@/photo/db/query';
|
import { getPhotos } from '@/photo/db/query';
|
||||||
import { revalidatePath } from 'next/cache';
|
|
||||||
import AdminPhotosTable from '@/admin/AdminPhotosTable';
|
|
||||||
import AdminPhotosTableInfinite from
|
|
||||||
'@/admin/AdminPhotosTableInfinite';
|
|
||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
|
import { OUTDATED_THRESHOLD } from '@/photo';
|
||||||
|
import AdminPhotosClient from '@/admin/AdminPhotosClient';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
const DEBUG_PHOTO_BLOBS = false;
|
const DEBUG_PHOTO_BLOBS = false;
|
||||||
|
|
||||||
@ -20,6 +14,7 @@ export default async function AdminPhotosPage() {
|
|||||||
const [
|
const [
|
||||||
photos,
|
photos,
|
||||||
photosCount,
|
photosCount,
|
||||||
|
photosCountOutdated,
|
||||||
blobPhotoUrls,
|
blobPhotoUrls,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPhotos({
|
getPhotos({
|
||||||
@ -30,46 +25,30 @@ export default async function AdminPhotosPage() {
|
|||||||
getPhotosMetaCached({ hidden: 'include'})
|
getPhotosMetaCached({ hidden: 'include'})
|
||||||
.then(({ count }) => count)
|
.then(({ count }) => count)
|
||||||
.catch(() => 0),
|
.catch(() => 0),
|
||||||
|
getPhotosMetaCached({
|
||||||
|
hidden: 'include',
|
||||||
|
updatedBefore: OUTDATED_THRESHOLD,
|
||||||
|
})
|
||||||
|
.then(({ count }) => count)
|
||||||
|
.catch(() => 0),
|
||||||
DEBUG_PHOTO_BLOBS
|
DEBUG_PHOTO_BLOBS
|
||||||
? getStoragePhotoUrlsNoStore()
|
? getStoragePhotoUrlsNoStore()
|
||||||
: [],
|
: [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteGrid
|
<AdminPhotosClient {...{
|
||||||
contentMain={
|
photos,
|
||||||
<div className="space-y-4">
|
photosCount,
|
||||||
<PhotoUpload
|
photosCountOutdated,
|
||||||
shouldResize={!PRO_MODE_ENABLED}
|
onLastPhotoUpload: async () => {
|
||||||
onLastUpload={async () => {
|
'use server';
|
||||||
'use server';
|
// Update upload count in admin nav
|
||||||
// Update upload count in admin nav
|
revalidatePath('/admin', 'layout');
|
||||||
revalidatePath('/admin', 'layout');
|
},
|
||||||
}}
|
blobPhotoUrls,
|
||||||
/>
|
infiniteScrollInitial: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS,
|
||||||
{blobPhotoUrls.length > 0 &&
|
infiniteScrollMultiple: INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS,
|
||||||
<div className={clsx(
|
}} />
|
||||||
'border-b pb-6',
|
|
||||||
'border-gray-200 dark:border-gray-700',
|
|
||||||
)}>
|
|
||||||
<AdminUploadsTable
|
|
||||||
title={`Photo Blobs (${blobPhotoUrls.length})`}
|
|
||||||
urls={blobPhotoUrls}
|
|
||||||
/>
|
|
||||||
</div>}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<AdminPhotosTable
|
|
||||||
photos={photos}
|
|
||||||
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
|
||||||
/>
|
|
||||||
{photosCount > photos.length &&
|
|
||||||
<AdminPhotosTableInfinite
|
|
||||||
initialOffset={INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS}
|
|
||||||
itemsPerPage={INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS}
|
|
||||||
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
|
|
||||||
/>}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,8 @@ export default function Banner({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{icon ?? <IoInformationCircleOutline
|
{icon ?? <IoInformationCircleOutline
|
||||||
size={17}
|
size={18}
|
||||||
className="translate-y-[1px]"
|
className="translate-y-[1px] shrink-0"
|
||||||
/>}
|
/>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,11 +6,13 @@ export default function FormWithConfirm({
|
|||||||
action,
|
action,
|
||||||
confirmText,
|
confirmText,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
action: (formData: FormData) => void
|
action: (formData: FormData) => void
|
||||||
confirmText?: string
|
confirmText?: string
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
|
className?: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -24,6 +26,7 @@ export default function FormWithConfirm({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -5,10 +5,10 @@ import { useRef, useState } from 'react';
|
|||||||
import { CopyExif } from '@/lib/CopyExif';
|
import { CopyExif } from '@/lib/CopyExif';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import Spinner from './Spinner';
|
|
||||||
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
|
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
|
||||||
import { FiUploadCloud } from 'react-icons/fi';
|
import { FiUploadCloud } from 'react-icons/fi';
|
||||||
import { MAX_IMAGE_SIZE } from '@/services/next-image';
|
import { MAX_IMAGE_SIZE } from '@/services/next-image';
|
||||||
|
import LoaderButton from './primitives/LoaderButton';
|
||||||
|
|
||||||
const INPUT_ID = 'file';
|
const INPUT_ID = 'file';
|
||||||
|
|
||||||
@ -36,7 +36,8 @@ export default function ImageInput({
|
|||||||
showUploadStatus?: boolean
|
showUploadStatus?: boolean
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLCanvasElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const [image, setImage] = useState<HTMLImageElement>();
|
const [image, setImage] = useState<HTMLImageElement>();
|
||||||
const [filesLength, setFilesLength] = useState(0);
|
const [filesLength, setFilesLength] = useState(0);
|
||||||
@ -57,26 +58,24 @@ export default function ImageInput({
|
|||||||
loading && 'pointer-events-none cursor-not-allowed',
|
loading && 'pointer-events-none cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<LoaderButton
|
||||||
className={clsx(
|
type="button"
|
||||||
'button primary normal-case',
|
isLoading={loading}
|
||||||
loading && 'disabled'
|
className="primary"
|
||||||
)}
|
icon={<FiUploadCloud
|
||||||
|
size={18}
|
||||||
|
className="translate-x-[-0.5px] translate-y-[0.5px]"
|
||||||
|
/>}
|
||||||
aria-disabled={loading}
|
aria-disabled={loading}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
hideTextOnMobile={false}
|
||||||
>
|
>
|
||||||
<span className="w-4 inline-flex items-center mr-1">
|
|
||||||
{loading
|
|
||||||
? <Spinner color="text" className="translate-y-[0.5px]" />
|
|
||||||
: <FiUploadCloud
|
|
||||||
size={17}
|
|
||||||
className="translate-y-[0.5px] shrink-0"
|
|
||||||
/>}
|
|
||||||
</span>
|
|
||||||
{loading
|
{loading
|
||||||
? 'Uploading'
|
? 'Uploading'
|
||||||
: 'Upload Photos'}
|
: 'Upload Photos'}
|
||||||
</span>
|
</LoaderButton>
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
id={INPUT_ID}
|
id={INPUT_ID}
|
||||||
type="file"
|
type="file"
|
||||||
className="!hidden"
|
className="!hidden"
|
||||||
@ -100,7 +99,7 @@ export default function ImageInput({
|
|||||||
|
|
||||||
const isPng = callbackArgs.extension === 'png';
|
const isPng = callbackArgs.extension === 'png';
|
||||||
|
|
||||||
const canvas = ref.current;
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
// Specify wide gamut to avoid data loss while resizing
|
// Specify wide gamut to avoid data loss while resizing
|
||||||
const ctx = canvas?.getContext(
|
const ctx = canvas?.getContext(
|
||||||
@ -227,7 +226,7 @@ export default function ImageInput({
|
|||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<canvas
|
<canvas
|
||||||
ref={ref}
|
ref={canvasRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'bg-gray-50 dark:bg-gray-900/50 rounded-md',
|
'bg-gray-50 dark:bg-gray-900/50 rounded-md',
|
||||||
'border border-gray-200 dark:border-gray-800',
|
'border border-gray-200 dark:border-gray-800',
|
||||||
|
|||||||
@ -4,13 +4,18 @@ import { clsx } from 'clsx/lite';
|
|||||||
export default function ResponsiveDate({
|
export default function ResponsiveDate({
|
||||||
date,
|
date,
|
||||||
className,
|
className,
|
||||||
|
titleLabel,
|
||||||
}: {
|
}: {
|
||||||
date: Date
|
date: Date
|
||||||
className?: string
|
className?: string
|
||||||
|
titleLabel?: string
|
||||||
}) {
|
}) {
|
||||||
|
const title = titleLabel
|
||||||
|
? `${titleLabel}: ${formatDate(date).toLocaleUpperCase()}`
|
||||||
|
: formatDate(date).toLocaleUpperCase();
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={formatDate(date).toLocaleUpperCase()}
|
title={title}
|
||||||
className={clsx(className, 'uppercase')}
|
className={clsx(className, 'uppercase')}
|
||||||
>
|
>
|
||||||
{/* Small */}
|
{/* Small */}
|
||||||
|
|||||||
@ -6,13 +6,6 @@ import { clsx } from 'clsx/lite';
|
|||||||
import { toastSuccess } from '@/toast';
|
import { toastSuccess } from '@/toast';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
|
|
||||||
interface Props extends ComponentProps<typeof LoaderButton> {
|
|
||||||
onFormStatusChange?: (pending: boolean) => void
|
|
||||||
onFormSubmitToastMessage?: string
|
|
||||||
onFormSubmit?: () => void
|
|
||||||
primary?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubmitButtonWithStatus({
|
export default function SubmitButtonWithStatus({
|
||||||
icon,
|
icon,
|
||||||
styleAs,
|
styleAs,
|
||||||
@ -26,7 +19,12 @@ export default function SubmitButtonWithStatus({
|
|||||||
primary,
|
primary,
|
||||||
type: _type,
|
type: _type,
|
||||||
...buttonProps
|
...buttonProps
|
||||||
}: Props) {
|
}: {
|
||||||
|
onFormStatusChange?: (pending: boolean) => void
|
||||||
|
onFormSubmitToastMessage?: string
|
||||||
|
onFormSubmit?: () => void
|
||||||
|
primary?: boolean
|
||||||
|
} & ComponentProps<typeof LoaderButton>) {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
const pendingPrevious = useRef(pending);
|
const pendingPrevious = useRef(pending);
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Spinner, { SpinnerColor } from '@/components/Spinner';
|
import Spinner, { SpinnerColor } from '@/components/Spinner';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { ButtonHTMLAttributes, ReactNode } from 'react';
|
import { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
@ -42,7 +44,7 @@ export default function LoaderButton(props: {
|
|||||||
: ['h-9']),
|
: ['h-9']),
|
||||||
styleAs === 'link' && 'hover:text-dim',
|
styleAs === 'link' && 'hover:text-dim',
|
||||||
styleAs === 'link-without-hover' && 'hover:text-main',
|
styleAs === 'link-without-hover' && 'hover:text-main',
|
||||||
'inline-flex items-center gap-2 self-start',
|
'inline-flex items-center gap-2 self-start whitespace-nowrap',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={isLoading || disabled}
|
disabled={isLoading || disabled}
|
||||||
@ -51,7 +53,7 @@ export default function LoaderButton(props: {
|
|||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
'min-w-[1.25rem] h-4',
|
'min-w-[1.25rem] h-4',
|
||||||
styleAs === 'button' ? 'translate-y-[-0.5px]' : 'translate-y-[0.5px]',
|
styleAs === 'button' ? 'translate-y-[-0.5px]' : 'translate-y-[0.5px]',
|
||||||
'inline-flex justify-center',
|
'inline-flex justify-center shrink-0',
|
||||||
)}>
|
)}>
|
||||||
{isLoading
|
{isLoading
|
||||||
? <Spinner
|
? <Spinner
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import LoaderButton from '@/components/primitives/LoaderButton';
|
|||||||
export default function PathLoaderButton({
|
export default function PathLoaderButton({
|
||||||
path,
|
path,
|
||||||
icon,
|
icon,
|
||||||
|
title,
|
||||||
prefetch,
|
prefetch,
|
||||||
loaderDelay = 100,
|
loaderDelay = 100,
|
||||||
shouldScroll = true,
|
shouldScroll = true,
|
||||||
@ -21,6 +22,7 @@ export default function PathLoaderButton({
|
|||||||
}: {
|
}: {
|
||||||
path: string
|
path: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
|
title?: string
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
loaderDelay?: number
|
loaderDelay?: number
|
||||||
shouldScroll?: boolean
|
shouldScroll?: boolean
|
||||||
@ -58,6 +60,7 @@ export default function PathLoaderButton({
|
|||||||
return (
|
return (
|
||||||
<LoaderButton
|
<LoaderButton
|
||||||
icon={icon}
|
icon={icon}
|
||||||
|
title={title}
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
|||||||
@ -3,17 +3,44 @@ import { Photo } from '.';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
export default function PhotoDate({
|
export default function PhotoDate({
|
||||||
photo: { takenAtNaive },
|
photo,
|
||||||
className,
|
className,
|
||||||
|
dateType = 'takenAt',
|
||||||
}: {
|
}: {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
className?: string
|
className?: string
|
||||||
|
dateType?: 'takenAt' | 'createdAt' | 'updatedAt'
|
||||||
}) {
|
}) {
|
||||||
const date = useMemo(() => {
|
const date = useMemo(() => {
|
||||||
const date = new Date(takenAtNaive);
|
const date = new Date(dateType === 'takenAt'
|
||||||
|
? photo.takenAt
|
||||||
|
: dateType === 'createdAt'
|
||||||
|
? photo.createdAt
|
||||||
|
: photo.updatedAt);
|
||||||
return isNaN(date.getTime()) ? new Date() : date;
|
return isNaN(date.getTime()) ? new Date() : date;
|
||||||
}, [takenAtNaive]);
|
}, [
|
||||||
|
dateType,
|
||||||
|
photo.takenAt,
|
||||||
|
photo.createdAt,
|
||||||
|
photo.updatedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getTitleLabel = () => {
|
||||||
|
switch (dateType) {
|
||||||
|
case 'takenAt':
|
||||||
|
return 'TAKEN';
|
||||||
|
case 'createdAt':
|
||||||
|
return 'CREATED';
|
||||||
|
case 'updatedAt':
|
||||||
|
return 'UPDATED';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveDate {...{ date, className }} />
|
<ResponsiveDate {...{
|
||||||
|
date,
|
||||||
|
className,
|
||||||
|
titleLabel: getTitleLabel(),
|
||||||
|
}} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { getExifDataAction } from './actions';
|
|||||||
import { TagsWithMeta } from '@/tag';
|
import { TagsWithMeta } from '@/tag';
|
||||||
import AiButton from './ai/AiButton';
|
import AiButton from './ai/AiButton';
|
||||||
import usePhotoFormParent from './form/usePhotoFormParent';
|
import usePhotoFormParent from './form/usePhotoFormParent';
|
||||||
import PhotoSyncButton from '@/admin/PhotoSyncButton';
|
import ExifSyncButton from '@/admin/ExifSyncButton';
|
||||||
|
|
||||||
export default function PhotoEditPageClient({
|
export default function PhotoEditPageClient({
|
||||||
photo,
|
photo,
|
||||||
@ -68,10 +68,10 @@ export default function PhotoEditPageClient({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{hasAiTextGeneration &&
|
{hasAiTextGeneration &&
|
||||||
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
|
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
|
||||||
<PhotoSyncButton
|
<ExifSyncButton
|
||||||
action={action}
|
action={action}
|
||||||
label="EXIF"
|
label="EXIF"
|
||||||
formData={{ photoUrl: photo.url }}
|
photoUrl={photo.url}
|
||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
isLoading={pending}
|
isLoading={pending}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default function PhotoSmall({
|
|||||||
'active:brightness-75',
|
'active:brightness-75',
|
||||||
selected && 'brightness-50',
|
selected && 'brightness-50',
|
||||||
'min-w-[50px]',
|
'min-w-[50px]',
|
||||||
'rounded-[0.15rem] overflow-hidden',
|
'rounded-[3px] overflow-hidden',
|
||||||
'border border-gray-200 dark:border-gray-800',
|
'border border-gray-200 dark:border-gray-800',
|
||||||
)}
|
)}
|
||||||
prefetch={prefetch}
|
prefetch={prefetch}
|
||||||
|
|||||||
@ -10,15 +10,18 @@ import { clsx } from 'clsx/lite';
|
|||||||
export default function PhotoUpload({
|
export default function PhotoUpload({
|
||||||
shouldResize,
|
shouldResize,
|
||||||
onLastUpload,
|
onLastUpload,
|
||||||
|
isUploading,
|
||||||
|
setIsUploading,
|
||||||
showUploadStatus,
|
showUploadStatus,
|
||||||
debug,
|
debug,
|
||||||
}: {
|
}: {
|
||||||
shouldResize?: boolean
|
shouldResize?: boolean
|
||||||
onLastUpload?: () => Promise<void>
|
onLastUpload?: () => Promise<void>
|
||||||
|
isUploading: boolean
|
||||||
|
setIsUploading: (isUploading: boolean) => void
|
||||||
showUploadStatus?: boolean
|
showUploadStatus?: boolean
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [uploadError, setUploadError] = useState<string>();
|
const [uploadError, setUploadError] = useState<string>();
|
||||||
const [debugDownload, setDebugDownload] = useState<{
|
const [debugDownload, setDebugDownload] = useState<{
|
||||||
href: string
|
href: string
|
||||||
|
|||||||
@ -291,9 +291,8 @@ export const getExifDataAction = async (
|
|||||||
// - strip GPS data if necessary
|
// - strip GPS data if necessary
|
||||||
// - update blur data (or destroy if blur is disabled)
|
// - update blur data (or destroy if blur is disabled)
|
||||||
// - generate AI text data, if enabled, and auto-generated fields are empty
|
// - generate AI text data, if enabled, and auto-generated fields are empty
|
||||||
export const syncPhotoAction = async (formData: FormData) =>
|
export const syncPhotoAction = async (photoId: string) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
const photoId = formData.get('photoId') as string | undefined;
|
|
||||||
const photo = await getPhoto(photoId ?? '', true);
|
const photo = await getPhoto(photoId ?? '', true);
|
||||||
|
|
||||||
if (photo) {
|
if (photo) {
|
||||||
@ -347,6 +346,14 @@ export const syncPhotoAction = async (formData: FormData) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const syncPhotosAction = async (photoIds: string[]) =>
|
||||||
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
|
for (const photoId of photoIds) {
|
||||||
|
await syncPhotoAction(photoId);
|
||||||
|
}
|
||||||
|
revalidateAllKeysAndPaths();
|
||||||
|
});
|
||||||
|
|
||||||
export const clearCacheAction = async () =>
|
export const clearCacheAction = async () =>
|
||||||
runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths);
|
runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths);
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,8 @@ const getPhotosCacheKeyForOption = (
|
|||||||
return value ? `${option}-${createLensKey(value)}` : null;
|
return value ? `${option}-${createLensKey(value)}` : null;
|
||||||
}
|
}
|
||||||
case 'takenBefore':
|
case 'takenBefore':
|
||||||
case 'takenAfterInclusive': {
|
case 'takenAfterInclusive':
|
||||||
|
case 'updatedBefore': {
|
||||||
const value = options[option];
|
const value = options[option];
|
||||||
return value ? `${option}-${value.toISOString()}` : null;
|
return value ? `${option}-${value.toISOString()}` : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
|||||||
export const PHOTO_DEFAULT_LIMIT = 100;
|
export const PHOTO_DEFAULT_LIMIT = 100;
|
||||||
|
|
||||||
export type GetPhotosOptions = {
|
export type GetPhotosOptions = {
|
||||||
sortBy?: 'createdAt' | 'takenAt' | 'priority'
|
sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority'
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
query?: string
|
query?: string
|
||||||
@ -19,6 +19,7 @@ export type GetPhotosOptions = {
|
|||||||
focal?: number
|
focal?: number
|
||||||
takenBefore?: Date
|
takenBefore?: Date
|
||||||
takenAfterInclusive?: Date
|
takenAfterInclusive?: Date
|
||||||
|
updatedBefore?: Date
|
||||||
hidden?: 'exclude' | 'include' | 'only'
|
hidden?: 'exclude' | 'include' | 'only'
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ export const getWheresFromOptions = (
|
|||||||
hidden = 'exclude',
|
hidden = 'exclude',
|
||||||
takenBefore,
|
takenBefore,
|
||||||
takenAfterInclusive,
|
takenAfterInclusive,
|
||||||
|
updatedBefore,
|
||||||
query,
|
query,
|
||||||
tag,
|
tag,
|
||||||
camera,
|
camera,
|
||||||
@ -52,13 +54,17 @@ export const getWheresFromOptions = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (takenBefore) {
|
if (takenBefore) {
|
||||||
wheres.push(`taken_at > $${valuesIndex++}`);
|
wheres.push(`taken_at < $${valuesIndex++}`);
|
||||||
wheresValues.push(takenBefore.toISOString());
|
wheresValues.push(takenBefore.toISOString());
|
||||||
}
|
}
|
||||||
if (takenAfterInclusive) {
|
if (takenAfterInclusive) {
|
||||||
wheres.push(`taken_at <= $${valuesIndex++}`);
|
wheres.push(`taken_at >= $${valuesIndex++}`);
|
||||||
wheresValues.push(takenAfterInclusive.toISOString());
|
wheresValues.push(takenAfterInclusive.toISOString());
|
||||||
}
|
}
|
||||||
|
if (updatedBefore) {
|
||||||
|
wheres.push(`updated_at < $${valuesIndex++}`);
|
||||||
|
wheresValues.push(updatedBefore.toISOString());
|
||||||
|
}
|
||||||
if (query) {
|
if (query) {
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`);
|
wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`);
|
||||||
@ -106,6 +112,8 @@ export const getOrderByFromOptions = (options: GetPhotosOptions) => {
|
|||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'createdAt':
|
case 'createdAt':
|
||||||
return 'ORDER BY created_at DESC';
|
return 'ORDER BY created_at DESC';
|
||||||
|
case 'createdAtAsc':
|
||||||
|
return 'ORDER BY created_at ASC';
|
||||||
case 'takenAt':
|
case 'takenAt':
|
||||||
return 'ORDER BY taken_at DESC';
|
return 'ORDER BY taken_at DESC';
|
||||||
case 'priority':
|
case 'priority':
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import camelcaseKeys from 'camelcase-keys';
|
|||||||
import { isBefore } from 'date-fns';
|
import { isBefore } from 'date-fns';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const OUTDATED_THRESHOLD = new Date('2024-06-16');
|
||||||
|
|
||||||
// INFINITE SCROLL: LARGE PHOTOS
|
// INFINITE SCROLL: LARGE PHOTOS
|
||||||
export const INFINITE_SCROLL_LARGE_PHOTO_INITIAL =
|
export const INFINITE_SCROLL_LARGE_PHOTO_INITIAL =
|
||||||
process.env.NODE_ENV === 'development' ? 2 : 12;
|
process.env.NODE_ENV === 'development' ? 2 : 12;
|
||||||
|
|||||||
@ -67,6 +67,7 @@
|
|||||||
input[type=checkbox] {
|
input[type=checkbox] {
|
||||||
@apply
|
@apply
|
||||||
rounded-md
|
rounded-md
|
||||||
|
dark:bg-transparent
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
@apply
|
@apply
|
||||||
@ -98,6 +99,7 @@
|
|||||||
text-invert
|
text-invert
|
||||||
bg-gray-900 dark:bg-gray-100
|
bg-gray-900 dark:bg-gray-100
|
||||||
disabled:text-dim
|
disabled:text-dim
|
||||||
|
font-medium
|
||||||
disabled:bg-gray-100 dark:disabled:bg-gray-900
|
disabled:bg-gray-100 dark:disabled:bg-gray-900
|
||||||
disabled:border-gray-200 disabled:dark:border-gray-700
|
disabled:border-gray-200 disabled:dark:border-gray-700
|
||||||
border-gray-900 dark:border-gray-100
|
border-gray-900 dark:border-gray-100
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
|||||||
|
|
||||||
// Admin paths
|
// Admin paths
|
||||||
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
||||||
|
export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`;
|
||||||
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
|
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
|
||||||
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
|
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
|
||||||
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user