Refine admin photo menu placement/appearance

This commit is contained in:
Sam Becker 2025-03-25 10:08:48 -05:00
parent 0f1753fad0
commit 477d7c088e
4 changed files with 150 additions and 154 deletions

View File

@ -1,12 +1,131 @@
import { authCachedSafe } from '@/auth/cache';
import AdminPhotoMenuClient from './AdminPhotoMenuClient';
import { ComponentProps } from 'react';
'use client';
export default async function AdminPhotoMenu(
props: ComponentProps<typeof AdminPhotoMenuClient>,
) {
const session = await authCachedSafe();
return Boolean(session?.user?.email)
? <AdminPhotoMenuClient {...props} />
: null;
import { ComponentProps, useMemo } from 'react';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
import {
deletePhotoAction,
syncPhotoAction,
toggleFavoritePhotoAction,
} from '@/photo/actions';
import {
Photo,
deleteConfirmationTextForPhoto,
downloadFileNameForPhoto,
} from '@/photo';
import { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/more/MoreMenu';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';
import IconGrSync from '@/components/icons/IconGrSync';
import { isPhotoOutdated } from '@/photo/outdated';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import IconFavs from '@/components/icons/IconFavs';
import IconEdit from '@/components/icons/IconEdit';
export default function AdminPhotoMenu({
photo,
revalidatePhoto,
includeFavorite = true,
...props
}: Omit<ComponentProps<typeof MoreMenu>, 'items'> & {
photo: Photo
revalidatePhoto?: RevalidatePhoto
includeFavorite?: boolean
}) {
const { isUserSignedIn, registerAdminUpdate } = useAppState();
const isFav = isPhotoFav(photo);
const path = usePathname();
const shouldRedirectFav = isPathFavs(path) && isFav;
const shouldRedirectDelete = pathForPhoto({ photo: photo.id }) === path;
const items = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Edit',
icon: <IconEdit
size={15}
className="translate-x-[0.5px] translate-y-[-0.5px]"
/>,
href: pathForAdminPhotoEdit(photo.id),
}];
if (includeFavorite) {
items.push({
label: isFav ? 'Unfavorite' : 'Favorite',
icon: <IconFavs
size={14}
className="translate-x-[-1px]"
highlight={isFav}
/>,
action: () => toggleFavoritePhotoAction(
photo.id,
shouldRedirectFav,
).then(() => revalidatePhoto?.(photo.id)),
});
}
items.push({
label: 'Download',
icon: <MdOutlineFileDownload
size={17}
className="translate-x-[-1px] translate-y-[-0.5px]"
/>,
href: photo.url,
hrefDownloadName: downloadFileNameForPhoto(photo),
});
items.push({
label: 'Sync',
labelComplex: <span className="inline-flex items-center gap-2">
<span>Sync</span>
{isPhotoOutdated(photo) &&
<InsightsIndicatorDot
colorOverride="blue"
className="translate-y-[1.5px]"
/>}
</span>,
icon: <IconGrSync className="translate-x-[-1px]" />,
action: () => syncPhotoAction(photo.id)
.then(() => revalidatePhoto?.(photo.id)),
});
items.push({
label: 'Delete',
icon: <BiTrash
size={15}
className="translate-x-[-1px]"
/>,
className: 'text-error *:hover:text-error',
action: () => {
if (confirm(deleteConfirmationTextForPhoto(photo))) {
return deletePhotoAction(
photo.id,
photo.url,
shouldRedirectDelete,
).then(() => {
revalidatePhoto?.(photo.id, true);
registerAdminUpdate?.();
});
}
},
});
return items;
}, [
photo,
includeFavorite,
isFav,
shouldRedirectFav,
revalidatePhoto,
shouldRedirectDelete,
registerAdminUpdate,
]);
return (
isUserSignedIn
? <MoreMenu {...{
items,
...props,
}}/>
: null
);
}

View File

