Merge pull request #109 from sambecker/batch-migrate

Add tools to update outdated photos
This commit is contained in:
Sam Becker 2024-06-17 00:35:33 -05:00 committed by GitHub
commit 3616ee1c8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1212 additions and 885 deletions

View File

@ -214,6 +214,9 @@ FAQ
#### 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."
#### 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 dont 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.

View File

@ -9,9 +9,9 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.28",
"@aws-sdk/client-s3": "3.596.0",
"@aws-sdk/s3-request-presigner": "3.596.0",
"@ai-sdk/openai": "^0.0.29",
"@aws-sdk/client-s3": "3.598.0",
"@aws-sdk/s3-request-presigner": "3.598.0",
"@next/bundle-analyzer": "14.2.4",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@tailwindcss/container-queries": "^0.1.1",
@ -30,7 +30,7 @@
"@vercel/blob": "^0.23.3",
"@vercel/kv": "^2.0.0",
"@vercel/speed-insights": "^1.0.12",
"ai": "^3.1.35",
"ai": "^3.1.36",
"autoprefixer": "10.4.19",
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.1",
@ -57,7 +57,7 @@
"tailwindcss": "3.4.4",
"ts-exif-parser": "^0.2.2",
"typescript": "5.4.5",
"undici": "^6.18.2",
"undici": "^6.19.0",
"use-debounce": "^10.0.1"
}
}

1437
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,22 @@ import PhotoUpload from '@/photo/PhotoUpload';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { useAppState } from '@/state/AppState';
import Link from 'next/link';
import { useState } from 'react';
import { FaArrowRight } from 'react-icons/fa';
export default function AdminCTA() {
const { isUserSignedIn } = useAppState();
const [isUploading, setIsUploading] = useState(false);
return (
<div className="flex justify-center pt-4">
{isUserSignedIn
? <PhotoUpload showUploadStatus={false} />
? <PhotoUpload
showUploadStatus={false}
isUploading={isUploading}
setIsUploading={setIsUploading}
/>
: <Link
href={PATH_ADMIN_PHOTOS}
className="button primary"

View File

@ -31,28 +31,26 @@ export default async function AdminNav() {
getPhotosMostRecentUpdateCached().catch(() => undefined),
]);
const navItemPhotos = {
// Photos
const items = [{
label: 'Photos',
href: PATH_ADMIN_PHOTOS,
count: countPhotos,
};
}];
const navItemUploads = {
// Uploads
if (countUploads > 0) { items.push({
label: 'Uploads',
href: PATH_ADMIN_UPLOADS,
count: countUploads,
};
}); }
const navItemTags = {
// Tags
if (countTags > 0) { items.push({
label: 'Tags',
href: PATH_ADMIN_TAGS,
count: countTags,
};
const items = [navItemPhotos];
if (countUploads > 0) { items.push(navItemUploads); }
if (countTags > 0) { items.push(navItemTags); }
}); }
return (
<AdminNavClient {...{ items, mostRecentPhotoUpdateTime }} />

View 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>
);
}

View 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>}
/>
);
}

View File

