Merge branch 'main' into static

This commit is contained in:
Sam Becker 2024-02-17 11:16:37 -06:00
commit 66f6458dd0
9 changed files with 953 additions and 482 deletions

View File

@ -9,23 +9,23 @@
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.511.0", "@aws-sdk/client-s3": "3.515.0",
"@aws-sdk/s3-request-presigner": "3.511.0", "@aws-sdk/s3-request-presigner": "3.515.0",
"@headlessui/react": "2.0.0-alpha.4",
"@next/bundle-analyzer": "14.1.0", "@next/bundle-analyzer": "14.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1", "@testing-library/react": "^14.2.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.11.17", "@types/node": "^20.11.19",
"@types/react": "18.2.55", "@types/react": "18.2.55",
"@types/react-dom": "18.2.19", "@types/react-dom": "18.2.19",
"@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1", "@typescript-eslint/parser": "^7.0.1",
"@vercel/analytics": "^1.1.3", "@vercel/analytics": "^1.2.0",
"@vercel/blob": "^0.22.0", "@vercel/blob": "^0.22.0",
"@vercel/postgres": "0.7.2", "@vercel/postgres": "0.7.2",
"@vercel/speed-insights": "^1.0.9", "@vercel/speed-insights": "^1.0.10",
"autoprefixer": "10.4.17", "autoprefixer": "10.4.17",
"camelcase-keys": "^9.1.3", "camelcase-keys": "^9.1.3",
"clsx": "^2.1.0", "clsx": "^2.1.0",
@ -37,7 +37,7 @@
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.5", "nanoid": "^5.0.5",
"next": "14.1.1-canary.56", "next": "14.1.1-canary.58",
"next-auth": "5.0.0-beta.9", "next-auth": "5.0.0-beta.9",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "8.4.35", "postcss": "8.4.35",