@ -1,131 +0,0 @@
'use client';
import { ComponentProps, useMemo } from 'react';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
import {
deletePhotoAction,
syncPhotoAction,
toggleFavoritePhotoAction,
} from '@/photo/actions';
import {
Photo,
deleteConfirmationTextForPhoto,
downloadFileNameForPhoto,
} from '@/photo';
import { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/more/MoreMenu';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';
import IconGrSync from '@/components/icons/IconGrSync';
import { isPhotoOutdated } from '@/photo/outdated';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import IconFavs from '@/components/icons/IconFavs';
import IconEdit from '@/components/icons/IconEdit';
export default function AdminPhotoMenuClient({
photo,
revalidatePhoto,
includeFavorite = true,
...props
}: Omit<ComponentProps<typeof MoreMenu>, 'items'> & {
photo: Photo
revalidatePhoto?: RevalidatePhoto
includeFavorite?: boolean
}) {
const { isUserSignedIn, registerAdminUpdate } = useAppState();
const isFav = isPhotoFav(photo);
const path = usePathname();
const shouldRedirectFav = isPathFavs(path) && isFav;
const shouldRedirectDelete = pathForPhoto({ photo: photo.id }) === path;
const items = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Edit',
icon: <IconEdit
size={15}
className="translate-x-[0.5px] translate-y-[-0.5px]"
/>,
href: pathForAdminPhotoEdit(photo.id),
}];
if (includeFavorite) {
items.push({
label: isFav ? 'Unfavorite' : 'Favorite',
icon: <IconFavs
size={14}
className="translate-x-[-1px]"
highlight={isFav}
/>,
action: () => toggleFavoritePhotoAction(
photo.id,
shouldRedirectFav,
).then(() => revalidatePhoto?.(photo.id)),
});
}
items.push({
label: 'Download',
icon: <MdOutlineFileDownload
size={17}
className="translate-x-[-1px] translate-y-[-0.5px]"
/>,
href: photo.url,
hrefDownloadName: downloadFileNameForPhoto(photo),
});
items.push({
label: 'Sync',
labelComplex: <span className="inline-flex items-center gap-2">
<span>Sync</span>
{isPhotoOutdated(photo) &&
<InsightsIndicatorDot
colorOverride="blue"
className="translate-y-[1.5px]"
/>}
</span>,
icon: <IconGrSync className="translate-x-[-1px]" />,
action: () => syncPhotoAction(photo.id)
.then(() => revalidatePhoto?.(photo.id)),
});
items.push({
label: 'Delete',
icon: <BiTrash
size={15}
className="translate-x-[-1px]"
/>,
className: 'text-error *:hover:text-error',
action: () => {
if (confirm(deleteConfirmationTextForPhoto(photo))) {
return deletePhotoAction(
photo.id,
photo.url,
shouldRedirectDelete,
).then(() => {
revalidatePhoto?.(photo.id, true);
registerAdminUpdate?.();
});
}
},
});
return items;
}, [
photo,
includeFavorite,
isFav,
shouldRedirectFav,
revalidatePhoto,
shouldRedirectDelete,
registerAdminUpdate,
]);
return (
isUserSignedIn
? <MoreMenu {...{
items,
...props,
}}/>
: null
);
}

View File

@ -46,10 +46,11 @@ export default function MoreMenu({
<DropdownMenu.Trigger asChild>
<button
className={clsx(
'p-1 min-h-0 border-none shadow-none hover:outline-hidden',
'hover:bg-gray-100 active:bg-gray-100',
'p-1 min-h-0 border-none shadow-none',
'hover:bg-gray-100 active:bg-gray-200/75',
'dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
'text-dim',
'outline-none',
buttonClassName,
isOpen && buttonClassNameOpen,
)}

View File

@ -30,7 +30,7 @@ import {
ALLOW_PUBLIC_DOWNLOADS,
SHOW_TAKEN_AT_TIME,
} from '@/app/config';
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
import AdminPhotoMenu from '@/admin/AdminPhotoMenu';
import { RevalidatePhoto } from './InfinitePhotoScroll';
import { useMemo, useRef } from 'react';
import useVisible from '@/utility/useVisible';
@ -179,7 +179,7 @@ export default function PhotoLarge({
? 'w-[80%]'
: undefined;
const largePhotoContent =
const renderLargePhoto =
<div className={clsx(
'relative',
arePhotosMatted && 'flex items-center justify-center',
@ -228,6 +228,15 @@ export default function PhotoLarge({
</div>
</div>;
const renderAdminMenu =
<AdminPhotoMenu {...{
photo,
revalidatePhoto,
buttonClassName: 'translate-y-[-4px]',
includeFavorite: includeFavoriteInAdminMenu,
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
}} />;
const largePhotoContainerClassName = clsx(arePhotosMatted &&
'flex items-center justify-center aspect-3/2 bg-gray-100',
);
@ -238,14 +247,14 @@ export default function PhotoLarge({
className={className}
contentMain={showZoomControls
? <div className={largePhotoContainerClassName}>
{largePhotoContent}
{renderLargePhoto}
</div>
: <Link
href={pathForPhoto({ photo })}
className={largePhotoContainerClassName}
prefetch={prefetch}
>
{largePhotoContent}
{renderLargePhoto}
</Link>}
contentSide={
<DivDebugBaselineGrid className={clsx(
@ -257,13 +266,8 @@ export default function PhotoLarge({
)}>
{/* Meta */}
<div>
<div className="float-right translate-y-[-4px]">
<AdminPhotoMenuClient {...{
photo,
revalidatePhoto,
includeFavorite: includeFavoriteInAdminMenu,
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
}} />
<div className="float-right hidden md:block">
{renderAdminMenu}
</div>
{hasTitle && (showTitleAsH1
? <h1>{renderPhotoLink}</h1>
@ -320,6 +324,9 @@ export default function PhotoLarge({
'space-y-baseline',
!hasTitleContent && !hasMetaContent && 'md:-mt-baseline',
)}>
<div className="float-right md:hidden">
{renderAdminMenu}
</div>
{showExifContent &&
<>
<ul className="text-medium">