commit
01c123dae7
@ -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
|
||||
|
||||
35
package.json
35
package.json
@ -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
2585
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
25
src/admin/AdminCTA.tsx
Normal file
25
src/admin/AdminCTA.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }} />
|
||||
);
|
||||
}
|
||||
|
||||
93
src/admin/AdminNavClient.tsx
Normal file
93
src/admin/AdminNavClient.tsx
Normal 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 detected—they may take several minutes to show up
|
||||
for visitors
|
||||
</div>
|
||||
</InfoBlock>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
103
src/admin/AdminPhotoTable.tsx
Normal file
103
src/admin/AdminPhotoTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function AdminGrid ({
|
||||
export default function AdminTable ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
40
src/admin/AdminTagBadge.tsx
Normal file
40
src/admin/AdminTagBadge.tsx
Normal 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">
|
||||
|
||||
{photoLabelForCount(count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
return (
|
||||
hideBadge
|
||||
? renderBadgeContent()
|
||||
: <Badge>{renderBadgeContent()}</Badge>
|
||||
);
|
||||
}
|
||||
43
src/admin/AdminTagTable.tsx
Normal file
43
src/admin/AdminTagTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/admin/ClearCacheButton.tsx
Normal file
21
src/admin/ClearCacheButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>}
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
{photoLabelForCount(count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>}
|
||||
breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />}
|
||||
>
|
||||
<TagForm {...{ tag, photos }}>
|
||||
<PhotoLightbox
|
||||
|
||||
@ -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>}
|
||||
/>
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export { GET, POST } from '@/auth';
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -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 <>
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 <>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 <>
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -19,8 +19,7 @@ export async function generateMetadata({
|
||||
|
||||
const [
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
{ count, dateRange },
|
||||
] = await getPhotosTagDataCached({
|
||||
tag,
|
||||
limit: GRID_THUMBNAILS_TO_SHOW_MAX,
|
||||
|
||||
@ -20,8 +20,7 @@ export async function generateMetadata({
|
||||
|
||||
const [
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
{ count, dateRange },
|
||||
] = await getPhotosTagDataCached({
|
||||
tag,
|
||||
limit: GRID_THUMBNAILS_TO_SHOW_MAX,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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')));
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useAppState } from '@/state';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -144,9 +144,9 @@ export default function FieldSetWithStatus({
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>}
|
||||
<div>
|
||||
{accessory && <div>
|
||||
{accessory}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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),
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
19
src/components/PageSpinner.tsx
Normal file
19
src/components/PageSpinner.tsx
Normal 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>
|
||||
} />
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@ export interface EntityLinkExternalProps {
|
||||
type?: LabeledIconType
|
||||
badged?: boolean
|
||||
contrast?: 'low' | 'medium' | 'high'
|
||||
prefetch?: boolean
|
||||
}
|
||||
|
||||
export default function EntityLink({
|
||||
|
||||
125
src/photo/InfinitePhotoScroll.tsx
Normal file
125
src/photo/InfinitePhotoScroll.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -77,7 +77,7 @@ export default function PhotoDetailPage({
|
||||
photo={photo}
|
||||
primaryTag={tag}
|
||||
priority
|
||||
prefetchShare
|
||||
prefetchRelatedLinks
|
||||
showCamera={!camera}
|
||||
showSimulation={!simulation}
|
||||
shouldShareTag={tag !== undefined}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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'] : [])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>)}
|
||||
/>}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
41
src/photo/PhotosLarge.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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[],
|
||||
}) {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
);
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}
|
||||
/>;
|
||||
}
|
||||
|
||||
@ -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>]
|
||||
: []}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>]
|
||||
: []}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>]
|
||||
: []}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>]
|
||||
: []}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user