@ -12,10 +12,7 @@ import PhotoDate from '@/photo/PhotoDate';
import FormWithConfirm from '@/components/FormWithConfirm';
import EditButton from './EditButton';
import DeleteButton from './DeleteButton';
import {
deletePhotoFormAction,
syncPhotoAction,
} from '@/photo/actions';
import { deletePhotoFormAction } from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton';
@ -24,15 +21,28 @@ export default function AdminPhotosTable({
photos,
onLastPhotoVisible,
revalidatePhoto,
photoIdsSyncing = [],
hasAiTextGeneration,
showUpdatedAt,
canEdit = true,
canDelete = true,
}: {
photos: Photo[],
onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto
hasAiTextGeneration?: boolean
photoIdsSyncing?: string[]
hasAiTextGeneration: boolean
showUpdatedAt?: boolean
canEdit?: boolean
canDelete?: boolean
}) {
const { invalidateSwr } = useAppState();
const opacityForPhotoId = (photoId: string) =>
photoIdsSyncing.length > 0 && !photoIdsSyncing.includes(photoId)
? 'opacity-40'
: undefined;
return (
<AdminTable>
{photos.map((photo, index) =>
@ -42,8 +52,12 @@ export default function AdminPhotosTable({
onVisible={index === photos.length - 1
? onLastPhotoVisible
: 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
key={photo.id}
href={pathForPhoto({ photo })}
@ -75,32 +89,39 @@ export default function AdminPhotosTable({
'lg:w-[50%] uppercase',
'text-dim',
)}>
<PhotoDate {...{ photo }} />
<PhotoDate {...{
photo,
dateType: showUpdatedAt ? 'updatedAt' : undefined,
}} />
</div>
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton path={pathForAdminPhotoEdit(photo)} />
{canEdit &&
<EditButton path={pathForAdminPhotoEdit(photo)} />}
<PhotoSyncButton
action={syncPhotoAction}
photoId={photo.id}
photoTitle={titleForPhoto(photo)}
formData={{ photoId: photo.id }}
onFormSubmit={invalidateSwr}
onSyncComplete={invalidateSwr}
isSyncingExternal={photoIdsSyncing.includes(photo.id)}
hasAiTextGeneration={hasAiTextGeneration}
disabled={photoIdsSyncing.length > 0}
className={opacityForPhotoId(photo.id)}
shouldConfirm
shouldToast
/>
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
onSubmit={() => revalidatePhoto?.(photo.id, true)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton clearLocalState />
</FormWithConfirm>
{canDelete &&
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
onSubmit={() => revalidatePhoto?.(photo.id, true)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton clearLocalState />
</FormWithConfirm>}
</div>
</Fragment>)}
</AdminTable>

View File

@ -3,16 +3,18 @@
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import InfinitePhotoScroll from '../photo/InfinitePhotoScroll';
import AdminPhotosTable from './AdminPhotosTable';
import { ComponentProps } from 'react';
export default function AdminPhotosTableInfinite({
initialOffset,
itemsPerPage,
hasAiTextGeneration,
canEdit,
canDelete,
}: {
initialOffset: number
itemsPerPage: number
hasAiTextGeneration?: boolean
}) {
} & Omit<ComponentProps<typeof AdminPhotosTable>, 'photos'>) {
return (
<InfinitePhotoScroll
cacheKey={`page-${PATH_ADMIN_PHOTOS}`}
@ -27,6 +29,8 @@ export default function AdminPhotosTableInfinite({
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}
hasAiTextGeneration={hasAiTextGeneration}
canEdit={canEdit}
canDelete={canDelete}
/>}
</InfinitePhotoScroll>
);

View 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>
);
}

View File

@ -1,61 +1,61 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import LoaderButton from '@/components/primitives/LoaderButton';
import { syncPhotoAction } from '@/photo/actions';
import IconGrSync from '@/site/IconGrSync';
import { clsx } from 'clsx/lite';
import { ComponentProps } from 'react';
import { toastSuccess } from '@/toast';
import { ComponentProps, useState } from 'react';
export default function PhotoSyncButton({
action,
label,
onFormSubmit,
formData: { photoId, photoUrl } = {},
photoId,
photoTitle,
onSyncComplete,
className,
isSyncingExternal,
hasAiTextGeneration,
disabled,
shouldConfirm,
shouldToast,
}: {
action: (formData: FormData) => void
label?: string
formData?: {
photoId?: string
photoUrl?: string
}
photoId: string
photoTitle?: string
onSyncComplete?: () => void
isSyncingExternal?: boolean
hasAiTextGeneration?: boolean
shouldConfirm?: boolean
shouldToast?: boolean
} & ComponentProps<typeof SubmitButtonWithStatus>) {
} & ComponentProps<typeof LoaderButton>) {
const [isSyncing, setIsSyncing] = useState(false);
const confirmText = ['Overwrite'];
if (photoTitle) { confirmText.push(`"${photoTitle}"`); }
confirmText.push('data from original file?');
if (hasAiTextGeneration) { confirmText.push(
'AI text will be generated for undefined fields.'); }
confirmText.push('This action cannot be undone.');
return (
<FormWithConfirm
action={action}
confirmText={shouldConfirm ? confirmText.join(' ') : undefined}
>
{photoId &&
<input name="photoId" value={photoId} hidden readOnly />}
{photoUrl &&
<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]',
)} />}
onFormSubmitToastMessage={shouldToast
? photoTitle
? `"${photoTitle}" data synced`
: 'Data synced'
: undefined}
onFormSubmit={onFormSubmit}
>
{label}
</SubmitButtonWithStatus>
</FormWithConfirm>
<LoaderButton
title="Update photo from original file"
className={className}
icon={<IconGrSync
className="translate-y-[0.5px] translate-x-[0.5px]"
/>}
onClick={() => {
if (!shouldConfirm || window.confirm(confirmText.join(' '))) {
setIsSyncing(true);
syncPhotoAction(photoId)
.then(() => {
onSyncComplete?.();
if (shouldToast) {
toastSuccess(photoTitle
? `"${photoTitle}" data synced`
: 'Data synced');
}
})
.finally(() => setIsSyncing(false));
}
}}
isLoading={isSyncing || isSyncingExternal}
disabled={disabled}
/>
);
}

View File

@ -6,7 +6,7 @@ export default async function AdminLayout({
children: React.ReactNode
}) {
return (
<div className="mt-4 space-y-5">
<div className="mt-4 space-y-4">
<AdminNav />
{children}
</div>

View 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,
}} />
);
}

