Merge pull request #81 from sambecker/static

Add static optimizations
This commit is contained in:
Sam Becker 2024-04-27 22:51:48 -05:00 committed by GitHub
commit 01c123dae7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
117 changed files with 3237 additions and 2455 deletions

View File

@ -89,7 +89,7 @@ _⚠ READ BEFORE PROCEEDING_
Application behavior can be changed by configuring the following environment variables:
- `NEXT_PUBLIC_PRO_MODE = 1` enables higher quality image storage for jpgs (will result in increased storage usage)
- `NEXT_PUBLIC_PRO_MODE = 1` enables higher quality image storage (results in increased storage usage)
- `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order

View File

@ -9,48 +9,49 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@aws-sdk/client-s3": "3.556.0",
"@aws-sdk/s3-request-presigner": "3.556.0",
"@next/bundle-analyzer": "14.2.2",
"@aws-sdk/client-s3": "3.564.0",
"@aws-sdk/s3-request-presigner": "3.564.0",
"@next/bundle-analyzer": "14.2.3",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^15.0.2",
"@testing-library/react": "^15.0.5",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.7",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"@upstash/ratelimit": "^1.1.2",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"@upstash/ratelimit": "^1.1.3",
"@vercel/analytics": "^1.2.2",
"@vercel/blob": "^0.23.2",
"@vercel/kv": "^1.0.1",
"@vercel/postgres": "0.8.0",
"@vercel/postgres": "^0.8.0",
"@vercel/speed-insights": "^1.0.10",
"ai": "^3.0.24",
"ai": "^3.0.34",
"autoprefixer": "10.4.19",
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"eslint": "8.57.0",
"eslint-config-next": "14.2.2",
"eslint-config-next": "14.2.3",
"exifr": "^7.1.3",
"framer-motion": "^11.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.7",
"next": "14.2.2",
"next": "^14.2.3",
"next-auth": "5.0.0-beta.15",
"next-themes": "^0.3.0",
"openai": "^4.38.2",
"openai": "^4.38.5",
"postcss": "8.4.38",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-icons": "^5.1.0",
"sonner": "^1.4.41",
"swr": "^2.2.5",
"tailwindcss": "3.4.3",
"ts-exif-parser": "^0.2.2",
"typescript": "5.4.5",

2585
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

25
src/admin/AdminCTA.tsx Normal file
View File

@ -0,0 +1,25 @@
'use client';
import PhotoUpload from '@/photo/PhotoUpload';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { useAppState } from '@/state/AppState';
import Link from 'next/link';
import { FaArrowRight } from 'react-icons/fa';
export default function AdminCTA() {
const { isUserSignedIn } = useAppState();
return (
<div className="flex justify-center pt-4">
{isUserSignedIn
? <PhotoUpload showUploadStatus={false} />
: <Link
href={PATH_ADMIN_PHOTOS}
className="button primary"
>
<span>Admin Dashboard</span>
<FaArrowRight size={10} />
</Link>}
</div>
);
}

View File

@ -1,65 +1,58 @@
'use client';
import SiteGrid from '@/components/SiteGrid';
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import {
PATH_ADMIN_CONFIGURATION,
checkPathPrefix,
isPathAdminConfiguration,
getPhotosCountIncludingHiddenCached,
getPhotosMostRecentUpdateCached,
getUniqueTagsCached,
} from '@/photo/cache';
import {
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
} from '@/site/paths';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BiCog } from 'react-icons/bi';
import AdminNavClient from './AdminNavClient';
export default function AdminNav({
items,
}: {
items: {
label: string,
href: string,
count: number,
}[]
}) {
const pathname = usePathname();
export default async function AdminNav() {
const [
countPhotos,
countUploads,
countTags,
mostRecentUpdate,
] = await Promise.all([
getPhotosCountIncludingHiddenCached().catch(() => 0),
getStorageUploadUrlsNoStore()
.then(urls => urls.length)
.catch(e => {
console.error(`Error getting blob upload urls: ${e}`);
return 0;
}),
getUniqueTagsCached().then(tags => tags.length).catch(() => 0),
getPhotosMostRecentUpdateCached(),
]);
const navItemPhotos = {
label: 'Photos',
href: PATH_ADMIN_PHOTOS,
count: countPhotos,
};
const navItemUploads = {
label: 'Uploads',
href: PATH_ADMIN_UPLOADS,
count: countUploads,
};
const navItemTags = {
label: 'Tags',
href: PATH_ADMIN_TAGS,
count: countTags,
};
const items = [navItemPhotos];
if (countUploads > 0) { items.push(navItemUploads); }
if (countTags > 0) { items.push(navItemTags); }
return (
<SiteGrid
contentMain={
<div className={clsx(
'flex gap-2 md:gap-4',
'border-b border-gray-200 dark:border-gray-800 pb-3',
)}>
<div className={clsx(
'flex gap-2 md:gap-4',
'flex-grow overflow-x-auto',
)}>
{items.map(({ label, href, count }) =>
<Link
key={label}
href={href}
className={clsx(
'flex gap-0.5',
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
)}
>
<span>{label}</span>
<span>({count})</span>
</Link>)}
</div>
<Link
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
>
<BiCog
size={18}
className="inline-block"
aria-label="App Configuration"
/>
</Link>
</div>
}
/>
<AdminNavClient {...{ items, mostRecentUpdate }} />
);
}

View File

@ -0,0 +1,93 @@
'use client';
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid';
import {
PATH_ADMIN_CONFIGURATION,
checkPathPrefix,
isPathAdminConfiguration,
} from '@/site/paths';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { differenceInMinutes } from 'date-fns';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { BiCog } from 'react-icons/bi';
import { FaRegClock } from 'react-icons/fa';
const RECENCY_THRESHOLD = 5;
export default function AdminNavClient({
items,
mostRecentUpdate,
}: {
items: {
label: string,
href: string,
count: number,
}[]
mostRecentUpdate?: Date
}) {
const pathname = usePathname();
const { adminUpdates = [] } = useAppState();
const shouldShowBanner = useMemo(() =>
((mostRecentUpdate ? [mostRecentUpdate] : []).concat(adminUpdates))
.some(date => differenceInMinutes(new Date(), date) < RECENCY_THRESHOLD)
, [mostRecentUpdate, adminUpdates]);
return (
<SiteGrid
contentMain={
<div className="space-y-5">
<div className={clsx(
'flex gap-2 md:gap-4',
'border-b border-gray-200 dark:border-gray-800 pb-3',
)}>
<div className={clsx(
'flex gap-2 md:gap-4',
'flex-grow overflow-x-auto',
)}>
{items.map(({ label, href, count }) =>
<Link
key={label}
href={href}
className={clsx(
'flex gap-0.5',
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
)}
prefetch={false}
>
<span>{label}</span>
{count > 0 &&
<span>({count})</span>}
</Link>)}
</div>
<Link
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
>
<BiCog
size={18}
className="inline-block"
aria-label="App Configuration"
/>
</Link>
</div>
{shouldShowBanner &&
<InfoBlock centered={false} padding="tight" color="blue">
<div className="flex items-center gap-3">
<FaRegClock className="flex-shrink-0" />
Updates detectedthey may take several minutes to show up
for visitors
</div>
</InfoBlock>}
</div>
}
/>
);
}

View File

@ -1,11 +1,11 @@
import { authCached } from '@/auth/cache';
import { authCachedSafe } from '@/auth/cache';
import AdminPhotoMenuClient from './AdminPhotoMenuClient';
import { ComponentProps } from 'react';
export default async function AdminPhotoMenu(
props: ComponentProps<typeof AdminPhotoMenuClient>,
) {
const session = await authCached();
const session = await authCachedSafe();
return Boolean(session?.user?.email)
? <AdminPhotoMenuClient {...props} />
: null;

View File

@ -9,21 +9,27 @@ import { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/MoreMenu';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
export default function AdminPhotoMenuClient({
photo,
revalidatePhoto,
...props
}: Omit<ComponentProps<typeof MoreMenu>, 'items'> & {
photo: Photo
revalidatePhoto?: RevalidatePhoto
}) {
const { isUserSignedIn, addAdminUpdate } = useAppState();
const isFav = isPhotoFav(photo);
const path = usePathname();
const shouldRedirectFav = isPathFavs(path) && isFav;
const shouldRedirectDelete = pathForPhoto(photo.id) === path;
return (
<>
<MoreMenu {...{
isUserSignedIn
? <MoreMenu {...{
items: [
{
label: 'Edit',
@ -43,7 +49,7 @@ export default function AdminPhotoMenuClient({
action: () => toggleFavoritePhotoAction(
photo.id,
shouldRedirectFav,
),
).then(() => revalidatePhoto?.(photo.id)),
}, {
label: 'Delete',
icon: <BiTrash
@ -56,13 +62,16 @@ export default function AdminPhotoMenuClient({
photo.id,
photo.url,
shouldRedirectDelete,
);
).then(() => {
revalidatePhoto?.(photo.id, true);
addAdminUpdate?.();
});
}
},
},
],
...props,
}}/>
</>
: null
);
}

View File

@ -0,0 +1,103 @@
'use client';
import { Photo, deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo';
import AdminTable from './AdminTable';
import { Fragment } from 'react';
import PhotoTiny from '@/photo/PhotoTiny';
import { clsx } from 'clsx/lite';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths';
import Link from 'next/link';
import { AiOutlineEyeInvisible } from 'react-icons/ai';
import PhotoDate from '@/photo/PhotoDate';
import FormWithConfirm from '@/components/FormWithConfirm';
import EditButton from './EditButton';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import IconGrSync from '@/site/IconGrSync';
import DeleteButton from './DeleteButton';
import {
deletePhotoFormAction,
syncPhotoExifDataAction,
} from '@/photo/actions';
import { useAppState } from '@/state/AppState';
export default function AdminPhotoTable({
photos,
}: {
photos: Photo[],
}) {
const { invalidateSwr } = useAppState();
return (
<AdminTable>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny photo={photo} />
<div className="flex flex-col lg:flex-row">
<Link
key={photo.id}
href={pathForPhoto(photo)}
className="lg:w-[50%] flex items-center gap-2"
prefetch={false}
>
<span className={clsx(
'inline-flex items-center gap-2',
photo.hidden && 'text-dim',
)}>
<span>{photo.title || 'Untitled'}</span>
{photo.hidden &&
<AiOutlineEyeInvisible
className="translate-y-[0.25px]"
size={16}
/>}
</span>
{photo.priorityOrder !== null &&
<span className={clsx(
'text-xs leading-none px-1.5 py-1 rounded-sm',
'dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
)}>
{photo.priorityOrder}
</span>}
</Link>
<div className={clsx(
'lg:w-[50%] uppercase',
'text-dim',
)}>
<PhotoDate {...{ photo }} />
</div>
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm
action={syncPhotoExifDataAction}
confirmText={
'Are you sure you want to overwrite EXIF data ' +
`for "${titleForPhoto(photo)}" from source file? ` +
'This action cannot be undone.'
}
>
<input type="hidden" name="id" value={photo.id} />
<SubmitButtonWithStatus
icon={<IconGrSync className="translate-y-[-0.5px]" />}
onFormSubmitToastMessage={`
"${titleForPhoto(photo)}" EXIF data synced
`}
onFormSubmit={invalidateSwr}
/>
</FormWithConfirm>
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton clearLocalState />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminTable>
);
}

View File

@ -1,7 +1,7 @@
import { clsx } from 'clsx/lite';
import { ReactNode } from 'react';
export default function AdminGrid ({
export default function AdminTable ({
title,
children,
}: {

View File

@ -0,0 +1,40 @@
import PhotoTag from '@/tag/PhotoTag';
import { photoLabelForCount } from '@/photo';
import { clsx } from 'clsx/lite';
import FavsTag from '@/tag/FavsTag';
import { isTagFavs } from '@/tag';
import Badge from '@/components/Badge';
export default function AdminTagBadge({
tag,
count,
hideBadge,
}: {
tag: string,
count: number,
hideBadge?: boolean,
}) {
const renderBadgeContent = () =>
<div className={clsx(
'inline-flex items-center gap-2',
// Fix nested EntityLink-in-Badge quirk for tags
'[&>*>*:first-child]:items-center',
)}>
{isTagFavs(tag)
? <FavsTag />
: <PhotoTag {...{ tag }} />}
<div className="text-dim uppercase">
<span>{count}</span>
<span className="hidden xs:inline-block">
&nbsp;
{photoLabelForCount(count)}
</span>
</div>
</div>;
return (
hideBadge
? renderBadgeContent()
: <Badge>{renderBadgeContent()}</Badge>
);
}

View File

@ -0,0 +1,43 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import { deletePhotoTagGloballyAction } from '@/photo/actions';
import AdminTable from '@/admin/AdminTable';
import { Fragment } from 'react';
import DeleteButton from '@/admin/DeleteButton';
import { photoQuantityText } from '@/photo';
import { TagsWithMeta, formatTag, sortTagsObject } from '@/tag';
import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/site/paths';
import { clsx } from 'clsx/lite';
import AdminTagBadge from './AdminTagBadge';
export default function AdminTagTable({
tags,
}: {
tags: TagsWithMeta
}) {
return (
<AdminTable>
{sortTagsObject(tags).map(({ tag, count }) =>
<Fragment key={tag}>
<div className="pr-2 col-span-2">
<AdminTagBadge {...{ tag, count }} />
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminTagEdit(tag)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`}
>
<input type="hidden" name="tag" value={tag} />
<DeleteButton clearLocalState />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminTable>
);
}

View File

@ -0,0 +1,21 @@
'use client';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { syncCacheAction } from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { BiTrash } from 'react-icons/bi';
export default function ClearCacheButton() {
const { invalidateSwr } = useAppState();
return (
<form action={syncCacheAction}>
<SubmitButtonWithStatus
icon={<BiTrash />}
onFormSubmit={invalidateSwr}
>
Clear Cache
</SubmitButtonWithStatus>
</form>
);
}

View File

@ -1,9 +1,34 @@
'use client';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { ComponentProps, useCallback } from 'react';
import { BiTrash } from 'react-icons/bi';
export default function DeleteButton () {
export default function DeleteButton (
props: ComponentProps<typeof SubmitButtonWithStatus> & {
clearLocalState?: boolean
}
) {
const {
onFormSubmit: onFormSubmitProps,
clearLocalState,
...rest
} = props;
const { invalidateSwr, addAdminUpdate } = useAppState();
const onFormSubmit = useCallback(() => {
onFormSubmitProps?.();
if (clearLocalState) {
invalidateSwr?.();
addAdminUpdate?.();
}
}, [onFormSubmitProps, clearLocalState, invalidateSwr, addAdminUpdate]);
return <SubmitButtonWithStatus
{...rest}
title="Delete"
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
spinnerColor="text"
@ -13,5 +38,6 @@ export default function DeleteButton () {
'!border-red-200 hover:!border-red-300',
'dark:!border-red-900/75 dark:hover:!border-red-900',
)}
onFormSubmit={onFormSubmit}
/>;
}

View File

