Merge branch 'main' into ppr-static

This commit is contained in:
Sam Becker 2024-04-28 17:36:32 -05:00
commit 999d40869c
19 changed files with 252 additions and 111 deletions

View File

@ -6,6 +6,7 @@ import {
PATH_ADMIN_CONFIGURATION,
checkPathPrefix,
isPathAdminConfiguration,
isPathTopLevelAdmin,
} from '@/site/paths';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
@ -16,6 +17,7 @@ import { useEffect, useMemo, useState } from 'react';
import { BiCog } from 'react-icons/bi';
import { FaRegClock } from 'react-icons/fa';
// Updates considered recent if they occurred in past 5 minutes
const areTimesRecent = (dates: Date[]) => dates
.some(date => differenceInMinutes(new Date(), date) < 5);
@ -39,17 +41,20 @@ export default function AdminNavClient({
.concat(adminUpdateTimes)
, [mostRecentPhotoUpdateTime, adminUpdateTimes]);
const [shouldShowBanner, setShouldShowBanner] =
const [hasRecentUpdates, setHasRecentUpdates] =
useState(areTimesRecent(updateTimes));
useEffect(() => {
// Check every 10 seconds if update times are recent
const timeout = setTimeout(() =>
setShouldShowBanner(areTimesRecent(updateTimes))
, 10_000);
return () => clearTimeout(timeout);
// Check every 5 seconds if update times are recent
setHasRecentUpdates(areTimesRecent(updateTimes));
const interval = setInterval(() =>
setHasRecentUpdates(areTimesRecent(updateTimes))
, 5_000);
return () => clearInterval(interval);
}, [updateTimes]);
const shouldShowBanner = hasRecentUpdates && isPathTopLevelAdmin(pathname);
return (
<SiteGrid
contentMain={
@ -94,7 +99,7 @@ export default function AdminNavClient({
<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
Photo updates detectedthey may take several minutes to show up
for visitors
</div>
</InfoBlock>}

View File

@ -19,19 +19,29 @@ import {
syncPhotoExifDataAction,
} from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
export default function AdminPhotoTable({
photos,
onLastPhotoVisible,
revalidatePhoto,
}: {
photos: Photo[],
onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto
}) {
const { invalidateSwr } = useAppState();
return (
<AdminTable>
{photos.map(photo =>
{photos.map((photo, index) =>
<Fragment key={photo.id}>
<PhotoTiny photo={photo} />
<PhotoTiny
photo={photo}
onVisible={index === photos.length - 1
? onLastPhotoVisible
: undefined}
/>
<div className="flex flex-col lg:flex-row">
<Link
key={photo.id}
@ -43,7 +53,7 @@ export default function AdminPhotoTable({
'inline-flex items-center gap-2',
photo.hidden && 'text-dim',
)}>
<span>{photo.title || 'Untitled'}</span>
<span>{titleForPhoto(photo)}</span>
{photo.hidden &&
<AiOutlineEyeInvisible
className="translate-y-[0.25px]"
@ -91,6 +101,7 @@ export default function AdminPhotoTable({
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
onSubmit={() => revalidatePhoto?.(photo.id, true)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />

View File

@ -0,0 +1,28 @@
'use client';
import InfinitePhotoScroll, {
InfinitePhotoScrollExternalProps,
} from '../photo/InfinitePhotoScroll';
import AdminPhotoTable from './AdminPhotoTable';
export default function AdminPhotoTableInfinite({
initialOffset,
itemsPerPage,
}: InfinitePhotoScrollExternalProps) {
return (
<InfinitePhotoScroll
cacheKey="AdminPhotoTable"
initialOffset={initialOffset}
itemsPerPage={itemsPerPage}
useCachedPhotos={false}
includeHiddenPhotos
>
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
<AdminPhotoTable
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}
/>}
</InfinitePhotoScroll>
);
}

View File

@ -1,40 +1,36 @@
import PhotoUpload from '@/photo/PhotoUpload';
import { clsx } from 'clsx/lite';
import SiteGrid from '@/components/SiteGrid';
import { pathForAdminPhotos } from '@/site/paths';
import { getPhotosCountIncludingHiddenCached } from '@/photo/cache';
import {
PaginationParams,
getPaginationFromSearchParams,
} from '@/site/pagination';
import StorageUrls from '@/admin/StorageUrls';
import { PRO_MODE_ENABLED } from '@/site/config';
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import MoreComponentsFromSearchParams from
'@/components/MoreComponentsFromSearchParams';
import { getPhotos } from '@/services/vercel-postgres';
import { revalidatePath } from 'next/cache';
import AdminPhotoTable from '@/admin/AdminPhotoTable';
import AdminPhotoTableInfinite from
'@/admin/AdminPhotoTableInfinite';
const DEBUG_PHOTO_BLOBS = false;
export default async function AdminPhotosPage({
searchParams,
}: PaginationParams) {
const { offset, limit } = getPaginationFromSearchParams(searchParams);
const INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS = 25;
const INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS = 50;
export default async function AdminPhotosPage() {
const [
photos,
count,
photosCount,
blobPhotoUrls,
] = await Promise.all([
getPhotos({ includeHidden: true, sortBy: 'createdAt', limit }),
getPhotos({
includeHidden: true,
sortBy: 'createdAt',
limit: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS,
}),
getPhotosCountIncludingHiddenCached(),
DEBUG_PHOTO_BLOBS ? getStoragePhotoUrlsNoStore() : [],
]);
const showMorePhotos = count > photos.length;
return (
<SiteGrid
contentMain={
@ -59,10 +55,10 @@ export default async function AdminPhotosPage({
</div>}
<div className="space-y-4">
<AdminPhotoTable photos={photos} />
{showMorePhotos &&
<MoreComponentsFromSearchParams
label="More photos"
path={pathForAdminPhotos(offset + 1)}
{photosCount > photos.length &&
<AdminPhotoTableInfinite
initialOffset={INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS}
/>}
</div>
</div>}

View File

@ -1,4 +1,3 @@
import { getPhotosCached } from '@/photo/cache';
import SiteGrid from '@/components/SiteGrid';
import {
INFINITE_SCROLL_INITIAL_GRID,
@ -10,11 +9,15 @@ import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response';
import { Metadata } from 'next/types';
import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { getPhotoSidebarDataCached } from '@/photo/data';
import InfinitePhotoScroll from '@/photo/InfinitePhotoScroll';
import { getPhotoSidebarData } from '@/photo/data';
import { getPhotos } from '@/services/vercel-postgres';
import { cache } from 'react';
import PhotoGridInfinite from '@/photo/PhotoGridInfinite';
export const dynamic = 'force-static';
const getPhotosCached = cache(getPhotos);
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG });
return generateOgImageMetaForPhotos(photos);
@ -29,7 +32,7 @@ export default async function GridPage() {
simulations,
] = await Promise.all([
getPhotosCached({ limit: INFINITE_SCROLL_INITIAL_GRID }),
...getPhotoSidebarDataCached(),
...getPhotoSidebarData(),
]);
return (
@ -38,8 +41,7 @@ export default async function GridPage() {
contentMain={<div className="space-y-0.5 sm:space-y-1">
<PhotoGrid {...{ photos }} />
{photosCount > photos.length &&
<InfinitePhotoScroll
type='grid'
<PhotoGridInfinite
initialOffset={INFINITE_SCROLL_INITIAL_GRID}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_GRID}
/>}

View File

@ -1,4 +1,3 @@
import { getPhotosCachedCached, getPhotosCountCached } from '@/photo/cache';
import {
INFINITE_SCROLL_INITIAL_HOME,
INFINITE_SCROLL_MULTIPLE_HOME,
@ -7,14 +6,18 @@ import {
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';
import { cache } from 'react';
import { getPhotos, getPhotosCount } from '@/services/vercel-postgres';
import PhotosLargeInfinite from '@/photo/PhotosLargeInfinite';
export const dynamic = 'force-static';
const getPhotosCached = cache(getPhotos);
export async function generateMetadata(): Promise<Metadata> {
// Make homepage queries resilient to error on first time setup
const photos = await getPhotosCachedCached({
const photos = await getPhotosCached({
limit: MAX_PHOTOS_TO_SHOW_OG,
})
.catch(() => []);
@ -27,11 +30,11 @@ export default async function HomePage() {
photos,
photosCount,
] = await Promise.all([
getPhotosCachedCached({
getPhotosCached({
limit: INFINITE_SCROLL_INITIAL_HOME,
})
.catch(() => []),
getPhotosCountCached()
getPhotosCount()
.catch(() => 0),
]);
@ -40,8 +43,7 @@ export default async function HomePage() {
? <div className="space-y-1">
<PhotosLarge {...{ photos }} />
{photosCount > photos.length &&
<InfinitePhotoScroll
type="full-frame"
<PhotosLargeInfinite
initialOffset={INFINITE_SCROLL_INITIAL_HOME}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_HOME}
/>}

View File

@ -24,7 +24,7 @@ export default function InfoBlock({
case 'blue': return [
'text-main',
'bg-blue-50/50 border-blue-200',
'dark:bg-blue-950/30 dark:border-blue-700/45',
'dark:bg-blue-950/30 dark:border-blue-600/50',
];
}
};

View File

@ -1,17 +1,16 @@
'use client';
import useSwrInfinite from 'swr/infinite';
import PhotosLarge from '@/photo/PhotosLarge';
import {
ReactNode,
useCallback,
useMemo,
useRef,
} from 'react';
import SiteGrid from '@/components/SiteGrid';
import Spinner from '@/components/Spinner';
import { getPhotosAction } from '@/photo/actions';
import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions';
import { Photo } from '.';
import PhotoGrid from './PhotoGrid';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
@ -20,19 +19,33 @@ export type RevalidatePhoto = (
revalidateRemainingPhotos?: boolean,
) => Promise<any>;
export default function InfinitePhotoScroll({
type = 'full-frame',
initialOffset,
itemsPerPage,
}: {
type: 'full-frame' | 'grid'
export type InfinitePhotoScrollExternalProps = {
initialOffset: number
itemsPerPage: number
debug?: boolean
}
export default function InfinitePhotoScroll({
cacheKey,
initialOffset,
itemsPerPage,
wrapMoreButtonInGrid,
useCachedPhotos = true,
includeHiddenPhotos,
children,
}: InfinitePhotoScrollExternalProps & {
cacheKey: string
wrapMoreButtonInGrid?: boolean
useCachedPhotos?: boolean
includeHiddenPhotos?: boolean
children: (props: {
photos: Photo[]
onLastPhotoVisible: () => void
revalidatePhoto?: RevalidatePhoto
}) => ReactNode
}) {
const { swrTimestamp, isUserSignedIn } = useAppState();
const key = `${swrTimestamp}-${type}`;
const key = `${swrTimestamp}-${cacheKey}`;
const keyGenerator = useCallback(
(size: number, prev: Photo[]) => prev && prev.length === 0
@ -40,13 +53,19 @@ export default function InfinitePhotoScroll({
: [key, size]
, [key]);
const fetcher = useCallback(([_key, size]: [string, number]) => {
console.log('Fetching', size);
return getPhotosAction(
initialOffset + size * itemsPerPage,
itemsPerPage,
);
}, [initialOffset, itemsPerPage]);
const fetcher = useCallback(([_key, size]: [string, number]) =>
useCachedPhotos
? getPhotosCachedAction(
initialOffset + size * itemsPerPage,
itemsPerPage,
includeHiddenPhotos,
)
: getPhotosAction(
initialOffset + size * itemsPerPage,
itemsPerPage,
includeHiddenPhotos,
)
, [useCachedPhotos, initialOffset, itemsPerPage, includeHiddenPhotos]);
const { data, isLoading, isValidating, error, mutate, setSize } =
useSwrInfinite<Photo[]>(
@ -107,17 +126,12 @@ export default function InfinitePhotoScroll({
return (
<div className="space-y-4">
{type === 'full-frame'
? <PhotosLarge {...{
photos,
revalidatePhoto,
onLastPhotoVisible: advance,
}} />
: <PhotoGrid {...{
photos,
onLastPhotoVisible: advance,
}} />}
{!isFinished && (type === 'full-frame'
{children({
photos,
onLastPhotoVisible: advance,
revalidatePhoto,
})}
{!isFinished && (wrapMoreButtonInGrid
? <SiteGrid contentMain={renderMoreButton()} />
: renderMoreButton())}
</div>

View File

@ -0,0 +1,25 @@
'use client';
import InfinitePhotoScroll, {
InfinitePhotoScrollExternalProps,
} from './InfinitePhotoScroll';
import PhotoGrid from './PhotoGrid';
export default function PhotoGridInfinite({
initialOffset,
itemsPerPage,
}: InfinitePhotoScrollExternalProps) {
return (
<InfinitePhotoScroll
cacheKey="Grid"
initialOffset={initialOffset}
itemsPerPage={itemsPerPage}
>
{({ photos, onLastPhotoVisible }) =>
<PhotoGrid
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
/>}
</InfinitePhotoScroll>
);
}

View File

@ -22,7 +22,8 @@ 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';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
export default function PhotoLarge({
photo,
@ -63,20 +64,7 @@ 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]);
useOnVisible(ref, onVisible);
return (
<SiteGrid

View File

@ -8,7 +8,8 @@ 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';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
export default function PhotoSmall({
photo,
@ -31,20 +32,7 @@ export default function PhotoSmall({
}) {
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]);
useOnVisible(ref, onVisible);
return (
<Link

View File

@ -4,6 +4,8 @@ import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
export default function PhotoTiny({
photo,
@ -11,15 +13,22 @@ export default function PhotoTiny({
selected,
className,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
onVisible,
}: {
photo: Photo
tag?: string
selected?: boolean
className?: string
prefetch?: boolean
onVisible?: () => void
}) {
const ref = useRef<HTMLAnchorElement>(null);
useOnVisible(ref, onVisible);
return (
<Link
ref={ref}
href={pathForPhoto(photo, tag)}
className={clsx(
className,

View File

@ -7,14 +7,14 @@ export default function PhotosLarge({
photos,
animate = true,
prefetchFirstPhotoLinks,
revalidatePhoto,
onLastPhotoVisible,
revalidatePhoto,
}: {
photos: Photo[]
animate?: boolean
prefetchFirstPhotoLinks?: boolean
revalidatePhoto?: RevalidatePhoto
onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto
}) {
return (
<AnimateItems

View File

@ -0,0 +1,27 @@
'use client';
import InfinitePhotoScroll, {
InfinitePhotoScrollExternalProps,
} from './InfinitePhotoScroll';
import PhotosLarge from './PhotosLarge';
export default function PhotosLargeInfinite({
initialOffset,
itemsPerPage,
}: InfinitePhotoScrollExternalProps) {
return (
<InfinitePhotoScroll
cacheKey="PhotosLarge"
initialOffset={initialOffset}
itemsPerPage={itemsPerPage}
wrapMoreButtonInGrid
>
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
<PhotosLarge
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}
/>}
</InfinitePhotoScroll>
);
}

View File

@ -195,8 +195,19 @@ export async function streamAiImageQueryAction(
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
}
export const getPhotosAction = async (offset: number, limit: number) =>
getPhotosCachedCached({ offset, limit });
export const getPhotosCachedAction = async (
offset: number,
limit: number,
includeHidden?: boolean,
) =>
getPhotosCachedCached({ offset, includeHidden, limit });
export const getPhotosAction = async (
offset: number,
limit: number,
includeHidden?: boolean,
) =>
getPhotos({ offset, includeHidden, limit });
export const queryPhotosByTitleAction = async (query: string) =>
(await getPhotos({ query, limit: 10 }))

View File

@ -4,14 +4,25 @@ import {
getUniqueFilmSimulationsCached,
getUniqueTagsCached,
} from '@/photo/cache';
import {
getPhotosCount,
getUniqueCameras,
getUniqueFilmSimulations,
getUniqueTags,
} from '@/services/vercel-postgres';
import { SHOW_FILM_SIMULATIONS } from '@/site/config';
import { TAG_FAVS } from '@/tag';
import { sortTagsObject } from '@/tag';
export const getPhotoSidebarData = () => [
getPhotosCount(),
getUniqueTags().then(sortTagsObject),
getUniqueCameras(),
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulations() : [],
] as const;
export const getPhotoSidebarDataCached = () => [
getPhotosCountCached(),
getUniqueTagsCached().then(tags =>
tags.filter(({ tag }) => tag === TAG_FAVS).concat(
tags.filter(({ tag }) => tag !== TAG_FAVS))),
getUniqueTagsCached().then(sortTagsObject),
getUniqueCamerasCached(),
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],
] as const;

View File

@ -5,7 +5,7 @@ import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
// HARD-CODED GLOBAL CONFIGURATION
export const SHOULD_PREFETCH_ALL_LINKS: boolean | undefined = undefined;
export const SHOULD_DEBUG_SQL = false;
export const SHOULD_DEBUG_SQL = true;
// META / DOMAINS

View File

@ -241,6 +241,9 @@ export const isPathSignIn = (pathname?: string) =>
export const isPathAdmin = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN);
export const isPathTopLevelAdmin = (pathname?: string) =>
PATHS_ADMIN.some(path => path === pathname);
export const isPathAdminConfiguration = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION);

View File

@ -0,0 +1,21 @@
import { useEffect } from 'react';
export default function useOnVisible(
ref: React.RefObject<HTMLElement>,
onVisible?: () => void
) {
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();
}
}, [ref, onVisible]);
}