View File

@ -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 { 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 { OUTDATED_THRESHOLD } from '@/photo';
import AdminPhotosClient from '@/admin/AdminPhotosClient';
import { revalidatePath } from 'next/cache';
const DEBUG_PHOTO_BLOBS = false;
@ -20,6 +14,7 @@ export default async function AdminPhotosPage() {
const [
photos,
photosCount,
photosCountOutdated,
blobPhotoUrls,
] = await Promise.all([
getPhotos({
@ -30,46 +25,30 @@ export default async function AdminPhotosPage() {
getPhotosMetaCached({ hidden: 'include'})
.then(({ count }) => count)
.catch(() => 0),
getPhotosMetaCached({
hidden: 'include',
updatedBefore: OUTDATED_THRESHOLD,
})
.then(({ count }) => count)
.catch(() => 0),
DEBUG_PHOTO_BLOBS
? getStoragePhotoUrlsNoStore()
: [],
]);
return (
<SiteGrid
contentMain={
<div className="space-y-4">
<PhotoUpload
shouldResize={!PRO_MODE_ENABLED}
onLastUpload={async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
}}
/>
{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={INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
/>}
</div>
</div>}
/>
<AdminPhotosClient {...{
photos,
photosCount,
photosCountOutdated,
onLastPhotoUpload: async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
},
blobPhotoUrls,
infiniteScrollInitial: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS,
infiniteScrollMultiple: INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS,
}} />
);
}

View File

@ -27,8 +27,8 @@ export default function Banner({
>
<div className="flex items-center gap-2.5">
{icon ?? <IoInformationCircleOutline
size={17}
className="translate-y-[1px]"
size={18}
className="translate-y-[1px] shrink-0"
/>}
{children}
</div>

View File

@ -6,11 +6,13 @@ export default function FormWithConfirm({
action,
confirmText,
onSubmit,
className,
children,
}: {
action: (formData: FormData) => void
confirmText?: string
onSubmit?: () => void
className?: string
children: ReactNode
}) {
return (
@ -24,6 +26,7 @@ export default function FormWithConfirm({
e.preventDefault();
}
}}
className={className}
>
{children}
</form>

View File

@ -5,10 +5,10 @@ import { useRef, useState } from 'react';
import { CopyExif } from '@/lib/CopyExif';
import exifr from 'exifr';
import { clsx } from 'clsx/lite';
import Spinner from './Spinner';
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
import { FiUploadCloud } from 'react-icons/fi';
import { MAX_IMAGE_SIZE } from '@/services/next-image';
import LoaderButton from './primitives/LoaderButton';
const INPUT_ID = 'file';
@ -36,7 +36,8 @@ export default function ImageInput({
showUploadStatus?: boolean
debug?: boolean
}) {
const ref = useRef<HTMLCanvasElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [image, setImage] = useState<HTMLImageElement>();
const [filesLength, setFilesLength] = useState(0);
@ -57,26 +58,24 @@ export default function ImageInput({
loading && 'pointer-events-none cursor-not-allowed',
)}
>
<span
className={clsx(
'button primary normal-case',
loading && 'disabled'
)}
<LoaderButton
type="button"
isLoading={loading}
className="primary"
icon={<FiUploadCloud
size={18}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>}
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
? 'Uploading'
: 'Upload Photos'}
</span>
</LoaderButton>
<input
ref={inputRef}
id={INPUT_ID}
type="file"
className="!hidden"
@ -100,7 +99,7 @@ export default function ImageInput({
const isPng = callbackArgs.extension === 'png';
const canvas = ref.current;
const canvas = canvasRef.current;
// Specify wide gamut to avoid data loss while resizing
const ctx = canvas?.getContext(
@ -227,7 +226,7 @@ export default function ImageInput({
</div>}
</div>
<canvas
ref={ref}
ref={canvasRef}
className={clsx(
'bg-gray-50 dark:bg-gray-900/50 rounded-md',
'border border-gray-200 dark:border-gray-800',

View File

@ -4,13 +4,18 @@ import { clsx } from 'clsx/lite';
export default function ResponsiveDate({
date,
className,
titleLabel,
}: {
date: Date
className?: string
titleLabel?: string
}) {
const title = titleLabel
? `${titleLabel}: ${formatDate(date).toLocaleUpperCase()}`
: formatDate(date).toLocaleUpperCase();
return (
<span
title={formatDate(date).toLocaleUpperCase()}
title={title}
className={clsx(className, 'uppercase')}
>
{/* Small */}

View File

@ -6,13 +6,6 @@ import { clsx } from 'clsx/lite';
import { toastSuccess } from '@/toast';
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({
icon,
styleAs,
@ -26,7 +19,12 @@ export default function SubmitButtonWithStatus({
primary,
type: _type,
...buttonProps
}: Props) {
}: {
onFormStatusChange?: (pending: boolean) => void
onFormSubmitToastMessage?: string
onFormSubmit?: () => void
primary?: boolean
} & ComponentProps<typeof LoaderButton>) {
const { pending } = useFormStatus();
const pendingPrevious = useRef(pending);

View File

@ -1,3 +1,5 @@
'use client';
import Spinner, { SpinnerColor } from '@/components/Spinner';
import { clsx } from 'clsx/lite';
import { ButtonHTMLAttributes, ReactNode } from 'react';
@ -42,7 +44,7 @@ export default function LoaderButton(props: {
: ['h-9']),
styleAs === 'link' && 'hover:text-dim',
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,
)}
disabled={isLoading || disabled}
@ -51,7 +53,7 @@ export default function LoaderButton(props: {
<span className={clsx(
'min-w-[1.25rem] h-4',
styleAs === 'button' ? 'translate-y-[-0.5px]' : 'translate-y-[0.5px]',
'inline-flex justify-center',
'inline-flex justify-center shrink-0',
)}>
{isLoading
? <Spinner

View File

@ -8,6 +8,7 @@ import LoaderButton from '@/components/primitives/LoaderButton';
export default function PathLoaderButton({
path,
icon,
title,
prefetch,
loaderDelay = 100,
shouldScroll = true,
@ -21,6 +22,7 @@ export default function PathLoaderButton({
}: {
path: string
icon?: ReactNode
title?: string
prefetch?: boolean
loaderDelay?: number
shouldScroll?: boolean
@ -58,6 +60,7 @@ export default function PathLoaderButton({
return (
<LoaderButton
icon={icon}
title={title}
className={className}
onClick={() => {
startTransition(() => {

View File

@ -3,17 +3,44 @@ import { Photo } from '.';
import { useMemo } from 'react';
export default function PhotoDate({
photo: { takenAtNaive },
photo,
className,
dateType = 'takenAt',
}: {
photo: Photo
className?: string
dateType?: 'takenAt' | 'createdAt' | 'updatedAt'
}) {
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;
}, [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 (
<ResponsiveDate {...{ date, className }} />
<ResponsiveDate {...{
date,
className,
titleLabel: getTitleLabel(),
}} />
);
}

View File

@ -14,7 +14,7 @@ import { getExifDataAction } from './actions';
import { TagsWithMeta } from '@/tag';
import AiButton from './ai/AiButton';
import usePhotoFormParent from './form/usePhotoFormParent';
import PhotoSyncButton from '@/admin/PhotoSyncButton';
import ExifSyncButton from '@/admin/ExifSyncButton';
export default function PhotoEditPageClient({
photo,
@ -68,10 +68,10 @@ export default function PhotoEditPageClient({
<div className="flex gap-2">
{hasAiTextGeneration &&
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
<PhotoSyncButton
<ExifSyncButton
action={action}
label="EXIF"
formData={{ photoUrl: photo.url }}
photoUrl={photo.url}
/>
</div>}
isLoading={pending}

View File

@ -43,7 +43,7 @@ export default function PhotoSmall({
'active:brightness-75',
selected && 'brightness-50',
'min-w-[50px]',
'rounded-[0.15rem] overflow-hidden',
'rounded-[3px] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
prefetch={prefetch}

View File

@ -10,15 +10,18 @@ import { clsx } from 'clsx/lite';
export default function PhotoUpload({
shouldResize,
onLastUpload,
isUploading,
setIsUploading,
showUploadStatus,
debug,
}: {
shouldResize?: boolean
onLastUpload?: () => Promise<void>
isUploading: boolean
setIsUploading: (isUploading: boolean) => void
showUploadStatus?: boolean
debug?: boolean
}) {
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string>();
const [debugDownload, setDebugDownload] = useState<{
href: string

View File

@ -291,9 +291,8 @@ export const getExifDataAction = async (
// - strip GPS data if necessary
// - update blur data (or destroy if blur is disabled)
// - 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 () => {
const photoId = formData.get('photoId') as string | undefined;
const photo = await getPhoto(photoId ?? '', true);
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 () =>
runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths);

View File

@ -62,7 +62,8 @@ const getPhotosCacheKeyForOption = (
return value ? `${option}-${createLensKey(value)}` : null;
}
case 'takenBefore':
case 'takenAfterInclusive': {
case 'takenAfterInclusive':
case 'updatedBefore': {
const value = options[option];
return value ? `${option}-${value.toISOString()}` : null;
}

View File

@ -8,7 +8,7 @@ export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100;
export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'takenAt' | 'priority'
sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority'
limit?: number
offset?: number
query?: string
@ -19,6 +19,7 @@ export type GetPhotosOptions = {
focal?: number
takenBefore?: Date
takenAfterInclusive?: Date
updatedBefore?: Date
hidden?: 'exclude' | 'include' | 'only'
};
@ -30,6 +31,7 @@ export const getWheresFromOptions = (
hidden = 'exclude',
takenBefore,
takenAfterInclusive,
updatedBefore,
query,
tag,
camera,
@ -52,13 +54,17 @@ export const getWheresFromOptions = (
}
if (takenBefore) {
wheres.push(`taken_at > $${valuesIndex++}`);
wheres.push(`taken_at < $${valuesIndex++}`);
wheresValues.push(takenBefore.toISOString());
}
if (takenAfterInclusive) {
wheres.push(`taken_at <= $${valuesIndex++}`);
wheres.push(`taken_at >= $${valuesIndex++}`);
wheresValues.push(takenAfterInclusive.toISOString());
}
if (updatedBefore) {
wheres.push(`updated_at < $${valuesIndex++}`);
wheresValues.push(updatedBefore.toISOString());
}
if (query) {
// eslint-disable-next-line max-len
wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`);
@ -106,6 +112,8 @@ export const getOrderByFromOptions = (options: GetPhotosOptions) => {
switch (sortBy) {
case 'createdAt':
return 'ORDER BY created_at DESC';
case 'createdAtAsc':
return 'ORDER BY created_at ASC';
case 'takenAt':
return 'ORDER BY taken_at DESC';
case 'priority':

View File

@ -14,6 +14,8 @@ import camelcaseKeys from 'camelcase-keys';
import { isBefore } from 'date-fns';
import type { Metadata } from 'next';
export const OUTDATED_THRESHOLD = new Date('2024-06-16');
// INFINITE SCROLL: LARGE PHOTOS
export const INFINITE_SCROLL_LARGE_PHOTO_INITIAL =
process.env.NODE_ENV === 'development' ? 2 : 12;

View File

@ -67,6 +67,7 @@
input[type=checkbox] {
@apply
rounded-md
dark:bg-transparent
}
.error {
@apply
@ -98,6 +99,7 @@
text-invert
bg-gray-900 dark:bg-gray-100
disabled:text-dim
font-medium
disabled:bg-gray-100 dark:disabled:bg-gray-900
disabled:border-gray-200 disabled:dark:border-gray-700
border-gray-900 dark:border-gray-100

View File

@ -30,6 +30,7 @@ const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
// Admin paths
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_TAGS = `${PATH_ADMIN}/tags`;
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;