1172
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,14 @@
'use client'; 'use client';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { pathForAdminPhotoEdit } from '@/site/paths'; import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths';
import MoreMenu from '../components/MoreMenu'; import { deletePhotoAction, toggleFavoritePhotoAction } from '@/photo/actions';
import { toggleFavoritePhoto } from '@/photo/actions';
import { FaRegEdit, FaRegStar, FaStar } from 'react-icons/fa'; import { FaRegEdit, FaRegStar, FaStar } from 'react-icons/fa';
import { Photo } from '@/photo'; import { Photo, deleteConfirmationTextForPhoto } from '@/photo';
import { isPathFavs, isPhotoFav } from '@/tag'; import { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/MoreMenu';
export default function AdminPhotoMenuClient({ export default function AdminPhotoMenuClient({
photo, photo,
@ -17,29 +18,50 @@ export default function AdminPhotoMenuClient({
}) { }) {
const isFav = isPhotoFav(photo); const isFav = isPhotoFav(photo);
const path = usePathname(); const path = usePathname();
const shouldRedirect = isPathFavs(path) && isFav; const shouldRedirectFav = isPathFavs(path) && isFav;
const shouldRedirectDelete = pathForPhoto(photo.id) === path;
return ( return (
<MoreMenu {...{ <>
items: [ <MoreMenu {...{
{ items: [
label: 'Edit', {
icon: <FaRegEdit size={14} />, label: 'Edit',
href: pathForAdminPhotoEdit(photo.id), icon: <FaRegEdit size={14} />,
}, { href: pathForAdminPhotoEdit(photo.id),
label: isFav ? 'Unfavorite' : 'Favorite', }, {
icon: isFav label: isFav ? 'Unfavorite' : 'Favorite',
? <FaStar icon: isFav
? <FaStar
size={14}
className="text-amber-500 translate-x-[-1.5px]"
/>
: <FaRegStar
size={14}
className="translate-x-[-2px]"
/>,
action: () => toggleFavoritePhotoAction(
photo.id,
shouldRedirectFav,
),
}, {
label: 'Delete',
icon: <BiTrash
size={14} size={14}
className="text-amber-500" className="translate-x-[-1.5px] "
/>
: <FaRegStar
size={14}
className="translate-x-[-1px]"
/>, />,
action: () => toggleFavoritePhoto(photo.id, shouldRedirect), action: () => {
}, if (confirm(deleteConfirmationTextForPhoto(photo))) {
], return deletePhotoAction(
...props, photo.id,
}}/> photo.url,
shouldRedirectDelete,
);
}
},
},
],
...props,
}}/>
</>
); );
} }

View File

@ -5,14 +5,16 @@ import PhotoTiny from '@/photo/PhotoTiny';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import FormWithConfirm from '@/components/FormWithConfirm'; import FormWithConfirm from '@/components/FormWithConfirm';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { deletePhotoAction, syncPhotoExifDataAction } from '@/photo/actions'; import {
deletePhotoFormAction,
syncPhotoExifDataAction,
} from '@/photo/actions';
import { import {
pathForAdminPhotos, pathForAdminPhotos,
pathForPhoto, pathForPhoto,
pathForAdminPhotoEdit, pathForAdminPhotoEdit,
} from '@/site/paths'; } from '@/site/paths';
import { titleForPhoto } from '@/photo'; import { deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo';
import MoreComponentsClient from '@/components/MoreComponentsClient';
import { import {
getPhotosCached, getPhotosCached,
getPhotosCountIncludingHiddenCached, getPhotosCountIncludingHiddenCached,
@ -30,6 +32,7 @@ import { PRO_MODE_ENABLED } from '@/site/config';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import IconGrSync from '@/site/IconGrSync'; import IconGrSync from '@/site/IconGrSync';
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache'; import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import MoreComponentsClient from '@/components/MoreComponentsClient';
const DEBUG_PHOTO_BLOBS = false; const DEBUG_PHOTO_BLOBS = false;
@ -131,10 +134,8 @@ export default async function AdminPhotosPage({
/> />
</FormWithConfirm> </FormWithConfirm>
<FormWithConfirm <FormWithConfirm
action={deletePhotoAction} action={deletePhotoFormAction}
confirmText={ confirmText={deleteConfirmationTextForPhoto(photo)}
// eslint-disable-next-line max-len
`Are you sure you want to delete "${titleForPhoto(photo)}?"`}
> >
<input type="hidden" name="id" value={photo.id} /> <input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} /> <input type="hidden" name="url" value={photo.url} />

View File

@ -1,8 +1,8 @@
import { clsx} from 'clsx/lite'; import React, { ReactNode, useState } from 'react';
import Link from 'next/link'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Menu } from '@headlessui/react'; import clsx from 'clsx';
import { FiMoreHorizontal } from 'react-icons/fi'; import { FiMoreHorizontal } from 'react-icons/fi';
import { Fragment, ReactNode, useState } from 'react'; import Link from 'next/link';
export default function MoreMenu({ export default function MoreMenu({
items, items,
@ -13,26 +13,13 @@ export default function MoreMenu({
label: ReactNode, label: ReactNode,
icon?: ReactNode, icon?: ReactNode,
href?: string, href?: string,
action?: () => Promise<void>, action?: () => Promise<void> | void,
}[] }[]
className?: string className?: string
buttonClassName?: string buttonClassName?: string
}) { }){
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const itemClass = clsx(
'block w-full',
'border-none min-h-0 bg-transparent',
'text-sm text-main text-left',
'px-3 py-1.5 rounded-[3px]',
'hover:text-main',
'hover:bg-gray-50 active:bg-gray-100',
'hover:dark:bg-gray-900/75 active:dark:bg-gray-900',
'whitespace-nowrap',
'shadow-none',
isLoading && 'cursor-not-allowed opacity-50',
);
const renderItemContent = ( const renderItemContent = (
label: ReactNode, label: ReactNode,
icon?: ReactNode, icon?: ReactNode,
@ -43,57 +30,77 @@ export default function MoreMenu({
</div>; </div>;
return ( return (
<div className={clsx( <DropdownMenu.Root>
className, <DropdownMenu.Trigger asChild>
'relative z-10', <button
)}> className={clsx(
<Menu> buttonClassName,
<Menu.Button className={clsx( 'p-1 min-h-0 border-none shadow-none hover:outline-none',
buttonClassName, 'hover:bg-gray-100 active:bg-gray-100',
'p-1 py-1 min-h-0 border-none shadow-none outline-none', 'hover:dark:bg-gray-800/75 active:dark:bg-gray-900',
'text-dim', 'text-dim',
)} )}
aria-label={`Choose an action for photo: ${'photo'}`}
> >
<FiMoreHorizontal size={18} /> <FiMoreHorizontal size={18} />
</Menu.Button> </button>
<Menu.Items className={clsx( </DropdownMenu.Trigger>
'absolute top-6',
'min-w-[8rem]', <DropdownMenu.Portal>
'text-left', <DropdownMenu.Content
'md:right-1', align="end"
'p-1 rounded-md border', className={clsx(
'bg-content outline-none', className,
'shadow-lg dark:shadow-xl', 'min-w-[8rem]',
)}> 'ml-2.5',
'p-1 rounded-md border',
'bg-content',
'shadow-lg dark:shadow-xl',
)}
>
{items.map(({ label, icon, href, action }) => {items.map(({ label, icon, href, action }) =>
<Menu.Item <DropdownMenu.Item
key={`${label}`} key={`${label}`}
disabled={isLoading} disabled={isLoading}
as={Fragment} className={clsx(
'block w-full',
'border-none min-h-0 bg-transparent',
'select-none hover:outline-none',
'text-sm text-main text-left',
'px-3 py-1.5 rounded-[3px]',
'hover:text-main',
'hover:bg-gray-50 active:bg-gray-100',
'hover:dark:bg-gray-900/75 active:dark:bg-gray-900',
'whitespace-nowrap',
'shadow-none',
isLoading
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
)}
onClick={e => {
const result = action?.();
if (result instanceof Promise) {
e.preventDefault();
setIsLoading(true);
result.finally(() => setIsLoading(false));
}
}}
> >
<> <>
{href && {href &&
<Link <Link
href={href} href={href}
className={itemClass} className="hover:text-main"
> >
{renderItemContent(label, icon)} {renderItemContent(label, icon)}
</Link>} </Link>}
{action && {action &&
<button renderItemContent(label, icon)}
onClick={() => {
setIsLoading(true);
action().finally(() => setIsLoading(false));
}}
className={itemClass}
>
{renderItemContent(label, icon)}
</button>}
</> </>
</Menu.Item> </DropdownMenu.Item>
)} )}
</Menu.Items> </DropdownMenu.Content>
</Menu> </DropdownMenu.Portal>
</div> </DropdownMenu.Root>
); );
} };

View File

@ -57,7 +57,7 @@ export default function PhotoGrid({
'aspect-square', 'aspect-square',
'overflow-hidden', 'overflow-hidden',
'[&>*]:flex [&>*]:w-full [&>*]:h-full', '[&>*]:flex [&>*]:w-full [&>*]:h-full',
'[&>*>*]:object-cover [&>*>*]:min-h-full', '[&>*>*]:object-cover',
) )
: undefined} : undefined}
style={{ style={{

View File

@ -5,6 +5,8 @@ import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths'; import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import AdminPhotoMenu from '@/admin/AdminPhotoMenu';
import { Suspense } from 'react';
export default function PhotoSmall({ export default function PhotoSmall({
photo, photo,
@ -12,21 +14,34 @@ export default function PhotoSmall({
camera, camera,
simulation, simulation,
selected, selected,
showAdminMenu,
}: { }: {
photo: Photo photo: Photo
tag?: string tag?: string
camera?: Camera camera?: Camera
simulation?: FilmSimulation simulation?: FilmSimulation
selected?: boolean selected?: boolean
showAdminMenu?: boolean
}) { }) {
return ( return (
<Link <Link
href={pathForPhoto(photo, tag, camera, simulation)} href={pathForPhoto(photo, tag, camera, simulation)}
className={clsx( className={clsx(
'relative group',
'active:brightness-75', 'active:brightness-75',
selected && 'brightness-50', selected && 'brightness-50',
)} )}
> >
<Suspense>
{showAdminMenu &&
<AdminPhotoMenu
buttonClassName={clsx(
'absolute top-1 right-1 opacity-0',
'group-hover:opacity-100 group-focus:opacity-100',
)}
photo={photo}
/>}
</Suspense>
<ImageSmall <ImageSmall
src={photo.url} src={photo.url}
aspectRatio={photo.aspectRatio} aspectRatio={photo.aspectRatio}

View File

@ -52,7 +52,7 @@ export async function updatePhotoAction(formData: FormData) {
redirect(PATH_ADMIN_PHOTOS); redirect(PATH_ADMIN_PHOTOS);
} }
export async function toggleFavoritePhoto( export async function toggleFavoritePhotoAction(
photoId: string, photoId: string,
shouldRedirect?: boolean, shouldRedirect?: boolean,
) { ) {
@ -70,13 +70,26 @@ export async function toggleFavoritePhoto(
} }
} }
export async function deletePhotoAction(formData: FormData) { export async function deletePhotoAction(
photoId: string,
photoUrl: string,
shouldRedirect?: boolean,
) {
await Promise.all([ await Promise.all([
deleteStorageUrl(formData.get('url') as string), deleteStorageUrl(photoUrl),
sqlDeletePhoto(formData.get('id') as string), sqlDeletePhoto(photoId),
]); ]);
revalidateAllKeysAndPaths(); revalidateAllKeysAndPaths();
if (shouldRedirect) {
redirect(PATH_ROOT);
}
};
export async function deletePhotoFormAction(formData: FormData) {
return deletePhotoAction(
formData.get('url') as string,
formData.get('id') as string,
);
}; };
export async function deletePhotoTagGloballyAction(formData: FormData) { export async function deletePhotoTagGloballyAction(formData: FormData) {

View File

@ -180,6 +180,9 @@ export const photoQuantityText = (count: number, includeParentheses = true) =>
? `(${count} ${photoLabelForCount(count)})` ? `(${count} ${photoLabelForCount(count)})`
: `${count} ${photoLabelForCount(count)}`; : `${count} ${photoLabelForCount(count)}`;
export const deleteConfirmationTextForPhoto = (photo: Photo) =>
`Are you sure you want to delete "${titleForPhoto(photo)}?"`;
export type PhotoDateRange = { start: string, end: string }; export type PhotoDateRange = { start: string, end: string };
export const descriptionForPhotoSet = ( export const descriptionForPhotoSet = (