@ -13,6 +13,7 @@ export default function EditButton ({
title={label}
href={href}
className="button"
prefetch={false}
>
<FaRegEdit className="translate-y-[-0.5px]" />
<span className="hidden sm:inline-block">

View File

@ -1,5 +1,5 @@
import { Fragment } from 'react';
import AdminGrid from './AdminGrid';
import AdminTable from './AdminTable';
import Link from 'next/link';
import ImageTiny from '@/components/ImageTiny';
import { StorageListResponse, fileNameForStorageUrl } from '@/services/storage';
@ -19,12 +19,12 @@ export default function StorageUrls({
urls: StorageListResponse
}) {
return (
<AdminGrid {...{ title }} >
<AdminTable {...{ title }} >
{urls.map(({ url, uploadedAt }) => {
const addUploadPath = pathForAdminUploadUrl(url);
const uploadFileName = fileNameForStorageUrl(url);
return <Fragment key={url}>
<Link href={addUploadPath}>
<Link href={addUploadPath} prefetch={false}>
<ImageTiny
alt={`Upload: ${uploadFileName}`}
src={url}
@ -41,6 +41,7 @@ export default function StorageUrls({
title={uploadedAt
? `${url} @ ${formatDate(uploadedAt, 'yyyy-MM-dd HH:mm:ss')}`
: url}
prefetch={false}
>
{uploadFileName}
</Link>
@ -69,6 +70,6 @@ export default function StorageUrls({
</FormWithConfirm>
</div>
</Fragment>;})}
</AdminGrid>
</AdminTable>
);
}

View File

@ -6,7 +6,7 @@ import SiteGrid from '@/components/SiteGrid';
import EntityLink from '@/components/primitives/EntityLink';
import LabeledIcon from '@/components/primitives/LabeledIcon';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { useAppState } from '@/state';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { useState } from 'react';
import { FaCamera, FaHandSparkles, FaUserAltSlash } from 'react-icons/fa';

View File

@ -1,9 +1,7 @@
import ClearCacheButton from '@/admin/ClearCacheButton';
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { syncCacheAction } from '@/photo/actions';
import SiteChecklist from '@/site/SiteChecklist';
import { BiTrash } from 'react-icons/bi';
export default async function AdminConfigurationPage() {
return (
@ -14,13 +12,7 @@ export default async function AdminConfigurationPage() {
<div className="flex-grow">
App Configuration
</div>
<form action={syncCacheAction}>
<SubmitButtonWithStatus
icon={<BiTrash />}
>
Clear Cache
</SubmitButtonWithStatus>
</form>
<ClearCacheButton />
</div>
<InfoBlock>
<SiteChecklist />

View File

@ -1,61 +1,13 @@
import AdminNav from '@/admin/AdminNav';
import {
getPhotosCountIncludingHiddenCached,
getUniqueTagsCached,
} from '@/photo/cache';
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import {
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
} from '@/site/paths';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const [
countPhotos,
countUploads,
countTags,
] = await Promise.all([
getPhotosCountIncludingHiddenCached(),
getStorageUploadUrlsNoStore()
.then(urls => urls.length)
.catch(e => {
console.error(`Error getting blob upload urls: ${e}`);
return 0;
}),
getUniqueTagsCached().then(tags => tags.length),
]);
const navItemPhotos = {
label: 'Photos',
href: PATH_ADMIN_PHOTOS,
count: countPhotos,
};
const navItemUploads = {
label: 'Uploads',
href: PATH_ADMIN_UPLOADS,
count: countUploads,
};
const navItemTags = {
label: 'Tags',
href: PATH_ADMIN_TAGS,
count: countTags,
};
const navItems = [navItemPhotos];
if (countUploads > 0) { navItems.push(navItemUploads); }
if (countTags > 0) { navItems.push(navItemTags); }
return (
<div className="mt-4 space-y-5">
<AdminNav items={navItems} />
<AdminNav />
{children}
</div>
);

View File

@ -1,54 +1,34 @@
import { Fragment } from 'react';
import PhotoUpload from '@/photo/PhotoUpload';
import Link from 'next/link';
import PhotoTiny from '@/photo/PhotoTiny';
import { clsx } from 'clsx/lite';
import FormWithConfirm from '@/components/FormWithConfirm';
import SiteGrid from '@/components/SiteGrid';
import {
deletePhotoFormAction,
syncPhotoExifDataAction,
} from '@/photo/actions';
import {
pathForAdminPhotos,
pathForPhoto,
pathForAdminPhotoEdit,
} from '@/site/paths';
import { deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo';
import MorePhotos from '@/photo/MorePhotos';
import {
getPhotosCached,
getPhotosCountIncludingHiddenCached,
} from '@/photo/cache';
import { AiOutlineEyeInvisible } from 'react-icons/ai';
import { pathForAdminPhotos } from '@/site/paths';
import { getPhotosCountIncludingHiddenCached } from '@/photo/cache';
import {
PaginationParams,
getPaginationForSearchParams,
getPaginationFromSearchParams,
} from '@/site/pagination';
import AdminGrid from '@/admin/AdminGrid';
import DeleteButton from '@/admin/DeleteButton';
import EditButton from '@/admin/EditButton';
import StorageUrls from '@/admin/StorageUrls';
import { PRO_MODE_ENABLED } from '@/site/config';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import IconGrSync from '@/site/IconGrSync';
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import PhotoDate from '@/photo/PhotoDate';
import MoreComponentsFromSearchParams from
'@/components/MoreComponentsFromSearchParams';
import { getPhotos } from '@/services/vercel-postgres';
import { revalidatePath } from 'next/cache';
import AdminPhotoTable from '@/admin/AdminPhotoTable';
const DEBUG_PHOTO_BLOBS = false;
export default async function AdminPhotosPage({
searchParams,
}: PaginationParams) {
const { offset, limit } = getPaginationForSearchParams(searchParams);
const { offset, limit } = getPaginationFromSearchParams(searchParams);
const [
photos,
count,
blobPhotoUrls,
] = await Promise.all([
getPhotosCached({ includeHidden: true, sortBy: 'createdAt', limit }),
getPhotos({ includeHidden: true, sortBy: 'createdAt', limit }),
getPhotosCountIncludingHiddenCached(),
DEBUG_PHOTO_BLOBS ? getStoragePhotoUrlsNoStore() : [],
]);
@ -58,7 +38,7 @@ export default async function AdminPhotosPage({
return (
<SiteGrid
contentMain={
<div className="space-y-8">
<div className="space-y-4">
<PhotoUpload
shouldResize={!PRO_MODE_ENABLED}
onLastUpload={async () => {
@ -78,77 +58,12 @@ export default async function AdminPhotosPage({
/>
</div>}
<div className="space-y-4">
<AdminGrid>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny photo={photo} />
<div className="flex flex-col lg:flex-row">
<Link
key={photo.id}
href={pathForPhoto(photo)}
className="lg:w-[50%] flex items-center gap-2"
>
<span className={clsx(
'inline-flex items-center gap-2',
photo.hidden && 'text-dim',
)}>
<span>{photo.title || 'Untitled'}</span>
{photo.hidden &&
<AiOutlineEyeInvisible
className="translate-y-[0.25px]"
size={16}
/>}
</span>
{photo.priorityOrder !== null &&
<span className={clsx(
'text-xs leading-none px-1.5 py-1 rounded-sm',
'dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
)}>
{photo.priorityOrder}
</span>}
</Link>
<div className={clsx(
'lg:w-[50%] uppercase',
'text-dim',
)}>
<PhotoDate {...{ photo }} />
</div>
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm
action={syncPhotoExifDataAction}
confirmText={
'Are you sure you want to overwrite EXIF data ' +
`for "${titleForPhoto(photo)}" from source file? ` +
'This action cannot be undone.'
}
>
<input type="hidden" name="id" value={photo.id} />
<SubmitButtonWithStatus
icon={<IconGrSync className="translate-y-[-0.5px]" />}
onFormSubmitToastMessage={`
"${titleForPhoto(photo)}" EXIF data synced
`}
/>
</FormWithConfirm>
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminGrid>
<AdminPhotoTable photos={photos} />
{showMorePhotos &&
<MorePhotos path={pathForAdminPhotos(offset + 1)} />}
<MoreComponentsFromSearchParams
label="More photos"
path={pathForAdminPhotos(offset + 1)}
/>}
</div>
</div>}
/>

View File

@ -1,14 +1,11 @@
import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getPhotosCached, getPhotosTagCountCached } from '@/photo/cache';
import { getPhotosCached } from '@/photo/cache';
import TagForm from '@/tag/TagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/site/paths';
import PhotoTag from '@/tag/PhotoTag';
import { photoLabelForCount } from '@/photo';
import PhotoLightbox from '@/photo/PhotoLightbox';
import FavsTag from '@/tag/FavsTag';
import { isTagFavs } from '@/tag';
import { clsx } from 'clsx/lite';
import { getPhotosTagMeta } from '@/services/vercel-postgres';
import AdminTagBadge from '@/admin/AdminTagBadge';
const MAX_PHOTO_TO_SHOW = 6;
@ -22,10 +19,10 @@ export default async function PhotoPageEdit({
const tag = decodeURIComponent(tagFromParams);
const [
count,
{ count },
photos,
] = await Promise.all([
getPhotosTagCountCached(tag),
getPhotosTagMeta(tag),
getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW }),
]);
@ -35,22 +32,7 @@ export default async function PhotoPageEdit({
<AdminChildPage
backPath={PATH_ADMIN_TAGS}
backLabel="Tags"
breadcrumb={<div className={clsx(
'flex items-center gap-2',
// Fix nested EntityLink-in-Badge quirk for tags
'[&>*>*:first-child]:items-center',
)}>
{isTagFavs(tag)
? <FavsTag />
: <PhotoTag {...{ tag }} />}
<div className="text-dim uppercase">
<span>{count}</span>
<span className="hidden xs:inline-block">
&nbsp;
{photoLabelForCount(count)}
</span>
</div>
</div>}
breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />}
>
<TagForm {...{ tag, photos }}>
<PhotoLightbox

View File

@ -1,17 +1,6 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import AdminTagTable from '@/admin/AdminTagTable';
import SiteGrid from '@/components/SiteGrid';
import { deletePhotoTagGloballyAction } from '@/photo/actions';
import AdminGrid from '@/admin/AdminGrid';
import { Fragment } from 'react';
import DeleteButton from '@/admin/DeleteButton';
import { photoQuantityText } from '@/photo';
import { getUniqueTagsHiddenCached } from '@/photo/cache';
import PhotoTag from '@/tag/PhotoTag';
import { formatTag, isTagFavs, sortTagsObject } from '@/tag';
import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/site/paths';
import { clsx } from 'clsx/lite';
import FavsTag from '@/tag/FavsTag';
export default async function AdminTagsPage() {
const tags = await getUniqueTagsHiddenCached();
@ -21,34 +10,7 @@ export default async function AdminTagsPage() {
contentMain={
<div className="space-y-6">
<div className="space-y-4">
<AdminGrid>
{sortTagsObject(tags).map(({ tag, count }) =>
<Fragment key={tag}>
<div className="pr-2 -translate-y-0.5">
{isTagFavs(tag)
? <FavsTag />
: <PhotoTag {...{ tag }} />}
</div>
<div className="text-dim uppercase">
{photoQuantityText(count, false)}
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminTagEdit(tag)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`}
>
<input type="hidden" name="tag" value={tag} />
<DeleteButton />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminGrid>
<AdminTagTable {...{ tags }} />
</div>
</div>}
/>

View File

@ -1,2 +1 @@
export { GET, POST } from '@/auth';
export const runtime = 'edge';

View File

@ -6,8 +6,6 @@ import {
SITE_TITLE,
} from '@/site/config';
export const runtime = 'edge';
export async function GET() {
if (PUBLIC_API_ENABLED) {
const photos = await getPhotosCached({ limit: API_PHOTO_REQUEST_LIMIT });

View File

@ -10,8 +10,6 @@ import {
import { CURRENT_STORAGE } from '@/site/config';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export const runtime = 'edge';
export async function GET(
_: Request,
{ params: { key } }: { params: { key: string } },

View File

@ -2,7 +2,7 @@ import {
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
import { Metadata } from 'next/types';
import { redirect } from 'next/navigation';
import {
PATH_ROOT,
@ -11,10 +11,13 @@ import {
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached } from '@/photo/cache';
import { ReactNode } from 'react';
import { ReactNode, cache } from 'react';
import { FilmSimulation } from '@/simulation';
import { getPhotosFilmSimulationDataCached } from '@/simulation/data';
const getPhotoCachedCached =
cache((photoId: string) => getPhotoCached(photoId));
interface PhotoFilmSimulationProps {
params: { photoId: string, simulation: FilmSimulation }
}
@ -22,7 +25,7 @@ interface PhotoFilmSimulationProps {
export async function generateMetadata({
params: { photoId, simulation },
}: PhotoFilmSimulationProps): Promise<Metadata> {
const photo = await getPhotoCached(photoId);
const photo = await getPhotoCachedCached(photoId);
if (!photo) { return {}; }
@ -53,14 +56,13 @@ export default async function PhotoFilmSimulationPage({
params: { photoId, simulation },
children,
}: PhotoFilmSimulationProps & { children: ReactNode }) {
const photo = await getPhotoCached(photoId);
const photo = await getPhotoCachedCached(photoId);
if (!photo) { redirect(PATH_ROOT); }
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosFilmSimulationDataCached({ simulation });
return <>

View File

@ -10,8 +10,6 @@ import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
export const runtime = 'edge';
export async function GET(
_: Request,
context: { params: { simulation: FilmSimulation } },

View File

@ -6,7 +6,7 @@ import {
getPhotosFilmSimulationDataCachedWithPagination,
} from '@/simulation/data';
import { PaginationParams } from '@/site/pagination';
import { Metadata } from 'next';
import { Metadata } from 'next/types';
interface FilmSimulationProps {
params: { simulation: FilmSimulation }
@ -17,8 +17,7 @@ export async function generateMetadata({
}: FilmSimulationProps): Promise<Metadata> {
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosFilmSimulationDataCached({
simulation,
limit: GRID_THUMBNAILS_TO_SHOW_MAX,

View File

@ -7,7 +7,7 @@ import {
getPhotosFilmSimulationDataCachedWithPagination,
} from '@/simulation/data';
import { PaginationParams } from '@/site/pagination';
import { Metadata } from 'next';
import { Metadata } from 'next/types';
interface FilmSimulationProps {
params: { simulation: FilmSimulation }
@ -18,8 +18,7 @@ export async function generateMetadata({
}: FilmSimulationProps): Promise<Metadata> {
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosFilmSimulationDataCached({
simulation,
limit: GRID_THUMBNAILS_TO_SHOW_MAX,

View File

@ -1,28 +1,26 @@
import { getPhotosCached } from '@/photo/cache';
import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos } from '@/photo';
import {
INFINITE_SCROLL_INITIAL_GRID,
INFINITE_SCROLL_MULTIPLE_GRID,
generateOgImageMetaForPhotos,
} from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response';
import { pathForGrid } from '@/site/paths';
import { Metadata } from 'next';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { Metadata } from 'next/types';
import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { getPhotoSidebarDataCached } from '@/photo/data';
import InfinitePhotoScroll from '@/photo/InfinitePhotoScroll';
export const runtime = 'edge';
export const dynamic = 'force-static';
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG });
return generateOgImageMetaForPhotos(photos);
}
export default async function GridPage({ searchParams }: PaginationParams) {
const { offset, limit } = getPaginationForSearchParams(searchParams);
export default async function GridPage() {
const [
photos,
photosCount,
@ -30,18 +28,22 @@ export default async function GridPage({ searchParams }: PaginationParams) {
cameras,
simulations,
] = await Promise.all([
getPhotosCached({ limit }),
getPhotosCached({ limit: INFINITE_SCROLL_INITIAL_GRID }),
...getPhotoSidebarDataCached(),
]);
const showMorePath = photosCount > photos.length
? pathForGrid(offset + 1)
: undefined;
return (
photos.length > 0
? <SiteGrid
contentMain={<PhotoGrid {...{ photos, showMorePath }} />}
contentMain={<div className="space-y-0.5 sm:space-y-1">
<PhotoGrid {...{ photos }} />
{photosCount > photos.length &&
<InfinitePhotoScroll
type='grid'
initialOffset={INFINITE_SCROLL_INITIAL_GRID}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_GRID}
/>}
</div>}
contentSide={<div className="sticky top-4 space-y-4 mt-[-4px]">
<PhotoGridSidebar {...{
tags,

View File

@ -7,8 +7,7 @@ import HomeImageResponse from '@/image-response/HomeImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
export const runtime = 'edge';
import { isNextImageReadyBasedOnPhotos } from '@/photo';
export async function GET() {
const [
@ -23,8 +22,17 @@ export async function GET() {
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
// Make sure next/image can be reached from absolute urls,
// which may not exist on first pre-render
const isNextImageReady = await isNextImageReadyBasedOnPhotos(photos);
return new ImageResponse(
<HomeImageResponse {...{ photos, width, height, fontFamily }}/>,
<HomeImageResponse {...{
photos: isNextImageReady ? photos : [],
width,
height,
fontFamily,
}}/>,
{ width, height, headers, fonts },
);
}

View File

@ -2,18 +2,21 @@ import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/react';
import { clsx } from 'clsx/lite';
import { IBM_Plex_Mono } from 'next/font/google';
import { Metadata } from 'next';
import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
import {
BASE_URL,
SITE_DESCRIPTION,
SITE_DOMAIN_OR_TITLE,
SITE_TITLE,
} from '@/site/config';
import AppStateProvider from '@/state/AppStateProvider';
import Nav from '@/site/Nav';
import ToasterWithThemes from '@/toast/ToasterWithThemes';
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
import Footer from '@/site/Footer';
import { Suspense } from 'react';
import FooterClient from '@/site/FooterClient';
import NavClient from '@/site/NavClient';
import CommandK from '@/site/CommandK';
import { Metadata } from 'next/types';
import { ThemeProvider } from 'next-themes';
import Nav from '@/site/Nav';
import Footer from '@/site/Footer';
import CommandK from '@/site/CommandK';
import SwrConfigClient from '../state/SwrConfigClient';
import '../site/globals.css';
import '../site/sonner.css';
@ -69,34 +72,31 @@ export default function RootLayout({
return (
<html
lang="en"
// Suppress hydration errors due to
// next-themes behavior
// Suppress hydration errors due to next-themes behavior
suppressHydrationWarning
>
<body className={ibmPlexMono.variable}>
<AppStateProvider>
<ThemeProvider attribute="class">
<main className={clsx(
'mx-3 mb-3',
'lg:mx-6 lg:mb-6',
)}>
<Suspense fallback={<NavClient />}>
<Nav />
</Suspense>
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',
<SwrConfigClient>
<ThemeProvider attribute="class">
<main className={clsx(
'mx-3 mb-3',
'lg:mx-6 lg:mb-6',
)}>
{children}
</div>
<Suspense fallback={<FooterClient />}>
<Nav siteDomainOrTitle={SITE_DOMAIN_OR_TITLE} />
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',
)}>
{children}
</div>
<Footer />
</Suspense>
</main>
<CommandK />
</ThemeProvider>
</main>
<CommandK />
</ThemeProvider>
</SwrConfigClient>
<Analytics debug={false} />
<SpeedInsights debug={false} />
<SpeedInsights debug={false} />
<PhotoEscapeHandler />
<ToasterWithThemes />
</AppStateProvider>

View File

@ -1,14 +1,15 @@
import { getPhotosCached, getPhotosCountCached } from '@/photo/cache';
import MorePhotos from '@/photo/MorePhotos';
import MoreComponentsFromSearchParams from
'@/components/MoreComponentsFromSearchParams';
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
import {
PaginationParams,
getPaginationForSearchParams,
getPaginationFromSearchParams,
} from '@/site/pagination';
import { pathForOg } from '@/site/paths';
export default async function GridPage({ searchParams }: PaginationParams) {
const { offset, limit } = getPaginationForSearchParams(searchParams);
const { offset, limit } = getPaginationFromSearchParams(searchParams);
const [
photos,
@ -26,7 +27,10 @@ export default async function GridPage({ searchParams }: PaginationParams) {
<StaggeredOgPhotos photos={photos} />
</div>
{showMorePhotos &&
<MorePhotos path={pathForOg(offset + 1)} />}
<MoreComponentsFromSearchParams
label="More photos"
path={pathForOg(offset + 1)}
/>}
</div>
);
}

View File

@ -5,8 +5,6 @@ import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
export const runtime = 'edge';
export async function GET(
_: Request,
context: { params: { photoId: string } },

View File

@ -3,7 +3,7 @@ import {
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
import { Metadata } from 'next/types';
import { redirect } from 'next/navigation';
import {
PATH_ROOT,
@ -11,9 +11,7 @@ import {
absolutePathForPhotoImage,
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached, getPhotosNearIdCached } from '@/photo/cache';
export const runtime = 'edge';
import { getPhotosNearIdCachedCached } from '@/photo/cache';
interface PhotoProps {
params: { photoId: string }
@ -22,7 +20,10 @@ interface PhotoProps {
export async function generateMetadata({
params: { photoId },
}:PhotoProps): Promise<Metadata> {
const photo = await getPhotoCached(photoId);
const { photo } = await getPhotosNearIdCachedCached(
photoId,
GRID_THUMBNAILS_TO_SHOW_MAX + 2,
);
if (!photo) { return {}; }
@ -53,20 +54,15 @@ export default async function PhotoPage({
params: { photoId },
children,
}: PhotoProps & { children: React.ReactNode }) {
const photos = await getPhotosNearIdCached(
const { photos, photo } = await getPhotosNearIdCachedCached(
photoId,
GRID_THUMBNAILS_TO_SHOW_MAX + 2,
);
const photo = photos.find(p => p.id === photoId);
if (!photo) { redirect(PATH_ROOT); }
const isPhotoFirst = photos.findIndex(p => p.id === photoId) === 0;
// Warm OG image without waiting on response
fetch(absolutePathForPhotoImage(photo));
return <>
{children}
<PhotoDetailPage

View File

@ -1,4 +1,5 @@
import { getPhotoCached } from '@/photo/cache';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { getPhotosNearIdCachedCached } from '@/photo/cache';
import PhotoShareModal from '@/photo/PhotoShareModal';
import { PATH_ROOT } from '@/site/paths';
import { redirect } from 'next/navigation';
@ -8,7 +9,12 @@ export default async function Share({
}: {
params: { photoId: string }
}) {
const photo = await getPhotoCached(photoId);
const { photo } = await getPhotosNearIdCachedCached(
photoId,
// Matching common query from photo detail page
// in order to reuse cached results
GRID_THUMBNAILS_TO_SHOW_MAX + 2,
);
if (!photo) { return redirect(PATH_ROOT); }

View File

@ -1,60 +1,49 @@
import { getPhotosCached, getPhotosCountCached } from '@/photo/cache';
import AnimateItems from '@/components/AnimateItems';
import MorePhotos from '@/photo/MorePhotos';
import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos } from '@/photo';
import PhotoLarge from '@/photo/PhotoLarge';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { getPhotosCachedCached, getPhotosCountCached } from '@/photo/cache';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { pathForRoot } from '@/site/paths';
import { Metadata } from 'next';
INFINITE_SCROLL_INITIAL_HOME,
INFINITE_SCROLL_MULTIPLE_HOME,
generateOgImageMetaForPhotos,
} from '@/photo';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response';
import InfinitePhotoScroll from '../photo/InfinitePhotoScroll';
import PhotosLarge from '@/photo/PhotosLarge';
export const runtime = 'edge';
export const dynamic = 'force-static';
export async function generateMetadata(): Promise<Metadata> {
// Make homepage queries resilient to error on first time setup
const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG })
const photos = await getPhotosCachedCached({
limit: MAX_PHOTOS_TO_SHOW_OG,
})
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
export default async function HomePage({ searchParams }: PaginationParams) {
const { offset, limit } = getPaginationForSearchParams(searchParams, 12);
export default async function HomePage() {
// Make homepage queries resilient to error on first time setup
const [
photos,
count,
photosCount,
] = await Promise.all([
// Make homepage queries resilient to error on first time setup
getPhotosCached({ limit }).catch(() => []),
getPhotosCountCached().catch(() => 0),
getPhotosCachedCached({
limit: INFINITE_SCROLL_INITIAL_HOME,
})
.catch(() => []),
getPhotosCountCached()
.catch(() => 0),
]);
const showMorePhotos = count > photos.length;
return (
photos.length > 0
? <div className="space-y-4">
<AnimateItems
className="space-y-1"
duration={0.7}
staggerDelay={0.15}
distanceOffset={0}
staggerOnFirstLoadOnly
items={photos.map((photo, index) =>
<PhotoLarge
key={photo.id}
photo={photo}
priority={index <= 1}
/>)}
/>
{showMorePhotos &&
<SiteGrid
contentMain={<MorePhotos path={pathForRoot(offset + 1)} />}
? <div className="space-y-1">
<PhotosLarge {...{ photos }} />
{photosCount > photos.length &&
<InfinitePhotoScroll
type="full-frame"
initialOffset={INFINITE_SCROLL_INITIAL_HOME}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_HOME}
/>}
</div>
: <PhotosEmptyState />

View File

@ -2,7 +2,7 @@ import {
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
import { Metadata } from 'next/types';
import { redirect } from 'next/navigation';
import {
PATH_ROOT,
@ -13,12 +13,15 @@ import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached } from '@/photo/cache';
import { PhotoCameraProps, cameraFromPhoto } from '@/camera';
import { getPhotosCameraDataCached } from '@/camera/data';
import { ReactNode } from 'react';
import { ReactNode, cache } from 'react';
const getPhotoCachedCached =
cache((photoId: string) => getPhotoCached(photoId));
export async function generateMetadata({
params: { photoId, make, model },
}: PhotoCameraProps): Promise<Metadata> {
const photo = await getPhotoCached(photoId);
const photo = await getPhotoCachedCached(photoId);
if (!photo) { return {}; }
@ -53,7 +56,7 @@ export default async function PhotoCameraPage({
params: { photoId, make, model },
children,
}: PhotoCameraProps & { children: ReactNode }) {
const photo = await getPhotoCached(photoId);
const photo = await getPhotoCachedCached(photoId);
if (!photo) { redirect(PATH_ROOT); }
@ -61,8 +64,7 @@ export default async function PhotoCameraPage({
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosCameraDataCached({ camera });
return <>

View File

@ -9,8 +9,6 @@ import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
export const runtime = 'edge';
export async function GET(
_: Request,
context: CameraProps,

View File

@ -1,5 +1,5 @@
import { Metadata } from 'next/types';
import { CameraProps, getCameraFromParams } from '@/camera';
import { Metadata } from 'next';
import { generateMetaForCamera } from '@/camera/meta';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { PaginationParams } from '@/site/pagination';
@ -16,8 +16,7 @@ export async function generateMetadata({
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosCameraDataCached({
camera,
limit: GRID_THUMBNAILS_TO_SHOW_MAX,

View File

@ -5,7 +5,7 @@ import {
} from '@/camera';
import CameraShareModal from '@/camera/CameraShareModal';
import { generateMetaForCamera } from '@/camera/meta';
import { Metadata } from 'next';
import { Metadata } from 'next/types';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { PaginationParams } from '@/site/pagination';
import {
@ -21,8 +21,7 @@ export async function generateMetadata({
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosCameraDataCached({
camera,
limit: GRID_THUMBNAILS_TO_SHOW_MAX,

View File

@ -2,7 +2,7 @@ import {
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
import { Metadata } from 'next/types';
import { redirect } from 'next/navigation';
import {
PATH_ROOT,
@ -12,7 +12,9 @@ import {
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached } from '@/photo/cache';
import { getPhotosTagDataCached } from '@/tag/data';
import { ReactNode } from 'react';
import { ReactNode, cache } from 'react';
const getPhotoCachedCached = cache(getPhotoCached);
interface PhotoTagProps {
params: { photoId: string, tag: string }
@ -21,7 +23,7 @@ interface PhotoTagProps {
export async function generateMetadata({
params: { photoId, tag },
}: PhotoTagProps): Promise<Metadata> {
const photo = await getPhotoCached(photoId);
const photo = await getPhotoCachedCached(photoId);
if (!photo) { return {}; }
@ -52,14 +54,13 @@ export default async function PhotoTagPage({
params: { photoId, tag },
children,
}: PhotoTagProps & { children: ReactNode }) {
const photo = await getPhotoCached(photoId);
const photo = await getPhotoCachedCached(photoId);
if (!photo) { redirect(PATH_ROOT); }
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosTagDataCached({ tag });
return <>

View File

@ -8,8 +8,6 @@ import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
export const runtime = 'edge';
export async function GET(
_: Request,
context: { params: { tag: string } },

View File

@ -19,8 +19,7 @@ export async function generateMetadata({
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosTagDataCached({
tag,
limit: GRID_THUMBNAILS_TO_SHOW_MAX,

View File

@ -20,8 +20,7 @@ export async function generateMetadata({
const [
photos,
count,
dateRange,
{ count, dateRange },
] = await getPhotosTagDataCached({
tag,
limit: GRID_THUMBNAILS_TO_SHOW_MAX,

View File

@ -8,8 +8,7 @@ import TemplateImageResponse from
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
export const runtime = 'edge';
import { isNextImageReadyBasedOnPhotos } from '@/photo';
export async function GET() {
const [
@ -27,10 +26,14 @@ export async function GET() {
const { width, height } = IMAGE_OG_DIMENSION;
// Make sure next/image can be reached from absolute urls,
// which may not exist on first pre-render
const isNextImageReady = await isNextImageReadyBasedOnPhotos(photos);
return new ImageResponse(
(
<TemplateImageResponse {...{
photos,
photos: isNextImageReady ? photos : [],
includeHeader: false,
outerMargin: 0,
width,

View File

@ -8,8 +8,7 @@ import TemplateImageResponse from
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
export const runtime = 'edge';
import { isNextImageReadyBasedOnPhotos } from '@/photo';
export async function GET() {
const [
@ -23,11 +22,15 @@ export async function GET() {
]);
const { width, height } = GRID_OG_DIMENSION;
// Make sure next/image can be reached from absolute urls,
// which may not exist on first pre-render
const isNextImageReady = await isNextImageReadyBasedOnPhotos(photos);
return new ImageResponse(
(
<TemplateImageResponse {...{
photos,
photos: isNextImageReady ? photos : [],
width,
height,
fontFamily,

View File

@ -3,16 +3,19 @@
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import InfoBlock from '@/components/InfoBlock';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { useLayoutEffect, useRef, useState } from 'react';
import { signInAction } from './actions';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { getCurrentUser, signInAction } from './actions';
import { useFormState } from 'react-dom';
import ErrorNote from '@/components/ErrorNote';
import { KEY_CALLBACK_URL, KEY_CREDENTIALS_SIGN_IN_ERROR } from '.';
import { useSearchParams } from 'next/navigation';
import { useAppState } from '@/state/AppState';
export default function SignInForm() {
const params = useSearchParams();
const { setUserEmail } = useAppState();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [response, action] = useFormState(signInAction, undefined);
@ -22,6 +25,13 @@ export default function SignInForm() {
emailRef.current?.focus();
}, []);
useEffect(() => {
return () => {
// Capture user email before unmounting
getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined));
};
}, [setUserEmail]);
const isFormValid =
email.length > 0 &&
password.length > 0;

View File

@ -4,10 +4,11 @@ import {
KEY_CALLBACK_URL,
KEY_CREDENTIALS_SIGN_IN_ERROR,
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
auth,
signIn,
signOut,
} from '@/auth';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS, PATH_ROOT } from '@/site/paths';
import { redirect } from 'next/navigation';
export const signInAction = async (
@ -35,6 +36,7 @@ export const signInAction = async (
redirect(formData.get(KEY_CALLBACK_URL) as string || PATH_ADMIN_PHOTOS);
};
export const signOutAction = async () => {
await signOut();
};
export const signOutAndRedirectAction = async () =>
signOut({ redirectTo: PATH_ROOT });
export const getCurrentUser = async () => (await auth())?.user;

View File

@ -1,4 +1,6 @@
import { cache } from 'react';
import { auth } from '@/auth';
import { screenForPPR } from '@/utility/ppr';
export const authCached = cache(auth);
export const authCachedSafe = cache(() => auth()
.catch(e => screenForPPR(e, null, 'auth')));

View File

@ -12,6 +12,7 @@ export default function PhotoCamera({
type = 'icon-first',
badged,
contrast,
prefetch,
countOnHover,
}: {
camera: Camera
@ -38,6 +39,7 @@ export default function PhotoCamera({
type={showAppleIcon && isCameraApple ? 'icon-first' : type}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
);

View File

@ -1,12 +1,11 @@
import {
PaginationSearchParams,
getPaginationForSearchParams,
getPaginationFromSearchParams,
} from '@/site/pagination';
import { Camera } from '.';
import {
getPhotosCached,
getPhotosCameraCountCached,
getPhotosCameraDateRangeCached,
getPhotosCameraMetaCached,
} from '@/photo/cache';
import { pathForCamera } from '@/site/paths';
@ -19,8 +18,7 @@ export const getPhotosCameraDataCached = ({
}) =>
Promise.all([
getPhotosCached({ camera, limit }),
getPhotosCameraCountCached(camera),
getPhotosCameraDateRangeCached(camera),
getPhotosCameraMetaCached(camera),
]);
export const getPhotosCameraDataCachedWithPagination = async ({
@ -32,9 +30,9 @@ export const getPhotosCameraDataCachedWithPagination = async ({
limit?: number,
searchParams?: PaginationSearchParams,
}) => {
const { offset, limit } = getPaginationForSearchParams(searchParams);
const { offset, limit } = getPaginationFromSearchParams(searchParams);
const [photos, count, dateRange] =
const [photos, { count, dateRange }] =
await getPhotosCameraDataCached({
camera,
limit: limitProp ?? limit,

View File

@ -2,7 +2,7 @@
import { ReactNode, useRef } from 'react';
import { Variant, motion } from 'framer-motion';
import { useAppState } from '@/state';
import { useAppState } from '@/state/AppState';
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom';
@ -19,6 +19,7 @@ interface Props extends AnimationConfig {
className?: string
classNameItem?: string
items: ReactNode[]
itemKeys?: string[]
animateFromAppState?: boolean
animateOnFirstLoadOnly?: boolean
staggerOnFirstLoadOnly?: boolean
@ -28,6 +29,7 @@ function AnimateItems({
className,
classNameItem,
items,
itemKeys,
type = 'scale',
duration = 0.6,
staggerDelay = 0.1,
@ -104,7 +106,7 @@ function AnimateItems({
>
{items.map((item, index) =>
<motion.div
key={index}
key={itemKeys ? itemKeys[index] : index}
className={classNameItem}
variants={{
hidden: getInitialVariant(),

View File

@ -2,20 +2,20 @@ import { clsx } from 'clsx/lite';
export default function Badge({
children,
className,
type = 'large',
dimContent,
highContrast,
uppercase,
interactive,
className,
}: {
children: React.ReactNode
className?: string
type?: 'large' | 'small' | 'text-only'
dimContent?: boolean
highContrast?: boolean
uppercase?: boolean
interactive?: boolean
className?: string
}) {
const stylesForType = () => {
switch (type) {
@ -44,6 +44,7 @@ export default function Badge({
};
return (
<span className={clsx(
className,
'leading-none',
stylesForType(),
uppercase && 'uppercase tracking-wider',

View File

@ -29,12 +29,12 @@ export default function ChecklistRow({
/>
<div className="flex flex-col min-w-0">
<div className={clsx(
'flex flex-wrap items-center gap-2',
'flex flex-wrap items-center gap-2 pb-1',
'font-bold dark:text-gray-300',
)}>
{title}
{experimental &&
<ExperimentalBadge className="translate-y-[0.5px]" />}
<ExperimentalBadge className="translate-y-[-0.5px]" />}
</div>
<div>
{children}

View File

@ -9,6 +9,15 @@ import {
useState,
useTransition,
} from 'react';
import {
PATH_ADMIN_BASELINE,
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
PATH_SIGN_IN,
pathForPhoto,
} from '../site/paths';
import Modal from './Modal';
import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce';
@ -17,38 +26,48 @@ import { useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state';
import { useAppState } from '@/state/AppState';
import { queryPhotosByTitleAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAndRedirectAction } from '@/auth/actions';
import { TbPhoto } from 'react-icons/tb';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate';
import PhotoTiny from '@/photo/PhotoTiny';
const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2;
type CommandKItem = {
label: string
keywords?: string[]
annotation?: ReactNode
annotationAria?: string
accessory?: ReactNode
path?: string
action?: () => void | Promise<void>
}
export type CommandKSection = {
heading: string
accessory?: ReactNode
items: {
label: string
keywords?: string[]
annotation?: ReactNode
annotationAria?: string
accessory?: ReactNode
path?: string
action?: () => void
}[]
items: CommandKItem[]
}
export default function CommandKClient({
onQueryChange,
serverSections = [],
showDebugTools,
footer,
}: {
onQueryChange?: (query: string) => Promise<CommandKSection[]>
serverSections?: CommandKSection[]
showDebugTools?: boolean
footer?: string
}) {
const {
isUserSignedIn,
setUserEmail,
isCommandKOpen: isOpen,
setIsCommandKOpen: setIsOpen,
setShouldRespondToKeyboardCommands,
@ -104,9 +123,21 @@ export default function CommandKClient({
useEffect(() => {
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH && !isPending) {
setIsLoading(true);
onQueryChange?.(queryDebounced).then(querySections => {
queryPhotosByTitleAction(queryDebounced).then(photos => {
if (isOpenRef.current) {
setQueriedSections(querySections);
setQueriedSections(photos.length > 0
? [{
heading: 'Photos',
accessory: <TbPhoto size={14} />,
items: photos.map(photo => ({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
annotation: <PhotoDate {...{ photo }} />,
accessory: <PhotoTiny photo={photo} />,
path: pathForPhoto(photo),
})),
}]
: []);
} else {
// Ignore stale requests that come in after dialog is closed
setQueriedSections([]);
@ -114,7 +145,7 @@ export default function CommandKClient({
setIsLoading(false);
});
}
}, [queryDebounced, onQueryChange, isPending]);
}, [queryDebounced, isPending]);
useEffect(() => {
if (queryLive === '') {
@ -157,7 +188,7 @@ export default function CommandKClient({
}],
}];
if (showDebugTools) {
if (isUserSignedIn && showDebugTools) {
clientSections.push({
heading: 'Debug Tools',
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
@ -168,6 +199,57 @@ export default function CommandKClient({
});
}
const sectionPages: CommandKSection = {
heading: 'Pages',
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
items: ([{
label: 'Home',
path: '/',
}, {
label: 'Grid',
path:'/grid',
}]),
};
const adminSection: CommandKSection = {
heading: 'Admin',
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
items: isUserSignedIn
? ([{
label: 'Manage Photos',
annotation: <BiLockAlt />,
path: PATH_ADMIN_PHOTOS,
}, {
label: 'Manage Uploads',
annotation: <BiLockAlt />,
path: PATH_ADMIN_UPLOADS,
}, {
label: 'Manage Tags',
annotation: <BiLockAlt />,
path: PATH_ADMIN_TAGS,
}, {
label: 'App Config',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
}] as CommandKItem[])
.concat(showDebugTools
? [{
label: 'Baseline Overview',
path: PATH_ADMIN_BASELINE,
}]
: [])
.concat({
label: 'Sign Out',
action: () => {
signOutAndRedirectAction().then(() => setUserEmail?.(undefined));
},
})
: [{
label: 'Sign In',
path: PATH_SIGN_IN,
}],
};
return (
<Command.Dialog
open={isOpen}
@ -220,6 +302,8 @@ export default function CommandKClient({
</Command.Empty>
{queriedSections
.concat(serverSections)
.concat(sectionPages)
.concat(adminSection)
.concat(clientSections)
.filter(({ items }) => items.length > 0)
.map(({ heading, accessory, items }) =>

View File

@ -1,6 +1,6 @@
'use client';
import { useAppState } from '@/state';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { ReactNode } from 'react';

View File

@ -1,10 +1,11 @@
import { clsx } from 'clsx/lite';
import { ReactNode } from 'react';
import { BiErrorAlt } from 'react-icons/bi';
export default function ErrorNote({
children,
}: {
children: React.ReactNode
children: ReactNode
}) {
return (
<div className={clsx(

View File

@ -144,9 +144,9 @@ export default function FieldSetWithStatus({
Boolean(error) && 'error',
)}
/>}
<div>
{accessory && <div>
{accessory}
</div>
</div>}
</div>
</div>
);

View File

@ -5,10 +5,12 @@ import { ReactNode } from 'react';
export default function FormWithConfirm({
action,
confirmText,
onSubmit,
children,
}: {
action: (data: FormData) => Promise<void>
confirmText: string
onSubmit?: () => void
children: ReactNode
}) {
return (
@ -19,6 +21,7 @@ export default function FormWithConfirm({
e.preventDefault();
} else {
e.currentTarget.requestSubmit();
onSubmit?.();
}
}}
>

View File

@ -19,6 +19,7 @@ export default function ImageInput({
maxSize = MAX_IMAGE_SIZE,
quality = 0.8,
loading,
showUploadStatus = true,
debug,
}: {
onStart?: () => void
@ -32,6 +33,7 @@ export default function ImageInput({
maxSize?: number
quality?: number
loading?: boolean
showUploadStatus?: boolean
debug?: boolean
}) {
const ref = useRef<HTMLCanvasElement>(null);
@ -62,7 +64,7 @@ export default function ImageInput({
)}
aria-disabled={loading}
>
<span className="w-4 inline-flex items-center">
<span className="w-4 inline-flex items-center mr-1">
{loading
? <Spinner color="text" className="translate-y-[0.5px]" />
: <FiUploadCloud
@ -219,7 +221,7 @@ export default function ImageInput({
}}
/>
</label>
{filesLength > 0 &&
{showUploadStatus && filesLength > 0 &&
<div className="max-w-full truncate text-ellipsis">
{uploadStatusText}
</div>}

View File

@ -1,10 +1,8 @@
import { IMAGE_LARGE_WIDTH } from '@/site';
import Link from 'next/link';
import ImageBlurFallback from './ImageBlurFallback';
export default function ImageLarge({
className,
href,
src,
alt,
aspectRatio,
@ -12,7 +10,6 @@ export default function ImageLarge({
priority,
}: {
className?: string
href: string
src: string
alt: string
aspectRatio: number
@ -20,19 +17,14 @@ export default function ImageLarge({
priority?: boolean
}) {
return (
<Link
href={href}
className="active:brightness-75"
>
<ImageBlurFallback {...{
className,
src,
alt,
priority,
blurDataURL: blurData,
width: IMAGE_LARGE_WIDTH,
height: Math.round(IMAGE_LARGE_WIDTH / aspectRatio),
}} />
</Link>
<ImageBlurFallback {...{
className,
src,
alt,
priority,
blurDataURL: blurData,
width: IMAGE_LARGE_WIDTH,
height: Math.round(IMAGE_LARGE_WIDTH / aspectRatio),
}} />
);
};

View File

@ -7,18 +7,21 @@ export default function ImageSmall({
alt,
aspectRatio,
blurData,
priority,
}: {
className?: string
src: string
alt: string
aspectRatio: number
blurData?: string
priority?: boolean
}) {
return (
<ImageBlurFallback {...{
className,
src,
alt,
priority,
blurDataURL: blurData,
width: IMAGE_SMALL_WIDTH,
height: Math.round(IMAGE_SMALL_WIDTH / aspectRatio),

View File

@ -4,14 +4,32 @@ import { ReactNode } from 'react';
export default function InfoBlock({
children,
className,
color = 'gray',
padding = 'normal',
centered = true,
}: {
children: ReactNode
className?: string
color?: 'gray' | 'blue'
padding?: 'loose' | 'normal' | 'tight';
centered?: boolean;
} ) {
const getColorClasses = () => {
switch (color) {
case 'gray': return [
'text-medium',
'bg-gray-50 border-gray-200',
'dark:bg-gray-900/40 dark:border-gray-800',
];
case 'blue': return [
'text-gray-700/80',
'dark:text-gray-300/60',
'bg-blue-50/50 border-blue-200',
'dark:bg-blue-950/30 dark:border-blue-700/45',
];
}
};
const getPaddingClasses = () => {
switch (padding) {
case 'loose': return 'p-4 md:p-24';
@ -24,8 +42,7 @@ export default function InfoBlock({
<div className={clsx(
'flex flex-col items-center justify-center',
'rounded-lg border',
'bg-gray-50 border-gray-200',
'dark:bg-gray-900/40 dark:border-gray-800',
...getColorClasses(),
getPaddingClasses(),
className,
)}>
@ -33,7 +50,6 @@ export default function InfoBlock({
'flex flex-col justify-center w-full',
centered && 'items-center',
'space-y-4',
'text-medium',
)}>
{children}
</div>

View File

@ -2,14 +2,16 @@
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useTransition } from 'react';
import Spinner from '../components/Spinner';
import Spinner from './Spinner';
export default function MorePhotos({
export default function MoreComponentsFromSearchParams({
path,
label = 'Load more',
triggerOnView = true,
prefetch = true,
}: {
path: string
label?: string
triggerOnView?: boolean
prefetch?: boolean
}) {
@ -59,7 +61,7 @@ export default function MorePhotos({
? <span className="relative inline-block translate-y-[3px]">
<Spinner size={16} />
</span>
: 'More photos'}
: label}
</button>
);
}

View File

@ -0,0 +1,19 @@
import clsx from 'clsx/lite';
import Spinner from './Spinner';
import SiteGrid from './SiteGrid';
export default function PageSpinner() {
return (
<SiteGrid contentMain={
<div className={clsx(
'flex justify-center items-center',
'w-full min-h-[20rem] sm:min-h-[30rem]',
)}>
<Spinner
size={24}
color="light-gray"
/>
</div>
} />
);
}

View File

@ -1,12 +1,15 @@
import { clsx } from 'clsx/lite';
import { RefObject } from 'react';
export default function SiteGrid({
containerRef,
className,
contentMain,
contentSide,
sideFirstOnMobile,
sideHiddenOnMobile,
}: {
containerRef?: RefObject<HTMLDivElement>
className?: string
contentMain: JSX.Element
contentSide?: JSX.Element
@ -14,14 +17,17 @@ export default function SiteGrid({
sideHiddenOnMobile?: boolean
}) {
return (
<div className={clsx(
className,
'grid',
'grid-cols-1 md:grid-cols-12',
'gap-x-4 lg:gap-x-6',
'gap-y-4',
'max-w-7xl',
)}>
<div
ref={containerRef}
className={clsx(
className,
'grid',
'grid-cols-1 md:grid-cols-12',
'gap-x-4 lg:gap-x-6',
'gap-y-4',
'max-w-7xl',
)}
>
<div className={clsx(
'col-span-1 md:col-span-9',
sideFirstOnMobile && 'order-2 md:order-none',

View File

@ -12,6 +12,7 @@ interface Props extends HTMLProps<HTMLButtonElement> {
spinnerColor?: SpinnerColor
onFormStatusChange?: (pending: boolean) => void
onFormSubmitToastMessage?: string
onFormSubmit?: () => void
primary?: boolean
}
@ -21,6 +22,7 @@ export default function SubmitButtonWithStatus({
spinnerColor,
onFormStatusChange,
onFormSubmitToastMessage,
onFormSubmit,
children,
disabled,
className,
@ -33,15 +35,14 @@ export default function SubmitButtonWithStatus({
const pendingPrevious = useRef(pending);
useEffect(() => {
if (
pendingPrevious.current &&
!pending &&
onFormSubmitToastMessage
) {
toastSuccess(onFormSubmitToastMessage);
if (pending && !pendingPrevious.current) {
if (onFormSubmitToastMessage) {
toastSuccess(onFormSubmitToastMessage);
}
onFormSubmit?.();
}
pendingPrevious.current = pending;
}, [pending, onFormSubmitToastMessage]);
}, [pending, onFormSubmitToastMessage, onFormSubmit]);
useEffect(() => {
onFormStatusChange?.(pending);

View File

@ -1,5 +1,6 @@
import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
export default function SwitcherItem({
icon,
@ -8,6 +9,7 @@ export default function SwitcherItem({
onClick,
active,
noPadding,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
}: {
icon: JSX.Element
href?: string
@ -15,6 +17,7 @@ export default function SwitcherItem({
onClick?: () => void
active?: boolean
noPadding?: boolean
prefetch?: boolean
}) {
const className = clsx(
classNameProp,
@ -38,7 +41,9 @@ export default function SwitcherItem({
return (
href
? <Link {...{ href, className }}>{renderIcon()}</Link>
? <Link {...{ href, className, prefetch }}>
{renderIcon()}
</Link>
: <div {...{ onClick, className }}>{renderIcon()}</div>
);
};

View File

@ -7,6 +7,7 @@ export interface EntityLinkExternalProps {
type?: LabeledIconType
badged?: boolean
contrast?: 'low' | 'medium' | 'high'
prefetch?: boolean
}
export default function EntityLink({

View File

@ -0,0 +1,125 @@
'use client';
import useSwrInfinite from 'swr/infinite';
import PhotosLarge from '@/photo/PhotosLarge';
import {
useCallback,
useMemo,
useRef,
} from 'react';
import SiteGrid from '@/components/SiteGrid';
import Spinner from '@/components/Spinner';
import { getPhotosAction } from '@/photo/actions';
import { Photo } from '.';
import PhotoGrid from './PhotoGrid';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
export type RevalidatePhoto = (
photoId: string,
revalidateRemainingPhotos?: boolean,
) => Promise<any>;
export default function InfinitePhotoScroll({
type = 'full-frame',
initialOffset,
itemsPerPage,
}: {
type: 'full-frame' | 'grid'
initialOffset: number
itemsPerPage: number
debug?: boolean
}) {
const { swrTimestamp, isUserSignedIn } = useAppState();
const key = `${swrTimestamp}-${type}`;
const keyGenerator = useCallback(
(size: number, prev: Photo[]) => prev && prev.length === 0
? null
: [key, size]
, [key]);
const fetcher = useCallback(([_key, size]: [string, number]) => {
console.log('Fetching', size);
return getPhotosAction(
initialOffset + size * itemsPerPage,
itemsPerPage,
);
}, [initialOffset, itemsPerPage]);
const { data, isLoading, isValidating, error, mutate, setSize } =
useSwrInfinite<Photo[]>(
keyGenerator,
fetcher,
{
initialSize: 2,
revalidateFirstPage: false,
revalidateOnFocus: Boolean(isUserSignedIn),
revalidateOnReconnect: Boolean(isUserSignedIn),
},
);
const buttonContainerRef = useRef<HTMLDivElement>(null);
const isLoadingOrValidating = isLoading || isValidating;
const isFinished = useMemo(() =>
data && data[data.length - 1]?.length < itemsPerPage
, [data, itemsPerPage]);
const advance = useCallback(() => {
if (!isFinished && !isLoadingOrValidating) {
setSize(size => size + 1);
}
}, [isFinished, isLoadingOrValidating, setSize]);
const photos = useMemo(() => (data ?? [])?.flat(), [data]);
const revalidatePhoto: RevalidatePhoto = useCallback((
photoId: string,
revalidateRemainingPhotos?: boolean,
) => mutate(data, {
revalidate: (_data: Photo[], [_, size]:[string, number]) => {
const i = (data ?? []).findIndex(photos =>
photos.some(photo => photo.id === photoId));
return revalidateRemainingPhotos ? size >= i : size === i;
},
} as any), [data, mutate]);
const renderMoreButton = () =>
<div ref={buttonContainerRef}>
<button
onClick={() => error ? mutate() : advance()}
disabled={isLoading || isValidating}
className={clsx(
'w-full flex justify-center',
isLoadingOrValidating && 'subtle',
)}
>
{error
? 'Try Again'
: isLoadingOrValidating
? <Spinner size={20} />
: 'Load More'}
</button>
</div>;
return (
<div className="space-y-4">
{type === 'full-frame'
? <PhotosLarge {...{
photos,
revalidatePhoto,
onLastPhotoVisible: advance,
}} />
: <PhotoGrid {...{
photos,
onLastPhotoVisible: advance,
}} />}
{!isFinished && (type === 'full-frame'
? <SiteGrid contentMain={renderMoreButton()} />
: renderMoreButton())}
</div>
);
}

View File

@ -77,7 +77,7 @@ export default function PhotoDetailPage({
photo={photo}
primaryTag={tag}
priority
prefetchShare
prefetchRelatedLinks
showCamera={!camera}
showSimulation={!simulation}
shouldShareTag={tag !== undefined}

View File

@ -13,7 +13,7 @@ import { useFormState } from 'react-dom';
import { areSimpleObjectsEqual } from '@/utility/object';
import IconGrSync from '@/site/IconGrSync';
import { getExifDataAction } from './actions';
import { Tags } from '@/tag';
import { TagsWithMeta } from '@/tag';
import AiButton from './ai/AiButton';
import usePhotoFormParent from './form/usePhotoFormParent';
@ -23,7 +23,7 @@ export default function PhotoEditPageClient({
hasAiTextGeneration,
}: {
photo: Photo
uniqueTags: Tags
uniqueTags: TagsWithMeta
hasAiTextGeneration: boolean
}) {
const seedExifData = { url: photo.url };

View File

@ -1,7 +1,7 @@
'use client';
import { getEscapePath } from '@/site/paths';
import { useAppState } from '@/state';
import { useAppState } from '@/state/AppState';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect } from 'react';

View File

@ -3,7 +3,6 @@ import PhotoSmall from './PhotoSmall';
import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems';
import { Camera } from '@/camera';
import MorePhotos from '@/photo/MorePhotos';
import { FilmSimulation } from '@/simulation';
import { GRID_ASPECT_RATIO, HIGH_DENSITY_GRID } from '@/site/config';
@ -13,19 +12,21 @@ export default function PhotoGrid({
tag,
camera,
simulation,
photoPriority,
fast,
animate = true,
animateOnFirstLoadOnly,
staggerOnFirstLoadOnly = true,
showMorePath,
additionalTile,
small,
onLastPhotoVisible,
}: {
photos: Photo[]
selectedPhoto?: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
photoPriority?: boolean
fast?: boolean
animate?: boolean
animateOnFirstLoadOnly?: boolean
@ -33,48 +34,51 @@ export default function PhotoGrid({
showMorePath?: string
additionalTile?: JSX.Element
small?: boolean
onLastPhotoVisible?: () => void
}) {
return (
<div className="space-y-4">
<AnimateItems
className={clsx(
'grid gap-0.5 sm:gap-1',
small
? 'grid-cols-3 xs:grid-cols-6'
: HIGH_DENSITY_GRID
? 'grid-cols-2 xs:grid-cols-4 lg:grid-cols-5'
: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center',
)}
type={animate === false ? 'none' : undefined}
duration={fast ? 0.3 : undefined}
staggerDelay={0.075}
distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
items={photos.map(photo =>
<div
key={photo.id}
className={GRID_ASPECT_RATIO !== 0
? 'aspect-square overflow-hidden'
: undefined}
style={{
...GRID_ASPECT_RATIO !== 0 && {
aspectRatio: GRID_ASPECT_RATIO,
},
}}
>
<PhotoSmall {...{
photo,
tag,
camera,
simulation,
selected: photo.id === selectedPhoto?.id,
}} />
</div>).concat(additionalTile ?? [])}
/>
{showMorePath &&
<MorePhotos path={showMorePath} />}
</div>
<AnimateItems
className={clsx(
'grid gap-0.5 sm:gap-1',
small
? 'grid-cols-3 xs:grid-cols-6'
: HIGH_DENSITY_GRID
? 'grid-cols-2 xs:grid-cols-4 lg:grid-cols-5'
: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center',
)}
type={animate === false ? 'none' : undefined}
duration={fast ? 0.3 : undefined}
staggerDelay={0.075}
distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
items={photos.map((photo, index) =>
<div
key={photo.id}
className={GRID_ASPECT_RATIO !== 0
? 'aspect-square overflow-hidden'
: undefined}
style={{
...GRID_ASPECT_RATIO !== 0 && {
aspectRatio: GRID_ASPECT_RATIO,
},
}}
>
<PhotoSmall {...{
photo,
tag,
camera,
simulation,
selected: photo.id === selectedPhoto?.id,
priority: photoPriority,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
}} />
</div>).concat(additionalTile ?? [])}
itemKeys={photos.map(photo => photo.id)
.concat(additionalTile ? ['more'] : [])}
/>
);
};

View File

@ -5,7 +5,7 @@ import PhotoTag from '@/tag/PhotoTag';
import { FaTag } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io';
import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.';
import { TAG_FAVS, Tags } from '@/tag';
import { TAG_FAVS, TagsWithMeta } from '@/tag';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation';
@ -18,7 +18,7 @@ export default function PhotoGridSidebar({
photosCount,
photosDateRange,
}: {
tags: Tags
tags: TagsWithMeta
cameras: Cameras
simulations: FilmSimulations
photosCount: number
@ -36,6 +36,7 @@ export default function PhotoGridSidebar({
key={TAG_FAVS}
countOnHover={count}
type="icon-last"
prefetch={false}
contrast="low"
badged
/>
@ -44,6 +45,7 @@ export default function PhotoGridSidebar({
tag={tag}
type="text-only"
countOnHover={count}
prefetch={false}
contrast="low"
badged
/>)}
@ -62,6 +64,7 @@ export default function PhotoGridSidebar({
camera={camera}
type="text-only"
countOnHover={count}
prefetch={false}
contrast="low"
hideAppleIcon
badged
@ -83,6 +86,7 @@ export default function PhotoGridSidebar({
simulation={simulation}
countOnHover={count}
type="text-only"
prefetch={false}
/>
</div>)}
/>}

View File

@ -1,9 +1,10 @@
'use client';
import {
Photo,
altTextForPhoto,
shouldShowCameraDataForPhoto,
shouldShowExifDataForPhoto,
titleForPhoto,
} from '.';
import SiteGrid from '@/components/SiteGrid';
import ImageLarge from '@/components/ImageLarge';
@ -16,33 +17,44 @@ import PhotoCamera from '../camera/PhotoCamera';
import { cameraFromPhoto } from '@/camera';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import { sortTags } from '@/tag';
import AdminPhotoMenu from '@/admin/AdminPhotoMenu';
import { Suspense } from 'react';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import PhotoLink from './PhotoLink';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
import { RevalidatePhoto } from './InfinitePhotoScroll';
import { useEffect, useRef } from 'react';
export default function PhotoLarge({
photo,
primaryTag,
priority,
prefetchShare,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
prefetchRelatedLinks = SHOULD_PREFETCH_ALL_LINKS,
revalidatePhoto,
showCamera = true,
showSimulation = true,
shouldShareTag,
shouldShareCamera,
shouldShareSimulation,
shouldScrollOnShare,
onVisible,
}: {
photo: Photo
primaryTag?: string
priority?: boolean
prefetchShare?: boolean
prefetch?: boolean
prefetchRelatedLinks?: boolean
revalidatePhoto?: RevalidatePhoto
showCamera?: boolean
showSimulation?: boolean
shouldShareTag?: boolean
shouldShareCamera?: boolean
shouldShareSimulation?: boolean
shouldScrollOnShare?: boolean
onVisible?: () => void
}) {
const ref = useRef<HTMLDivElement>(null);
const tags = sortTags(photo.tags, primaryTag);
const camera = cameraFromPhoto(photo);
@ -51,18 +63,39 @@ export default function PhotoLarge({
const showTagsContent = tags.length > 0;
const showExifContent = shouldShowExifDataForPhoto(photo);
useEffect(() => {
if (onVisible && ref.current) {
const observer = new IntersectionObserver(e => {
if (e[0].isIntersecting) {
onVisible();
}
}, {
root: null,
threshold: 0,
});
observer.observe(ref.current);
return () => observer.disconnect();
}
}, [onVisible]);
return (
<SiteGrid
containerRef={ref}
contentMain={
<ImageLarge
className="w-full"
alt={altTextForPhoto(photo)}
href={pathForPhoto(photo, primaryTag)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
priority={priority}
/>}
<Link
href={pathForPhoto(photo)}
className="active:brightness-75"
prefetch={prefetch}
>
<ImageLarge
className="w-full"
alt={altTextForPhoto(photo)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
priority={priority}
/>
</Link>}
contentSide={
<DivDebugBaselineGrid className={clsx(
'relative',
@ -74,19 +107,17 @@ export default function PhotoLarge({
{/* Meta */}
<div className="pr-2 md:pr-0">
<div className="md:relative flex gap-2 items-start">
<div className="flex-grow">
<Link
href={pathForPhoto(photo)}
className="font-bold uppercase"
>
{titleForPhoto(photo)}
</Link>
<PhotoLink
photo={photo}
className="font-bold uppercase flex-grow"
prefetch={prefetch}
/>
<div className="absolute right-0 translate-y-[-4px] z-10">
<AdminPhotoMenuClient {...{
photo,
revalidatePhoto,
}} />
</div>
<Suspense>
<div className="absolute right-0 translate-y-[-4px] z-10">
<AdminPhotoMenu photo={photo} />
</div>
</Suspense>
</div>
<div className="space-y-baseline">
{photo.caption &&
@ -99,9 +130,14 @@ export default function PhotoLarge({
<PhotoCamera
camera={camera}
contrast="medium"
prefetch={prefetchRelatedLinks}
/>}
{showTagsContent &&
<PhotoTags tags={tags} contrast="medium" />}
<PhotoTags
tags={tags}
contrast="medium"
prefetch={prefetchRelatedLinks}
/>}
</div>}
</div>
</div>
@ -131,6 +167,7 @@ export default function PhotoLarge({
{showSimulation && photo.filmSimulation &&
<PhotoFilmSimulation
simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks}
/>}
</>}
<div className={clsx(
@ -149,7 +186,7 @@ export default function PhotoLarge({
shouldShareCamera ? camera : undefined,
shouldShareSimulation ? photo.filmSimulation : undefined,
)}
prefetch={prefetchShare}
prefetch={prefetchRelatedLinks}
shouldScroll={shouldScrollOnShare}
/>
</div>

View File

@ -1,30 +1,35 @@
'use client';
import { ReactNode } from 'react';
import { Photo } from '@/photo';
import { Photo, titleForPhoto } from '@/photo';
import Link from 'next/link';
import { AnimationConfig } from '../components/AnimateItems';
import { useAppState } from '@/state';
import { useAppState } from '@/state/AppState';
import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { clsx } from 'clsx/lite';
export default function PhotoLink({
photo,
tag,
camera,
simulation,
scroll,
prefetch,
nextPhotoAnimation,
className,
children,
}: {
photo?: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
scroll?: boolean
prefetch?: boolean
nextPhotoAnimation?: AnimationConfig
children: ReactNode
className?: string
children?: ReactNode
}) {
const { setNextPhotoAnimation } = useAppState();
@ -38,12 +43,16 @@ export default function PhotoLink({
setNextPhotoAnimation?.(nextPhotoAnimation);
}
}}
scroll={false}
className={className}
scroll={scroll}
>
{children}
{children ?? titleForPhoto(photo)}
</Link>
: <span className="text-gray-300 dark:text-gray-700 cursor-default">
{children}
: <span className={clsx(
'text-gray-300 dark:text-gray-700 cursor-default',
className,
)}>
{children ?? (photo ? titleForPhoto(photo) : undefined)}
</span>
);
};

View File

@ -5,7 +5,7 @@ import { Photo, getNextPhoto, getPreviousPhoto } from '@/photo';
import PhotoLink from './PhotoLink';
import { useRouter } from 'next/navigation';
import { pathForPhoto } from '@/site/paths';
import { useAppState } from '@/state';
import { useAppState } from '@/state/AppState';
import { AnimationConfig } from '@/components/AnimateItems';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
@ -86,6 +86,7 @@ export default function PhotoLinks({
tag={tag}
camera={camera}
simulation={simulation}
scroll={false}
prefetch
>
PREV
@ -96,6 +97,7 @@ export default function PhotoLinks({
tag={tag}
camera={camera}
simulation={simulation}
scroll={false}
prefetch
>
NEXT

View File

@ -1,3 +1,5 @@
'use client';
import { Photo, altTextForPhoto } from '.';
import ImageSmall from '@/components/ImageSmall';
import Link from 'next/link';
@ -5,6 +7,8 @@ import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useEffect, useRef } from 'react';
export default function PhotoSmall({
photo,
@ -12,22 +16,46 @@ export default function PhotoSmall({
camera,
simulation,
selected,
priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
onVisible,
}: {
photo: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
selected?: boolean
priority?: boolean
prefetch?: boolean
onVisible?: () => void
}) {
const ref = useRef<HTMLAnchorElement>(null);
useEffect(() => {
if (onVisible && ref.current) {
const observer = new IntersectionObserver(e => {
if (e[0].isIntersecting) {
onVisible();
}
}, {
root: null,
threshold: 0,
});
observer.observe(ref.current);
return () => observer.disconnect();
}
}, [onVisible]);
return (
<Link
ref={ref}
href={pathForPhoto(photo, tag, camera, simulation)}
className={clsx(
'group',
'flex relative w-full h-full',
'flex w-full h-full',
'active:brightness-75',
selected && 'brightness-50',
)}
prefetch={prefetch}
>
<ImageSmall
src={photo.url}
@ -35,6 +63,7 @@ export default function PhotoSmall({
blurData={photo.blurData}
className="w-full"
alt={altTextForPhoto(photo)}
priority={priority}
/>
</Link>
);

View File

@ -3,17 +3,20 @@ import ImageTiny from '@/components/ImageTiny';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
export default function PhotoTiny({
photo,
tag,
selected,
className,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
}: {
photo: Photo
tag?: string
selected?: boolean
className?: string
prefetch?: boolean
}) {
return (
<Link
@ -26,6 +29,7 @@ export default function PhotoTiny({
'rounded-[0.15rem] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
prefetch={prefetch}
>
<ImageTiny
src={photo.url}

View File

@ -10,10 +10,12 @@ import { clsx } from 'clsx/lite';
export default function PhotoUpload({
shouldResize,
onLastUpload,
showUploadStatus,
debug,
}: {
shouldResize?: boolean
onLastUpload?: () => Promise<void>
showUploadStatus?: boolean
debug?: boolean
}) {
const [isUploading, setIsUploading] = useState(false);
@ -75,6 +77,7 @@ export default function PhotoUpload({
});
}
}}
showUploadStatus={showUploadStatus}
debug={debug}
/>
</form>

View File

@ -1,11 +1,11 @@
import AdminCTA from '@/admin/AdminCTA';
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid';
import { IS_SITE_READY } from '@/site/config';
import { PATH_ADMIN_CONFIGURATION, PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_CONFIGURATION } from '@/site/paths';
import SiteChecklist from '@/site/SiteChecklist';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import { FaArrowRight } from 'react-icons/fa';
import { HiOutlinePhotograph } from 'react-icons/hi';
export default function PhotosEmptyState() {
@ -27,19 +27,13 @@ export default function PhotosEmptyState() {
{!IS_SITE_READY ? 'Finish Setup' : 'Setup Complete!'}
</div>
{!IS_SITE_READY
? <SiteChecklist />
? <SiteChecklist simplifiedView />
: <div className="max-w-md text-center space-y-6">
<div className="space-y-2">
<div>
Add your first photo:
</div>
<Link
href={PATH_ADMIN_PHOTOS}
className="button primary"
>
<span>Admin Dashboard</span>
<FaArrowRight size={10} />
</Link>
<AdminCTA />
</div>
<div>
Change the name of this blog and other configuration

41
src/photo/PhotosLarge.tsx Normal file
View File

@ -0,0 +1,41 @@
import AnimateItems from '@/components/AnimateItems';
import { Photo } from '.';
import PhotoLarge from './PhotoLarge';
import { RevalidatePhoto } from './InfinitePhotoScroll';
export default function PhotosLarge({
photos,
animate = true,
prefetchFirstPhotoLinks,
revalidatePhoto,
onLastPhotoVisible,
}: {
photos: Photo[]
animate?: boolean
prefetchFirstPhotoLinks?: boolean
revalidatePhoto?: RevalidatePhoto
onLastPhotoVisible?: () => void
}) {
return (
<AnimateItems
className="space-y-1"
type={animate ? 'scale' : 'none'}
duration={0.7}
staggerDelay={0.15}
distanceOffset={0}
staggerOnFirstLoadOnly
items={photos.map((photo, index) =>
<PhotoLarge
key={photo.id}
photo={photo}
priority={index <= 1}
prefetchRelatedLinks={prefetchFirstPhotoLinks && index === 0}
revalidatePhoto={revalidatePhoto}
onVisible={index === photos.length - 1
? onLastPhotoVisible
: undefined}
/>)}
itemKeys={photos.map(photo => photo.id)}
/>
);
}

View File

@ -3,8 +3,8 @@
import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_UPLOADS } from '@/site/paths';
import { PhotoFormData, generateTakenAtFields } from './form';
import { Tags } from '@/tag';
import PhotoForm from './form/PhotoForm';
import { TagsWithMeta } from '@/tag';
import usePhotoFormParent from './form/usePhotoFormParent';
import AiButton from './ai/AiButton';
import { AiAutoGeneratedField } from './ai';
@ -19,7 +19,7 @@ export default function UploadPageClient({
}: {
blobId?: string
photoFormExif: Partial<PhotoFormData>
uniqueTags: Tags
uniqueTags: TagsWithMeta
hasAiTextGeneration?: boolean
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
}) {

View File

@ -7,6 +7,7 @@ import {
sqlUpdatePhoto,
sqlRenamePhotoTagGlobally,
getPhoto,
getPhotos,
} from '@/services/vercel-postgres';
import {
PhotoFormData,
@ -19,8 +20,10 @@ import {
deleteStorageUrl,
} from '@/services/storage';
import {
getPhotosCachedCached,
revalidateAdminPaths,
revalidateAllKeysAndPaths,
revalidatePhoto,
revalidatePhotosKey,
revalidateTagsKey,
} from '@/photo/cache';
@ -59,7 +62,7 @@ export async function updatePhotoAction(formData: FormData) {
await sqlUpdatePhoto(photo);
revalidateAllKeysAndPaths();
revalidatePhoto(photo.id);
redirect(PATH_ADMIN_PHOTOS);
});
@ -191,3 +194,10 @@ export async function streamAiImageQueryAction(
return safelyRunAdminServerAction(async () =>
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
}
export const getPhotosAction = async (offset: number, limit: number) =>
getPhotosCachedCached({ offset, limit });
export const queryPhotosByTitleAction = async (query: string) =>
(await getPhotos({ query, limit: 10 }))
.filter(({ title }) => Boolean(title));

View File

@ -9,23 +9,32 @@ import {
getPhoto,
getPhotos,
getPhotosCount,
getPhotosCameraCount,
getPhotosCountIncludingHidden,
getPhotosTagCount,
getUniqueCameras,
getUniqueTags,
getPhotosTagDateRange,
getPhotosCameraDateRange,
getPhotosTagMeta,
getPhotosCameraMeta,
getUniqueTagsHidden,
getUniqueFilmSimulations,
getPhotosFilmSimulationDateRange,
getPhotosFilmSimulationCount,
getPhotosFilmSimulationMeta,
getPhotosDateRange,
getPhotosNearId,
getPhotosMostRecentUpdate,
} from '@/services/vercel-postgres';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
import { createCameraKey } from '@/camera';
import { PATHS_ADMIN } from '@/site/paths';
import {
PATHS_ADMIN,
PATHS_TO_CACHE,
PATH_ADMIN,
PATH_GRID,
PATH_ROOT,
PREFIX_CAMERA,
PREFIX_FILM_SIMULATION,
PREFIX_TAG,
pathForPhoto,
} from '@/site/paths';
import { cache } from 'react';
// Table key
const KEY_PHOTOS = 'photos';
@ -94,15 +103,31 @@ export const revalidateAllKeys = () => {
revalidateFilmSimulationsKey();
};
export const revalidateAllKeysAndPaths = () => {
revalidateAllKeys();
revalidatePath('/', 'layout');
};
export const revalidateAdminPaths = () => {
PATHS_ADMIN.forEach(path => revalidatePath(path));
};
export const revalidateAllKeysAndPaths = () => {
revalidateAllKeys();
PATHS_TO_CACHE.forEach(path => revalidatePath(path, 'layout'));
};
export const revalidatePhoto = (photoId: string) => {
// Tags
revalidateTag(photoId);
revalidateTagsKey();
revalidateCamerasKey();
revalidateFilmSimulationsKey();
// Paths
revalidatePath(pathForPhoto(photoId), 'layout');
revalidatePath(PATH_ROOT, 'layout');
revalidatePath(PATH_GRID, 'layout');
revalidatePath(PREFIX_TAG, 'layout');
revalidatePath(PREFIX_CAMERA, 'layout');
revalidatePath(PREFIX_FILM_SIMULATION, 'layout');
revalidatePath(PATH_ADMIN, 'layout');
};
// Cache
export const getPhotosCached = (
@ -111,13 +136,18 @@ export const getPhotosCached = (
getPhotos,
[KEY_PHOTOS, ...getPhotosCacheKeys(...args)],
)(...args).then(parseCachedPhotosDates);
export const getPhotosCachedCached = cache(getPhotosCached);
export const getPhotosNearIdCached = (
const getPhotosNearIdCached = (
...args: Parameters<typeof getPhotosNearId>
) => unstable_cache(
getPhotosNearId,
[KEY_PHOTOS],
)(...args).then(parseCachedPhotosDates);
)(...args).then(({ photos, photo }) => ({
photos: parseCachedPhotosDates(photos),
photo: photo ? parseCachedPhotoDates(photo) : undefined,
}));
export const getPhotosNearIdCachedCached = cache(getPhotosNearIdCached);
export const getPhotosDateRangeCached =
unstable_cache(
@ -137,41 +167,27 @@ export const getPhotosCountIncludingHiddenCached =
[KEY_PHOTOS, KEY_COUNT, KEY_HIDDEN],
);
export const getPhotosTagCountCached =
export const getPhotosMostRecentUpdateCached =
unstable_cache(
getPhotosTagCount,
[KEY_PHOTOS, KEY_TAGS],
() => getPhotosMostRecentUpdate(),
[KEY_PHOTOS, KEY_COUNT, KEY_DATE_RANGE],
);
export const getPhotosCameraCountCached = (
...args: Parameters<typeof getPhotosCameraCount>
) =>
export const getPhotosTagMetaCached =
unstable_cache(
getPhotosCameraCount,
[KEY_PHOTOS, KEY_COUNT, createCameraKey(...args)],
)(...args);
export const getPhotosFilmSimulationCountCached =
unstable_cache(
getPhotosFilmSimulationCount,
[KEY_PHOTOS, KEY_FILM_SIMULATIONS, KEY_COUNT],
);
export const getPhotosTagDateRangeCached =
unstable_cache(
getPhotosTagDateRange,
getPhotosTagMeta,
[KEY_PHOTOS, KEY_TAGS, KEY_DATE_RANGE],
);
export const getPhotosCameraDateRangeCached =
export const getPhotosCameraMetaCached =
unstable_cache(
getPhotosCameraDateRange,
getPhotosCameraMeta,
[KEY_PHOTOS, KEY_CAMERAS, KEY_DATE_RANGE],
);
export const getPhotosFilmSimulationDateRangeCached =
export const getPhotosFilmSimulationMetaCached =
unstable_cache(
getPhotosFilmSimulationDateRange,
getPhotosFilmSimulationMeta,
[KEY_PHOTOS, KEY_FILM_SIMULATIONS, KEY_DATE_RANGE],
);

View File

@ -21,7 +21,7 @@ import { toastSuccess, toastWarning } from '@/toast';
import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback';
import { BLUR_ENABLED } from '@/site/config';
import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
import { TagsWithMeta, sortTagsObjectWithoutFavs } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import { AiContent } from '../ai/useAiImageQueries';
import AiButton from '../ai/AiButton';
@ -29,6 +29,7 @@ import Spinner from '@/components/Spinner';
import { getNextImageUrlForRequest } from '@/services/next-image';
import useDelay from '@/utility/useDelay';
import usePreventNavigation from '@/utility/usePreventNavigation';
import { useAppState } from '@/state/AppState';
const THUMBNAIL_SIZE = 300;
@ -46,7 +47,7 @@ export default function PhotoForm({
initialPhotoForm: Partial<PhotoFormData>
updatedExifData?: Partial<PhotoFormData>
type?: 'create' | 'edit'
uniqueTags?: Tags
uniqueTags?: TagsWithMeta
aiContent?: AiContent
setImageData?: (imageData: string) => void
debugBlur?: boolean
@ -62,6 +63,8 @@ export default function PhotoForm({
useState<string>();
const [hasBlurData, setHasBlurData] = useState(false);
const { invalidateSwr } = useAppState();
const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData),
[initialPhotoForm, formData]);
@ -257,7 +260,12 @@ export default function PhotoForm({
</div>
</div>
<CanvasBlurCapture
imageUrl={getNextImageUrlForRequest(url, 640)}
imageUrl={getNextImageUrlForRequest(
url,
640,
undefined,
window.location.origin,
)}
width={width}
height={height}
onLoad={aiContent?.setImageData}
@ -366,6 +374,7 @@ export default function PhotoForm({
<SubmitButtonWithStatus
disabled={!canFormBeSubmitted}
onFormStatusChange={onFormStatusChange}
onFormSubmit={invalidateSwr}
primary
>
{type === 'create' ? 'Create' : 'Update'}

View File

@ -1,5 +1,6 @@
import { getNextImageUrlForRequest } from '@/services/next-image';
import { FilmSimulation } from '@/simulation';
import { SHOW_EXIF_DATA } from '@/site/config';
import { HIGH_DENSITY_GRID, SHOW_EXIF_DATA } from '@/site/config';
import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/site/paths';
import { formatDateFromPostgresString } from '@/utility/date';
import {
@ -12,6 +13,20 @@ import {
import camelcaseKeys from 'camelcase-keys';
import type { Metadata } from 'next';
// ROOT PAGE
export const INFINITE_SCROLL_INITIAL_HOME =
process.env.NODE_ENV === 'development' ? 2 : 12;
export const INFINITE_SCROLL_MULTIPLE_HOME =
process.env.NODE_ENV === 'development' ? 2 : 24;
// GRID PAGE
export const INFINITE_SCROLL_INITIAL_GRID = HIGH_DENSITY_GRID
? process.env.NODE_ENV === 'development' ? 4 : 20
: process.env.NODE_ENV === 'development' ? 4 : 24;
export const INFINITE_SCROLL_MULTIPLE_GRID = HIGH_DENSITY_GRID
? process.env.NODE_ENV === 'development' ? 4 : 40
: process.env.NODE_ENV === 'development' ? 4 : 48;
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;
export const ACCEPTED_PHOTO_FILE_TYPES = [
@ -256,3 +271,8 @@ export const getKeywordsForPhoto = (photo: Photo) =>
.concat((photo.semanticDescription ?? '').split(' '))
.filter(Boolean)
.map(keyword => keyword.toLocaleLowerCase());
export const isNextImageReadyBasedOnPhotos = async (photos: Photo[]) =>
photos.length > 0 && fetch(getNextImageUrlForRequest(photos[0].url, 640))
.then(response => response.ok)
.catch(() => false);

View File

@ -27,6 +27,7 @@ import {
isUrlFromCloudflareR2,
} from './cloudflare-r2';
import { PATH_API_PRESIGNED_URL } from '@/site/paths';
import { screenForPPR } from '@/utility/ppr';
export const generateStorageId = () => generateNanoid(16);
@ -192,15 +193,15 @@ const getStorageUrlsForPrefix = async (prefix = '') => {
if (HAS_VERCEL_BLOB_STORAGE) {
urls.push(...await vercelBlobList(prefix)
.catch(() => []));
.catch(e => screenForPPR(e, [], 'vercel blob')));
}
if (HAS_AWS_S3_STORAGE) {
urls.push(...await awsS3List(prefix)
.catch(() => []));
.catch(e => screenForPPR(e, [], 'aws blob')));
}
if (HAS_CLOUDFLARE_R2_STORAGE) {
urls.push(...await cloudflareR2List(prefix)
.catch(() => []));
.catch(e => screenForPPR(e, [], 'cloudflare blob')));
}
return urls

View File

@ -9,9 +9,10 @@ import {
} from '@/photo';
import { Camera, Cameras, createCameraKey } from '@/camera';
import { parameterize } from '@/utility/string';
import { Tags } from '@/tag';
import { TagsWithMeta } from '@/tag';
import { FilmSimulation, FilmSimulations } from '@/simulation';
import { PRIORITY_ORDER_ENABLED } from '@/site/config';
import { SHOULD_DEBUG_SQL, PRIORITY_ORDER_ENABLED } from '@/site/config';
import { screenForPPR } from '@/utility/ppr';
const PHOTO_DEFAULT_LIMIT = 100;
@ -118,7 +119,7 @@ export const sqlInsertPhoto = (photo: PhotoDbInsert) =>
${photo.takenAt},
${photo.takenAtNaive}
)
`);
`, 'sqlInsertPhoto');
export const sqlUpdatePhoto = (photo: PhotoDbInsert) =>
safelyQueryPhotos(() => sql`
@ -149,28 +150,32 @@ export const sqlUpdatePhoto = (photo: PhotoDbInsert) =>
taken_at_naive=${photo.takenAtNaive},
updated_at=${(new Date()).toISOString()}
WHERE id=${photo.id}
`);
`, 'sqlUpdatePhoto');
export const sqlDeletePhotoTagGlobally = (tag: string) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET tags=ARRAY_REMOVE(tags, ${tag})
WHERE ${tag}=ANY(tags)
`);
`, 'sqlDeletePhotoTagGlobally');
export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag})
WHERE ${tag}=ANY(tags)
`);
`, 'sqlRenamePhotoTagGlobally');
export const sqlDeletePhoto = (id: string) =>
safelyQueryPhotos(() => sql`DELETE FROM photos WHERE id=${id}`);
safelyQueryPhotos(
() => sql`DELETE FROM photos WHERE id=${id}`,
'sqlDeletePhoto',
);
const sqlGetPhoto = (id: string) =>
safelyQueryPhotos(() =>
sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`
safelyQueryPhotos(
() => sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`,
'sqlGetPhoto',
);
const sqlGetPhotosCount = async () => sql`
@ -182,27 +187,9 @@ const sqlGetPhotosCountIncludingHidden = async () => sql`
SELECT COUNT(*) FROM photos
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosTagCount = async (tag: string) => sql`
SELECT COUNT(*) FROM photos
WHERE ${tag}=ANY(tags) AND
hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosCameraCount = async (camera: Camera) => sql`
SELECT COUNT(*) FROM photos
WHERE
LOWER(REPLACE(make, ' ', '-'))=${parameterize(camera.make, true)} AND
LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model, true)} AND
hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosFilmSimulationCount = async (
simulation: FilmSimulation,
) => sql`
SELECT COUNT(*) FROM photos
WHERE film_simulation=${simulation} AND
hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosMostRecentUpdate = async () => sql`
SELECT updated_at FROM photos ORDER BY updated_at DESC LIMIT 1
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined);
const sqlGetPhotosDateRange = async () => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
@ -212,36 +199,45 @@ const sqlGetPhotosDateRange = async () => sql`
? rows[0] as PhotoDateRange
: undefined);
const sqlGetPhotosTagDateRange = async (tag: string) => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
const sqlGetPhotosTagMeta = async (tag: string) => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE ${tag}=ANY(tags) AND
hidden IS NOT TRUE
`.then(({ rows }) => rows[0]?.start && rows[0]?.end
? rows[0] as PhotoDateRange
: undefined);
`.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
const sqlGetPhotosCameraDateRange = async (camera: Camera) => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
const sqlGetPhotosCameraMeta = async (camera: Camera) => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE
LOWER(REPLACE(make, ' ', '-'))=${parameterize(camera.make, true)} AND
LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model, true)} AND
hidden IS NOT TRUE
`.then(({ rows }) => rows[0]?.start && rows[0]?.end
? rows[0] as PhotoDateRange
: undefined);
`.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
const sqlGetPhotosFilmSimulationDateRange = async (
const sqlGetPhotosFilmSimulationMeta = async (
simulation: FilmSimulation,
) => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE film_simulation=${simulation} AND
hidden IS NOT TRUE
`.then(({ rows }) => rows[0]?.start && rows[0]?.end
? rows[0] as PhotoDateRange
: undefined);
`.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
const sqlGetUniqueTags = async () => sql`
SELECT DISTINCT unnest(tags) as tag, COUNT(*)
@ -249,7 +245,7 @@ const sqlGetUniqueTags = async () => sql`
WHERE hidden IS NOT TRUE
GROUP BY tag
ORDER BY tag ASC
`.then(({ rows }): Tags => rows.map(({ tag, count }) => ({
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
})));
@ -259,7 +255,7 @@ const sqlGetUniqueTagsHidden = async () => sql`
FROM photos
GROUP BY tag
ORDER BY tag ASC
`.then(({ rows }): Tags => rows.map(({ tag, count }) => ({
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
})));
@ -303,12 +299,18 @@ export type GetPhotosOptions = {
includeHidden?: boolean
}
const safelyQueryPhotos = async <T>(callback: () => Promise<T>): Promise<T> => {
const safelyQueryPhotos = async <T>(
callback: () => Promise<T>,
debugMessage: string
): Promise<T> => {
let result: T;
const start = new Date();
try {
result = await callback();
} catch (e: any) {
screenForPPR(e, undefined, 'neon postgres');
if (MIGRATION_FIELDS_01.some(field => new RegExp(
`column "${field}" of relation "photos" does not exist`,
'i',
@ -322,6 +324,7 @@ const safelyQueryPhotos = async <T>(callback: () => Promise<T>): Promise<T> => {
await sqlCreatePhotosTable();
result = await callback();
} else if (/endpoint is in transition/i.test(e.message)) {
console.log('sql get error: endpoint is in transition (setting timeout)');
// Wait 5 seconds and try again
await new Promise(resolve => setTimeout(resolve, 5000));
try {
@ -336,6 +339,12 @@ const safelyQueryPhotos = async <T>(callback: () => Promise<T>): Promise<T> => {
}
}
if (SHOULD_DEBUG_SQL && debugMessage) {
const time =
(((new Date()).getTime() - start.getTime()) / 1000).toFixed(2);
console.log(`Executing sql query: ${debugMessage} (${time} seconds)`);
}
return result;
};
@ -411,9 +420,8 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
values.push(limit, offset);
return safelyQueryPhotos(async () => {
const client = await db.connect();
return client.query(sql.join(' '), values);
})
return db.query(sql.join(' '), values);
}, sql.join(' '))
.then(({ rows }) => rows.map(parsePhotoFromDb));
};
@ -426,8 +434,7 @@ export const getPhotosNearId = async (
: 'ORDER BY taken_at DESC';
return safelyQueryPhotos(async () => {
const client = await db.connect();
return client.query(
return db.query(
`
WITH twi AS (
SELECT *, row_number()
@ -443,48 +450,72 @@ export const getPhotosNearId = async (
`,
[id, limit]
);
})
.then(({ rows }) => rows.map(parsePhotoFromDb));
}, `getPhotosNearId: ${id}`)
.then(({ rows }) => {
const photos = rows.map(parsePhotoFromDb);
return {
photos,
photo: photos.find(photo => photo.id === id),
};
});
};
export const getPhotoIds = async ({ limit }: { limit?: number }) => {
return safelyQueryPhotos(() => limit
? sql`SELECT id FROM photos LIMIT ${limit}`
: sql`SELECT id FROM photos`,
'getPhotoIds')
.then(({ rows }) => rows.map(({ id }) => id as string));
};
export const getPhoto = async (id: string): Promise<Photo | undefined> => {
// Check for photo id forwarding
// and convert short ids to uuids
const photoId = translatePhotoId(id);
return safelyQueryPhotos(() => sqlGetPhoto(photoId))
return safelyQueryPhotos(() => sqlGetPhoto(photoId), 'getPhoto')
.then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined);
};
export const getPhotosDateRange = () =>
safelyQueryPhotos(sqlGetPhotosDateRange);
safelyQueryPhotos(sqlGetPhotosDateRange, 'getPhotosDateRange');
export const getPhotosCount = () =>
safelyQueryPhotos(sqlGetPhotosCount);
safelyQueryPhotos(sqlGetPhotosCount, 'getPhotosCount');
export const getPhotosCountIncludingHidden = () =>
safelyQueryPhotos(sqlGetPhotosCountIncludingHidden);
safelyQueryPhotos(
sqlGetPhotosCountIncludingHidden,
'getPhotosCountIncludingHidden',
);
export const getPhotosMostRecentUpdate = () =>
safelyQueryPhotos(
sqlGetPhotosMostRecentUpdate,
'getPhotosMostRecentUpdate',
);
// TAGS
export const getUniqueTags = () =>
safelyQueryPhotos(sqlGetUniqueTags);
safelyQueryPhotos(sqlGetUniqueTags, 'getUniqueTags');
export const getUniqueTagsHidden = () =>
safelyQueryPhotos(sqlGetUniqueTagsHidden);
export const getPhotosTagDateRange = (tag: string) =>
safelyQueryPhotos(() => sqlGetPhotosTagDateRange(tag));
export const getPhotosTagCount = (tag: string) =>
safelyQueryPhotos(() => sqlGetPhotosTagCount(tag));
safelyQueryPhotos(sqlGetUniqueTagsHidden, 'getUniqueTagsHidden');
export const getPhotosTagMeta = (tag: string) =>
safelyQueryPhotos(
() => sqlGetPhotosTagMeta(tag),
'getPhotosTagMeta',
);
// CAMERAS
export const getUniqueCameras = () =>
safelyQueryPhotos(sqlGetUniqueCameras);
export const getPhotosCameraDateRange = (camera: Camera) =>
safelyQueryPhotos(() => sqlGetPhotosCameraDateRange(camera));
export const getPhotosCameraCount = (camera: Camera) =>
safelyQueryPhotos(() => sqlGetPhotosCameraCount(camera));
safelyQueryPhotos(sqlGetUniqueCameras, 'getUniqueCameras');
export const getPhotosCameraMeta = (camera: Camera) =>
safelyQueryPhotos(
() => sqlGetPhotosCameraMeta(camera),
'getPhotosCameraMeta',
);
// FILM SIMULATIONS
export const getUniqueFilmSimulations = () =>
safelyQueryPhotos(sqlGetUniqueFilmSimulations);
export const getPhotosFilmSimulationDateRange =
(simulation: FilmSimulation) => safelyQueryPhotos(() =>
sqlGetPhotosFilmSimulationDateRange(simulation));
export const getPhotosFilmSimulationCount = (simulation: FilmSimulation) =>
safelyQueryPhotos(() => sqlGetPhotosFilmSimulationCount(simulation));
safelyQueryPhotos(sqlGetUniqueFilmSimulations, 'getUniqueFilmSimulations');
export const getPhotosFilmSimulationMeta =
(simulation: FilmSimulation) => safelyQueryPhotos(
() => sqlGetPhotosFilmSimulationMeta(simulation),
'getPhotosFilmSimulationMeta',
);

View File

@ -11,6 +11,7 @@ export default function PhotoFilmSimulation({
type = 'icon-last',
badged = true,
contrast = 'low',
prefetch,
countOnHover,
}: {
simulation: FilmSimulation
@ -28,6 +29,7 @@ export default function PhotoFilmSimulation({
type={type}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
iconWide
/>

View File

@ -1,11 +1,10 @@
import {
getPhotosCached,
getPhotosFilmSimulationCountCached,
getPhotosFilmSimulationDateRangeCached,
getPhotosFilmSimulationMetaCached,
} from '@/photo/cache';
import {
PaginationSearchParams,
getPaginationForSearchParams,
getPaginationFromSearchParams,
} from '@/site/pagination';
import { pathForFilmSimulation } from '@/site/paths';
import { FilmSimulation } from '.';
@ -19,8 +18,7 @@ export const getPhotosFilmSimulationDataCached = ({
}) =>
Promise.all([
getPhotosCached({ simulation, limit }),
getPhotosFilmSimulationCountCached(simulation),
getPhotosFilmSimulationDateRangeCached(simulation),
getPhotosFilmSimulationMetaCached(simulation),
]);
export const getPhotosFilmSimulationDataCachedWithPagination = async ({
@ -32,9 +30,9 @@ export const getPhotosFilmSimulationDataCachedWithPagination = async ({
limit?: number,
searchParams?: PaginationSearchParams,
}) => {
const { offset, limit } = getPaginationForSearchParams(searchParams);
const { offset, limit } = getPaginationFromSearchParams(searchParams);
const [photos, count, dateRange] =
const [photos, { count, dateRange }] =
await getPhotosFilmSimulationDataCached({
simulation,
limit: limitProp ?? limit,

View File

@ -6,32 +6,17 @@ import {
getUniqueTagsCached,
} from '@/photo/cache';
import {
PATH_ADMIN_BASELINE,
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
PATH_SIGN_IN,
pathForCamera,
pathForFilmSimulation,
pathForPhoto,
pathForTag,
} from './paths';
import { formatCameraText } from '@/camera';
import { authCached } from '@/auth/cache';
import { getPhotos } from '@/services/vercel-postgres';
import { getKeywordsForPhoto, photoQuantityText, titleForPhoto } from '@/photo';
import PhotoTiny from '@/photo/PhotoTiny';
import { photoQuantityText } from '@/photo';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { sortTagsObject } from '@/tag';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { FaTag } from 'react-icons/fa';
import { TbPhoto } from 'react-icons/tb';
import { IoMdCamera } from 'react-icons/io';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAction } from '@/auth/actions';
import PhotoDate from '@/photo/PhotoDate';
import { ADMIN_DEBUG_TOOLS_ENABLED } from './config';
export default async function CommandK() {
@ -47,10 +32,6 @@ export default async function CommandK() {
getUniqueFilmSimulationsCached().catch(() => []),
]);
const session = await authCached().catch(() => null);
const isAdminLoggedIn = Boolean(session?.user?.email);
const SECTION_TAGS: CommandKSection = {
heading: 'Tags',
accessory: <FaTag
@ -89,81 +70,13 @@ export default async function CommandK() {
})),
};
const SECTION_PAGES: CommandKSection = {
heading: 'Pages',
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
items: ([{
label: 'Home',
path: '/',
}, {
label: 'Grid',
path:'/grid',
}]),
};
const SECTION_ADMIN: CommandKSection = {
heading: 'Admin',
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
items: isAdminLoggedIn
? [{
label: 'Manage Photos',
annotation: <BiLockAlt />,
path: PATH_ADMIN_PHOTOS,
}, {
label: 'Manage Uploads',
annotation: <BiLockAlt />,
path: PATH_ADMIN_UPLOADS,
}, {
label: 'Manage Tags',
annotation: <BiLockAlt />,
path: PATH_ADMIN_TAGS,
}, {
label: 'App Config',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
}, {
label: 'Sign Out',
action: signOutAction,
}]
: [{
label: 'Sign In',
path: PATH_SIGN_IN,
}],
};
if (isAdminLoggedIn && ADMIN_DEBUG_TOOLS_ENABLED) {
SECTION_ADMIN.items.push({
label: 'Baseline Overview',
path: PATH_ADMIN_BASELINE,
});
}
return <CommandKClient
serverSections={[
SECTION_TAGS,
SECTION_CAMERAS,
SECTION_FILM,
SECTION_PAGES,
SECTION_ADMIN,
]}
onQueryChange={async (query) => {
'use server';
const photos = (await getPhotos({ query, limit: 10 }));
return photos.length > 0
? [{
heading: 'Photos',
accessory: <TbPhoto size={14} />,
items: photos.map(photo => ({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
annotation: <PhotoDate {...{ photo }} />,
accessory: <PhotoTiny photo={photo} />,
path: pathForPhoto(photo),
})),
}]
: [];
}}
showDebugTools={isAdminLoggedIn && ADMIN_DEBUG_TOOLS_ENABLED}
showDebugTools={ADMIN_DEBUG_TOOLS_ENABLED}
footer={photoQuantityText(count, false)}
/>;
}

View File

@ -1,10 +1,75 @@
import { authCached } from '@/auth/cache';
import FooterClient from './FooterClient';
'use client';
import { clsx } from 'clsx/lite';
import SiteGrid from '../components/SiteGrid';
import ThemeSwitcher from '@/site/ThemeSwitcher';
import Link from 'next/link';
import { SHOW_REPO_LINK } from '@/site/config';
import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation';
import { isPathAdmin, isPathSignIn, pathForAdminPhotos } from './paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { signOutAndRedirectAction } from '@/auth/actions';
import Spinner from '@/components/Spinner';
import AnimateItems from '@/components/AnimateItems';
import { useAppState } from '@/state/AppState';
export default function Footer() {
const pathname = usePathname();
const { userEmail, setUserEmail } = useAppState();
const showFooter = !isPathSignIn(pathname);
const shouldAnimate = !isPathAdmin(pathname);
export default async function Footer() {
// Make footer auth resilient to error on first time setup
const session = await authCached().catch(() => null);
return (
<FooterClient userEmail={session?.user?.email} />
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={!shouldAnimate ? 'none' : 'bottom'}
distanceOffset={10}
items={showFooter
? [<div
key="footer"
className={clsx(
'flex items-center',
'text-dim min-h-10',
)}>
<div className="flex gap-x-4 gap-y-0.5 flex-grow flex-wrap">
{isPathAdmin(pathname)
? <>
{userEmail === undefined &&
<Spinner />}
{userEmail && <>
<div className={clsx(
'truncate max-w-full',
)}>
{userEmail}
</div>
<form action={() => signOutAndRedirectAction()
.then(() => setUserEmail?.(undefined))}>
<SubmitButtonWithStatus styleAsLink>
Sign out
</SubmitButtonWithStatus>
</form>
</>}
</>
: <>
<Link href={pathForAdminPhotos()}>
Admin
</Link>
{SHOW_REPO_LINK &&
<RepoLink />}
</>}
</div>
<div className="flex items-center h-10">
<ThemeSwitcher />
</div>
</div>]
: []}
/>}
/>
);
}

View File

@ -1,71 +0,0 @@
'use client';
import { clsx } from 'clsx/lite';
import SiteGrid from '../components/SiteGrid';
import ThemeSwitcher from '@/site/ThemeSwitcher';
import Link from 'next/link';
import { SHOW_REPO_LINK } from '@/site/config';
import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation';
import { isPathAdmin, isPathSignIn, pathForAdminPhotos } from './paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { signOutAction } from '@/auth/actions';
import Spinner from '@/components/Spinner';
import AnimateItems from '@/components/AnimateItems';
export default function FooterClient({
userEmail,
}: {
userEmail?: string | null | undefined
}) {
const pathname = usePathname();
const showFooter = !isPathSignIn(pathname);
const shouldAnimate = !isPathAdmin(pathname);
return (
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={!shouldAnimate ? 'none' : 'bottom'}
distanceOffset={10}
items={showFooter
? [<div
key="footer"
className={clsx(
'flex items-center',
'text-dim min-h-[4rem]',
)}>
<div className="flex gap-x-4 gap-y-0.5 flex-grow flex-wrap">
{isPathAdmin(pathname)
? <>
{userEmail === undefined &&
<Spinner />}
{userEmail && <>
<div>{userEmail}</div>
<form action={signOutAction}>
<SubmitButtonWithStatus styleAsLink>
Sign out
</SubmitButtonWithStatus>
</form>
</>}
</>
: <>
<Link href={pathForAdminPhotos()}>
Admin
</Link>
{SHOW_REPO_LINK &&
<RepoLink />}
</>}
</div>
<div className="flex items-center h-4">
<ThemeSwitcher />
</div>
</div>]
: []}
/>}
/>
);
}

View File

@ -1,10 +1,77 @@
import { authCached } from '@/auth/cache';
import NavClient from './NavClient';
'use client';
import { clsx } from 'clsx/lite';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import SiteGrid from '../components/SiteGrid';
import ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher';
import {
PATH_ROOT,
isPathAdmin,
isPathGrid,
isPathProtected,
isPathSignIn,
} from '@/site/paths';
import AnimateItems from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';
export default function Nav({
siteDomainOrTitle,
}: {
siteDomainOrTitle: string;
}) {
const pathname = usePathname();
const { isUserSignedIn } = useAppState();
const showNav = !isPathSignIn(pathname);
const renderLink = (
text: string,
linkOrAction: string | (() => void),
) =>
typeof linkOrAction === 'string'
? <Link href={linkOrAction}>{text}</Link>
: <button onClick={linkOrAction}>{text}</button>;
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
if (pathname === PATH_ROOT) {
return 'full-frame';
} else if (isPathGrid(pathname)) {
return 'grid';
} else if (isPathProtected(pathname)) {
return 'admin';
}
};
export default async function Nav() {
// Make nav auth resilient to error on first time setup
const session = await authCached().catch(() => null);
return (
<NavClient showAdmin={Boolean(session?.user?.email)} />
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={!isPathAdmin(pathname) ? 'bottom' : 'none'}
distanceOffset={10}
items={showNav
? [<div
key="nav"
className={clsx(
'flex items-center',
'w-full min-h-[4rem]',
)}>
<ViewSwitcher
currentSelection={switcherSelectionForPath()}
showAdmin={isUserSignedIn}
/>
<div className={clsx(
'flex-grow text-right text-ellipsis overflow-hidden',
'hidden xs:block',
)}>
{renderLink(siteDomainOrTitle, PATH_ROOT)}
</div>
</div>]
: []}
/>
}
/>
);
}
};

View File

@ -1,77 +0,0 @@
'use client';
import { clsx } from 'clsx/lite';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import SiteGrid from '../components/SiteGrid';
import { SITE_DOMAIN_OR_TITLE } from '@/site/config';
import ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher';
import {
PATH_ROOT,
isPathAdmin,
isPathGrid,
isPathProtected,
isPathSignIn,
} from '@/site/paths';
import AnimateItems from '../components/AnimateItems';
export default function NavClient({
showAdmin,
}: {
showAdmin?: boolean,
}) {
const pathname = usePathname();
const showNav = !isPathSignIn(pathname);
const shouldAnimate = !isPathAdmin(pathname);
const renderLink = (
text: string,
linkOrAction: string | (() => void),
) =>
typeof linkOrAction === 'string'
? <Link href={linkOrAction}>{text}</Link>
: <button onClick={linkOrAction}>{text}</button>;
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
if (pathname === PATH_ROOT) {
return 'full-frame';
} else if (isPathGrid(pathname)) {
return 'grid';
} else if (isPathProtected(pathname)) {
return 'admin';
}
};
return (
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={!shouldAnimate ? 'none' : 'bottom'}
distanceOffset={10}
items={showNav
? [<div
key="nav"
className={clsx(
'flex items-center',
'w-full min-h-[4rem]',
)}>
<ViewSwitcher
currentSelection={switcherSelectionForPath()}
showAdmin={showAdmin}
/>
<div className={clsx(
'flex-grow text-right text-ellipsis overflow-hidden',
'hidden xs:block',
)}>
{renderLink(SITE_DOMAIN_OR_TITLE, PATH_ROOT)}
</div>
</div>]
: []}
/>
}
/>
);
};

View File

@ -2,11 +2,16 @@ import { generateAuthSecret } from '@/auth';
import SiteChecklistClient from './SiteChecklistClient';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
export default async function SiteChecklist() {
export default async function SiteChecklist({
simplifiedView,
}: {
simplifiedView?: boolean
}) {
const secret = await generateAuthSecret();
return (
<SiteChecklistClient {...{
...CONFIG_CHECKLIST_STATUS,
simplifiedView,
secret,
}} />
);

View File

@ -49,9 +49,11 @@ export default function SiteChecklistClient({
isOgTextBottomAligned,
gridAspectRatio,
hasGridAspectRatio,
simplifiedView,
showRefreshButton,
secret,
}: ConfigChecklistStatus & {
simplifiedView?: boolean
showRefreshButton?: boolean
secret: string
}) {
@ -271,155 +273,157 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_SITE_DOMAIN'])}
</ChecklistRow>
</Checklist>
<Checklist
title="AI Text Generation"
icon={<HiSparkles />}
experimental
optional
>
<ChecklistRow
title="Add OpenAI Secret Key"
status={isAiTextGenerationEnabled}
isPending={isPendingPage}
{!simplifiedView && <>
<Checklist
title="AI Text Generation"
icon={<HiSparkles />}
experimental
optional
>
Store your OpenAI secret key in order to add experimental support
for AI-generated text descriptions and enable an invisible field
called {'"Semantic Description"'} used to support CMD-K search
{renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow>
<ChecklistRow
title="Enable Rate Limiting"
status={hasVercelKV}
isPending={isPendingPage}
optional
>
{renderLink(
<ChecklistRow
title="Add OpenAI Secret Key"
status={isAiTextGenerationEnabled}
isPending={isPendingPage}
optional
>
Store your OpenAI secret key in order to add experimental support
for AI-generated text descriptions and enable an invisible field
called {'"Semantic Description"'} used to support CMD-K search
{renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow>
<ChecklistRow
title="Enable Rate Limiting"
status={hasVercelKV}
isPending={isPendingPage}
optional
>
{renderLink(
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database',
'Create Vercel KV store',
)}
{' '}
and connect to project in order to enable rate limiting
</ChecklistRow>
<ChecklistRow
// eslint-disable-next-line max-len
'https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database',
'Create Vercel KV store',
)}
{' '}
and connect to project in order to enable rate limiting
</ChecklistRow>
<ChecklistRow
// eslint-disable-next-line max-len
title={`Auto-generated fields: ${aiTextAutoGeneratedFields.join(', ')}`}
status={hasAiTextAutoGeneratedFields}
isPending={isPendingPage}
title={`Auto-generated fields: ${aiTextAutoGeneratedFields.join(', ')}`}
status={hasAiTextAutoGeneratedFields}
isPending={isPendingPage}
optional
>
Comma-separated fields to auto-generate when
uploading photos. Accepted values: title, caption,
tags, description, all, or none (default is {'"all"'}).
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
</ChecklistRow>
</Checklist>
<Checklist
title="Settings"
icon={<BiCog size={16} />}
optional
>
Comma-separated fields to auto-generate when
uploading photos. Accepted values: title, caption,
tags, description, all, or none (default is {'"all"'}).
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
</ChecklistRow>
</Checklist>
<Checklist
title="Settings"
icon={<BiCog size={16} />}
optional
>
<ChecklistRow
title="Pro mode"
status={isProModeEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to enable
higher quality image storage:
{renderEnvVars(['NEXT_PUBLIC_PRO_MODE'])}
</ChecklistRow>
<ChecklistRow
title="Image blur"
status={isBlurEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
image blur data being stored and displayed
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
<ChecklistRow
title="Geo privacy"
status={isGeoPrivacyEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to disable
collection/display of location-based data
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow>
<ChecklistRow
title="Priority order"
status={isPriorityOrderEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
priority order photo field affecting photo order
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
</ChecklistRow>
<ChecklistRow
title="Public API"
status={isPublicApiEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to enable
a public API available at <code>/api</code>:
{renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
<ChecklistRow
title="Show Fujifilm simulations"
status={showFilmSimulations}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
simulations showing up in <code>/grid</code> sidebar:
{renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])}
</ChecklistRow>
<ChecklistRow
title="Show EXIF data"
status={showExifInfo}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
isPending={isPendingPage}
optional
>
Set environment variable to any number to enforce aspect ratio
{' '}
(default is {'"1"'}, i.e., square)set to {'"0"'} to disable:
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
</ChecklistRow>
<ChecklistRow
title="Legacy OG text alignment"
status={isOgTextBottomAligned}
isPending={isPendingPage}
optional
>
Set environment variable to {'"BOTTOM"'} to
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
</Checklist>
<ChecklistRow
title="Pro mode"
status={isProModeEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to enable
higher quality image storage:
{renderEnvVars(['NEXT_PUBLIC_PRO_MODE'])}
</ChecklistRow>
<ChecklistRow
title="Image Blur"
status={isBlurEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
image blur data being stored and displayed
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
</ChecklistRow>
<ChecklistRow
title="Geo privacy"
status={isGeoPrivacyEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to disable
collection/display of location-based data
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow>
<ChecklistRow
title="Priority order"
status={isPriorityOrderEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
priority order photo field affecting photo order
{renderEnvVars(['NEXT_PUBLIC_IGNORE_PRIORITY_ORDER'])}
</ChecklistRow>
<ChecklistRow
title="Public API"
status={isPublicApiEnabled}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to enable
a public API available at <code>/api</code>:
{renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to hide footer link:
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
<ChecklistRow
title="Show Fujifilm simulations"
status={showFilmSimulations}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to prevent
simulations showing up in <code>/grid</code> sidebar:
{renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])}
</ChecklistRow>
<ChecklistRow
title="Show EXIF data"
status={showExifInfo}
isPending={isPendingPage}
optional
>
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio}
isPending={isPendingPage}
optional
>
Set environment variable to any number to enforce aspect ratio
{' '}
(default is {'"1"'}, i.e., square)set to {'"0"'} to disable:
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
</ChecklistRow>
<ChecklistRow
title="Legacy OG text alignment"
status={isOgTextBottomAligned}
isPending={isPendingPage}
optional
>
Set environment variable to {'"BOTTOM"'} to
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
</Checklist>
</>}
{showRefreshButton &&
<div className="py-4 space-y-4">
<button onClick={refreshPage}>

Some files were not shown because too many files have changed in this diff Show More