Merge branch 'main' into static
This commit is contained in:
commit
48739f2caf
@ -1,8 +1,10 @@
|
||||
import { authCached } from '@/auth/cache';
|
||||
import AdminPhotoMenuClient, { AdminPhotoMenuClientProps }
|
||||
from './AdminPhotoMenuClient';
|
||||
import AdminPhotoMenuClient from './AdminPhotoMenuClient';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export default async function AdminPhotoMenu(props: AdminPhotoMenuClientProps) {
|
||||
export default async function AdminPhotoMenu(
|
||||
props: ComponentProps<typeof AdminPhotoMenuClient>,
|
||||
) {
|
||||
const session = await authCached();
|
||||
return Boolean(session?.user?.email)
|
||||
? <AdminPhotoMenuClient {...props} />
|
||||
|
||||
@ -3,19 +3,42 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { pathForAdminPhotoEdit } from '@/site/paths';
|
||||
import MoreMenu from '../components/MoreMenu';
|
||||
|
||||
export interface AdminPhotoMenuClientProps
|
||||
extends Omit<ComponentProps<typeof MoreMenu>, 'items'> {
|
||||
photoId: string
|
||||
}
|
||||
import { toggleFavoritePhoto } from '@/photo/actions';
|
||||
import { FaRegEdit, FaRegStar, FaStar } from 'react-icons/fa';
|
||||
import { Photo } from '@/photo';
|
||||
import { isPathFavs, isPhotoFav } from '@/tag';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function AdminPhotoMenuClient({
|
||||
photoId,
|
||||
photo,
|
||||
...props
|
||||
}: AdminPhotoMenuClientProps) {
|
||||
}: Omit<ComponentProps<typeof MoreMenu>, 'items'> & {
|
||||
photo: Photo
|
||||
}) {
|
||||
const isFav = isPhotoFav(photo);
|
||||
const path = usePathname();
|
||||
const shouldRedirect = isPathFavs(path) && isFav;
|
||||
return (
|
||||
<MoreMenu {...{
|
||||
items: [{ href: pathForAdminPhotoEdit(photoId), label: 'Edit Photo' }],
|
||||
items: [
|
||||
{
|
||||
label: 'Edit Photo',
|
||||
icon: <FaRegEdit size={14} className="translate-y-[-0.5px]" />,
|
||||
href: pathForAdminPhotoEdit(photo.id),
|
||||
}, {
|
||||
label: isFav ? 'Unfavorite' : 'Favorite',
|
||||
icon: isFav
|
||||
? <FaStar
|
||||
size={14}
|
||||
className="translate-y-[-1px] text-amber-500"
|
||||
/>
|
||||
: <FaRegStar
|
||||
size={14}
|
||||
className="translate-x-[-1px]"
|
||||
/>,
|
||||
action: () => toggleFavoritePhoto(photo.id, shouldRedirect),
|
||||
},
|
||||
],
|
||||
...props,
|
||||
}}/>
|
||||
);
|
||||
|
||||
@ -4,10 +4,12 @@ import { clsx } from 'clsx/lite';
|
||||
export default function Checklist({
|
||||
title,
|
||||
icon,
|
||||
optional,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
icon?: ReactNode
|
||||
optional?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
@ -15,11 +17,13 @@ export default function Checklist({
|
||||
<div className={clsx(
|
||||
'flex items-center gap-3',
|
||||
'text-gray-600 dark:text-gray-300',
|
||||
'pl-[18px] mb-3',
|
||||
'pl-[18px] mb-3 text-lg',
|
||||
)}>
|
||||
{icon}
|
||||
<div className="text-lg">
|
||||
{title}
|
||||
<div className="flex gap-1.5">
|
||||
<div>{title}</div>
|
||||
{optional &&
|
||||
<div className="text-dim">(Optional)</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
|
||||
@ -27,7 +27,6 @@ export default function ChecklistRow({
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="font-bold dark:text-gray-300">
|
||||
{title}
|
||||
{optional && ' (optional)'}
|
||||
</div>
|
||||
<div>
|
||||
{children}
|
||||
|
||||
@ -2,17 +2,46 @@ import { clsx} from 'clsx/lite';
|
||||
import Link from 'next/link';
|
||||
import { Menu } from '@headlessui/react';
|
||||
import { FiMoreHorizontal } from 'react-icons/fi';
|
||||
import { ReactNode } from 'react';
|
||||
import { Fragment, ReactNode, useState } from 'react';
|
||||
|
||||
export default function MoreMenu({
|
||||
items,
|
||||
className,
|
||||
buttonClassName,
|
||||
}: {
|
||||
items: { href: string, label: ReactNode }[]
|
||||
items: {
|
||||
label: ReactNode,
|
||||
icon?: ReactNode,
|
||||
href?: string,
|
||||
action?: () => Promise<void>,
|
||||
}[]
|
||||
className?: string
|
||||
buttonClassName?: string
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const itemClass = clsx(
|
||||
'block w-full',
|
||||
'border-none min-h-0 bg-transparent',
|
||||
'text-left text-main',
|
||||
'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 = (
|
||||
label: ReactNode,
|
||||
icon?: ReactNode,
|
||||
) =>
|
||||
<div className="flex items-center">
|
||||
<span className="w-6">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
className,
|
||||
@ -28,28 +57,39 @@ export default function MoreMenu({
|
||||
<FiMoreHorizontal size={18} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className={clsx(
|
||||
'block outline-none h-auto',
|
||||
'absolute top-6',
|
||||
'min-w-[9rem]',
|
||||
'text-left',
|
||||
'md:right-1',
|
||||
'text-sm',
|
||||
'p-1 rounded-md border',
|
||||
'bg-content',
|
||||
'bg-content outline-none',
|
||||
'shadow-lg dark:shadow-xl',
|
||||
)}>
|
||||
{items.map(({ href, label }) =>
|
||||
<Menu.Item key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className={clsx(
|
||||
'block',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
{items.map(({ label, icon, href, action }) =>
|
||||
<Menu.Item
|
||||
key={`${label}`}
|
||||
disabled={isLoading}
|
||||
as={Fragment}
|
||||
>
|
||||
<>
|
||||
{href &&
|
||||
<Link
|
||||
href={href}
|
||||
className={itemClass}
|
||||
>
|
||||
{renderItemContent(label, icon)}
|
||||
</Link>}
|
||||
{action &&
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLoading(true);
|
||||
action().finally(() => setIsLoading(false));
|
||||
}}
|
||||
className={itemClass}
|
||||
>
|
||||
{renderItemContent(label, icon)}
|
||||
</button>}
|
||||
</>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Items>
|
||||
|
||||
@ -89,8 +89,8 @@ export default function PhotoLarge({
|
||||
</Link>
|
||||
</div>
|
||||
<Suspense>
|
||||
<div className="h-4 translate-y-[-3.5px]">
|
||||
<AdminPhotoMenu photoId={photo.id} />
|
||||
<div className="h-4 translate-y-[-3.5px] z-10">
|
||||
<AdminPhotoMenu photo={photo} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
@ -104,7 +104,7 @@ export default function PhotoLarge({
|
||||
type="text-only"
|
||||
/>
|
||||
{showSimulation && photo.filmSimulation &&
|
||||
<div className="translate-x-[-0.3rem] relative -z-10">
|
||||
<div className="translate-x-[-0.3rem]">
|
||||
<PhotoFilmSimulation
|
||||
simulation={photo.filmSimulation}
|
||||
/>
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
revalidateAllKeysAndPaths,
|
||||
revalidatePhotosKey,
|
||||
} from '@/photo/cache';
|
||||
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths';
|
||||
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS, PATH_ROOT } from '@/site/paths';
|
||||
import { extractExifDataFromBlobPath } from './server';
|
||||
import { TAG_FAVS, isTagFavs } from '@/tag';
|
||||
import { convertPhotoToPhotoDbInsert } from '.';
|
||||
@ -52,7 +52,10 @@ export async function updatePhotoAction(formData: FormData) {
|
||||
redirect(PATH_ADMIN_PHOTOS);
|
||||
}
|
||||
|
||||
export async function toggleFavoritePhoto(photoId: string) {
|
||||
export async function toggleFavoritePhoto(
|
||||
photoId: string,
|
||||
shouldRedirect?: boolean,
|
||||
) {
|
||||
const photo = await getPhoto(photoId);
|
||||
if (photo) {
|
||||
const { tags } = photo;
|
||||
@ -61,6 +64,9 @@ export async function toggleFavoritePhoto(photoId: string) {
|
||||
: [...tags, TAG_FAVS];
|
||||
await sqlUpdatePhoto(convertPhotoToPhotoDbInsert(photo));
|
||||
revalidateAllKeysAndPaths();
|
||||
if (shouldRedirect) {
|
||||
redirect(PATH_ROOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ export default function SiteChecklistClient({
|
||||
hasAwsS3Storage,
|
||||
hasMultipleStorageProviders,
|
||||
currentStorage,
|
||||
hasAuth,
|
||||
hasAuthSecret,
|
||||
hasAdminUser,
|
||||
hasTitle,
|
||||
hasDomain,
|
||||
@ -198,26 +198,27 @@ export default function SiteChecklistClient({
|
||||
>
|
||||
<ChecklistRow
|
||||
title="Setup auth"
|
||||
status={hasAuth}
|
||||
status={hasAuthSecret}
|
||||
isPending={isPendingPage}
|
||||
>
|
||||
Store auth secret in environment variable:
|
||||
<div className="overflow-x-auto">
|
||||
<InfoBlock className="my-1.5 inline-flex" padding="tight">
|
||||
<div className="flex flex-nowrap items-center gap-4">
|
||||
<span>{secret}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{renderCopyButton('Secret', secret)}
|
||||
<IconButton
|
||||
icon={<BiRefresh size={18} />}
|
||||
onClick={refreshSecret}
|
||||
isLoading={isPendingSecret}
|
||||
spinnerColor="text"
|
||||
/>
|
||||
{!hasAuthSecret &&
|
||||
<div className="overflow-x-auto">
|
||||
<InfoBlock className="my-1.5 inline-flex" padding="tight">
|
||||
<div className="flex flex-nowrap items-center gap-4">
|
||||
<span>{secret}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{renderCopyButton('Secret', secret)}
|
||||
<IconButton
|
||||
icon={<BiRefresh size={18} />}
|
||||
onClick={refreshSecret}
|
||||
isLoading={isPendingSecret}
|
||||
spinnerColor="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoBlock>
|
||||
</div>
|
||||
</InfoBlock>
|
||||
</div>}
|
||||
{renderEnvVars(['AUTH_SECRET'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -237,6 +238,7 @@ export default function SiteChecklistClient({
|
||||
<Checklist
|
||||
title="Content"
|
||||
icon={<BiPencil size={16} />}
|
||||
optional
|
||||
>
|
||||
<ChecklistRow
|
||||
title="Add title"
|
||||
@ -260,6 +262,7 @@ export default function SiteChecklistClient({
|
||||
<Checklist
|
||||
title="Settings"
|
||||
icon={<BiCog size={16} />}
|
||||
optional
|
||||
>
|
||||
<ChecklistRow
|
||||
title="Pro Mode"
|
||||
|
||||
@ -110,7 +110,7 @@ export const CONFIG_CHECKLIST_STATUS = {
|
||||
HAS_AWS_S3_STORAGE,
|
||||
hasMultipleStorageProviders: HAS_MULTIPLE_STORAGE_PROVIDERS,
|
||||
currentStorage: CURRENT_STORAGE,
|
||||
hasAuth: (process.env.AUTH_SECRET ?? '').length > 0,
|
||||
hasAuthSecret: (process.env.AUTH_SECRET ?? '').length > 0,
|
||||
hasAdminUser: (
|
||||
(process.env.ADMIN_EMAIL ?? '').length > 0 &&
|
||||
(process.env.ADMIN_PASSWORD ?? '').length > 0
|
||||
@ -134,5 +134,5 @@ export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;
|
||||
export const IS_SITE_READY =
|
||||
CONFIG_CHECKLIST_STATUS.hasPostgres &&
|
||||
CONFIG_CHECKLIST_STATUS.hasStorage &&
|
||||
CONFIG_CHECKLIST_STATUS.hasAuth &&
|
||||
CONFIG_CHECKLIST_STATUS.hasAuthSecret &&
|
||||
CONFIG_CHECKLIST_STATUS.hasAdminUser;
|
||||
|
||||
@ -4,7 +4,11 @@ import {
|
||||
descriptionForPhotoSet,
|
||||
photoQuantityText,
|
||||
} from '@/photo';
|
||||
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
|
||||
import {
|
||||
absolutePathForTag,
|
||||
absolutePathForTagImage,
|
||||
getPathComponents,
|
||||
} from '@/site/paths';
|
||||
import { capitalizeWords, convertStringToArray } from '@/utility/string';
|
||||
|
||||
export const TAG_FAVS = 'favs';
|
||||
@ -77,3 +81,8 @@ export const generateMetaForTag = (
|
||||
});
|
||||
|
||||
export const isTagFavs = (tag: string) => tag.toLowerCase() === TAG_FAVS;
|
||||
|
||||
export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
||||
|
||||
export const isPathFavs = (pathname?: string) =>
|
||||
getPathComponents(pathname).tag === TAG_FAVS;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user