Finalize admin recipe management

This commit is contained in:
Sam Becker 2025-03-12 21:08:56 -05:00
parent 3684c57dee
commit ba18289e0e
14 changed files with 108 additions and 25 deletions

View File

@ -1,7 +1,7 @@
import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getPhotosCached } from '@/photo/cache';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForRecipe } from '@/app/paths';
import { PATH_ADMIN, PATH_ADMIN_RECIPES, pathForRecipe } from '@/app/paths';
import PhotoLightbox from '@/photo/PhotoLightbox';
import { getPhotosMeta } from '@/photo/db/query';
import AdminRecipeBadge from '@/admin/AdminRecipeBadge';
@ -39,8 +39,8 @@ export default async function RecipePageEdit({
return (
<AdminChildPage
backPath={PATH_ADMIN_TAGS}
backLabel="Tags"
backPath={PATH_ADMIN_RECIPES}
backLabel="Recipes"
breadcrumb={<AdminRecipeBadge {...{ recipe, count, hideBadge: true }} />}
accessory={recipeData && filmSimulation &&
<AdminShowRecipeButton

View File

@ -1,9 +1,9 @@
import AdminRecipeTable from '@/admin/AdminRecipeTable';
import SiteGrid from '@/components/SiteGrid';
import { getUniqueRecipesCached } from '@/photo/cache';
import { getUniqueRecipes } from '@/photo/db/query';
export default async function AdminTagsPage() {
const recipes = await getUniqueRecipesCached().catch(() => []);
export default async function AdminRecipesPage() {
const recipes = await getUniqueRecipes().catch(() => []);
return (
<SiteGrid

View File

@ -1,9 +1,9 @@
import AdminTagTable from '@/admin/AdminTagTable';
import SiteGrid from '@/components/SiteGrid';
import { getUniqueTagsHiddenCached } from '@/photo/cache';
import { getUniqueTagsHidden } from '@/photo/db/query';
export default async function AdminTagsPage() {
const tags = await getUniqueTagsHiddenCached().catch(() => []);
const tags = await getUniqueTagsHidden().catch(() => []);
return (
<SiteGrid

View File

@ -5,7 +5,7 @@ import Link from 'next/link';
import { PATH_ADMIN_RECIPES } from '@/app/paths';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { ReactNode, useMemo, useState } from 'react';
import { renamePhotoTagGloballyAction } from '@/photo/actions';
import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string';
import { useAppState } from '@/state/AppState';
@ -31,23 +31,23 @@ export default function AdminRecipeForm({
return (
<form
action={renamePhotoTagGloballyAction}
action={renamePhotoRecipeGloballyAction}
className="space-y-8"
>
<FieldSetWithStatus
id="updatedTagRaw"
label="New Tag Name"
id="updatedRecipeRaw"
label="New Recipe Name"
value={updatedRecipeRaw}
onChange={setUpdatedRecipeRaw}
/>
{/* Form data: tag to be replaced */}
{/* Form data: recipe to be replaced */}
<input
name="recipe"
value={recipe}
hidden
readOnly
/>
{/* Form data: updated tag */}
{/* Form data: updated recipe */}
<input
name="updatedRecipe"
value={updatedRecipe}

View File

@ -1,5 +1,5 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import { deletePhotoTagGloballyAction } from '@/photo/actions';
import { deletePhotoRecipeGloballyAction } from '@/photo/actions';
import AdminTable from '@/admin/AdminTable';
import { Fragment } from 'react';
import DeleteFormButton from '@/admin/DeleteFormButton';
@ -28,7 +28,7 @@ export default function AdminRecipeTable({
)}>
<EditButton path={pathForAdminRecipeEdit(recipe)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
action={deletePhotoRecipeGloballyAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatRecipe(recipe)}" from ${photoQuantityText(count, false).toLowerCase()}?`}

View File

@ -64,6 +64,7 @@ export const PATHS_ADMIN = [
PATH_ADMIN_PHOTOS,
PATH_ADMIN_UPLOADS,
PATH_ADMIN_TAGS,
PATH_ADMIN_RECIPES,
PATH_ADMIN_INSIGHTS,
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_BASELINE,

View File

@ -3,6 +3,7 @@ import LoaderButton from './primitives/LoaderButton';
import clsx from 'clsx/lite';
import { toastSuccess } from '@/toast';
import Tooltip from './Tooltip';
import { ComponentProps } from 'react';
export default function CopyButton({
label,
@ -10,6 +11,7 @@ export default function CopyButton({
subtle,
iconSize = 15,
tooltip,
tooltipColor,
className,
}: {
label: string
@ -17,6 +19,7 @@ export default function CopyButton({
subtle?: boolean
iconSize?: number
tooltip?: string
tooltipColor?: ComponentProps<typeof Tooltip>['color']
className?: string
}) {
const button =
@ -38,7 +41,7 @@ export default function CopyButton({
return (
tooltip
? <Tooltip content={tooltip}>
? <Tooltip content={tooltip} color={tooltipColor}>
{button}
</Tooltip>
: button

View File

@ -5,18 +5,22 @@ export default function MenuSurface({
ref,
children,
className,
color,
}: {
ref?: RefObject<HTMLDivElement | null>
children: ReactNode
className?: string
color?: 'light' | 'dark' | 'frosted'
}) {
return (
<div
ref={ref}
className={clsx(
'component-surface',
color === undefined && 'component-surface shadow-xs dark:shadow-md',
color === 'light' && 'component-surface-light shadow-xs',
color === 'dark' && 'component-surface-dark shadow-md',
color === 'frosted' && 'component-surface-frosted shadow-xs',
'px-2 py-1.5 max-w-[14rem]',
'shadow-xs',
'text-[0.8rem] leading-tight',
'text-balance text-center',
className,

View File

@ -1,6 +1,6 @@
'use client';
import { ReactNode, useRef, useState } from 'react';
import { ReactNode, useRef, useState, ComponentProps } from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
import MenuSurface from './MenuSurface';
import useSupportsHover from '@/utility/useSupportsHover';
@ -13,6 +13,7 @@ export default function TooltipPrimitive({
classNameTrigger: classNameTriggerProp,
sideOffset = 10,
supportMobile,
color,
children,
}: {
content?: ReactNode
@ -20,6 +21,7 @@ export default function TooltipPrimitive({
classNameTrigger?: string
sideOffset?: number
supportMobile?: boolean
color?: ComponentProps<typeof MenuSurface>['color']
children: ReactNode
}) {
const refTrigger = useRef<HTMLButtonElement>(null);
@ -59,7 +61,7 @@ export default function TooltipPrimitive({
{children}
</span>}
</Tooltip.Trigger>
<Tooltip.Portal >
<Tooltip.Portal>
<Tooltip.Content
ref={refContent}
sideOffset={sideOffset}
@ -69,10 +71,12 @@ export default function TooltipPrimitive({
'data-[side=bottom]:animate-fade-in-from-top',
// Extra collision padding
'mx-2',
// Z-index above
'z-100',
)}
>
{content &&
<MenuSurface className={className}>
<MenuSurface {...{ color, className }}>
{content}
</MenuSurface>}
</Tooltip.Content>

View File

@ -10,6 +10,8 @@ import {
getPhotos,
addTagsToPhotos,
getUniqueTags,
deletePhotoRecipeGlobally,
renamePhotoRecipeGlobally,
} from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db';
import {
@ -25,10 +27,12 @@ import {
revalidateAllKeysAndPaths,
revalidatePhoto,
revalidatePhotosKey,
revalidateRecipesKey,
revalidateTagsKey,
} from '@/photo/cache';
import {
PATH_ADMIN_PHOTOS,
PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS,
PATH_ROOT,
pathForPhoto,
@ -301,6 +305,29 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
}
});
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const recipe = formData.get('recipe') as string;
await deletePhotoRecipeGlobally(recipe);
revalidatePhotosKey();
revalidateAdminPaths();
});
export const renamePhotoRecipeGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const recipe = formData.get('recipe') as string;
const updatedRecipe = formData.get('updatedRecipe') as string;
if (recipe && updatedRecipe && recipe !== updatedRecipe) {
await renamePhotoRecipeGlobally(recipe, updatedRecipe);
revalidatePhotosKey();
revalidateRecipesKey();
redirect(PATH_ADMIN_RECIPES);
}
});
export const deleteUploadsAction = async (urls: string[]) =>
runAuthenticatedAdminServerAction(async () => {
await Promise.all(urls.map(url => deleteFile(url)));

View File

@ -30,6 +30,8 @@ import {
PATH_ROOT,
PREFIX_CAMERA,
PREFIX_FILM_SIMULATION,
PREFIX_FOCAL_LENGTH,
PREFIX_RECIPE,
PREFIX_TAG,
pathForPhoto,
} from '@/app/paths';
@ -97,17 +99,25 @@ export const revalidatePhotosKey = () =>
export const revalidateTagsKey = () =>
revalidateTag(KEY_TAGS);
export const revalidateRecipesKey = () =>
revalidateTag(KEY_RECIPES);
export const revalidateCamerasKey = () =>
revalidateTag(KEY_CAMERAS);
export const revalidateFilmSimulationsKey = () =>
revalidateTag(KEY_FILM_SIMULATIONS);
export const revalidateFocalLengthsKey = () =>
revalidateTag(KEY_FOCAL_LENGTHS);
export const revalidateAllKeys = () => {
revalidatePhotosKey();
revalidateTagsKey();
revalidateCamerasKey();
revalidateFilmSimulationsKey();
revalidateRecipesKey();
revalidateFocalLengthsKey();
};
export const revalidateAdminPaths = () => {
@ -125,6 +135,8 @@ export const revalidatePhoto = (photoId: string) => {
revalidateTagsKey();
revalidateCamerasKey();
revalidateFilmSimulationsKey();
revalidateRecipesKey();
revalidateFocalLengthsKey();
// Paths
revalidatePath(pathForPhoto({ photo: photoId }), 'layout');
revalidatePath(PATH_ROOT, 'layout');
@ -133,6 +145,8 @@ export const revalidatePhoto = (photoId: string) => {
revalidatePath(PREFIX_TAG, 'layout');
revalidatePath(PREFIX_CAMERA, 'layout');
revalidatePath(PREFIX_FILM_SIMULATION, 'layout');
revalidatePath(PREFIX_RECIPE, 'layout');
revalidatePath(PREFIX_FOCAL_LENGTH, 'layout');
revalidatePath(PATH_ADMIN, 'layout');
};

View File

@ -265,6 +265,23 @@ export const addTagsToPhotos = (tags: string[], photoIds: string[]) =>
convertArrayToPostgresString(photoIds),
]), 'addTagsToPhotos');
export const deletePhotoRecipeGlobally = (recipe: string) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET recipe_title=NULL
WHERE recipe_title=${recipe}
`, 'deletePhotoRecipeGlobally');
export const renamePhotoRecipeGlobally = (
recipe: string,
updatedRecipe: string,
) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET recipe_title=${updatedRecipe}
WHERE recipe_title=${recipe}
`, 'renamePhotoRecipeGlobally');
export const deletePhoto = (id: string) =>
safelyQueryPhotos(() => sql`
DELETE FROM photos WHERE id=${id}

View File

@ -124,11 +124,12 @@ export default function PhotoRecipeOverlay({
text={generateRecipeText({ recipe, simulation }).join('\n')}
iconSize={17}
className={clsx(
'translate-y-[1.5px]',
'translate-y-[-0.5px]',
'text-black/40 active:text-black/75',
'hover:text-black/40',
)}
tooltip="Copy recipe text"
tooltipColor="frosted"
/>
<LoaderButton
icon={<IoCloseCircle size={20} />}

View File

@ -324,6 +324,18 @@
@layer components {
.component-surface {
@apply
bg-content rounded-lg
text-main bg-content rounded-lg
}
.component-surface-light {
@apply
text-dark bg-white rounded-lg
}
.component-surface-dark {
@apply
text-light bg-black rounded-lg
}
.component-surface-frosted {
@apply
text-black bg-neutral-200/95 rounded-lg
}
}