Merge branch 'main' into static
This commit is contained in:
commit
66f6458dd0
14
package.json
14
package.json
@ -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
1172
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||||
|
}}/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user