Finalize admin recipe management
This commit is contained in:
parent
3684c57dee
commit
ba18289e0e
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()}?`}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
@ -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>
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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} />}
|
||||
|
||||
14
tailwind.css
14
tailwind.css
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user