Merge pull request #206 from sambecker/admin-recipes

Admin recipe tools
This commit is contained in:
Sam Becker 2025-03-12 21:20:56 -05:00 committed by GitHub
commit 073dee9efd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 426 additions and 35 deletions

View File

@ -0,0 +1,62 @@
import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation';
import { getPhotosCached } from '@/photo/cache';
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';
import AdminRecipeForm from '@/admin/AdminRecipeForm';
import { getPhotoWithRecipeFromPhotos } from '@/recipe';
import AdminShowRecipeButton from '@/admin/AdminShowRecipeButton';
const MAX_PHOTO_TO_SHOW = 6;
interface Props {
params: Promise<{ recipe: string }>
}
export default async function RecipePageEdit({
params,
}: Props) {
const { recipe: recipeFromParams } = await params;
const recipe = decodeURIComponent(recipeFromParams);
const [
{ count },
photos,
] = await Promise.all([
getPhotosMeta({ recipe }),
getPhotosCached({ recipe, limit: MAX_PHOTO_TO_SHOW }),
]);
const {
recipeData,
filmSimulation,
} = getPhotoWithRecipeFromPhotos(photos) ?? {};
if (count === 0) { redirect(PATH_ADMIN); }
return (
<AdminChildPage
backPath={PATH_ADMIN_RECIPES}
backLabel="Recipes"
breadcrumb={<AdminRecipeBadge {...{ recipe, count, hideBadge: true }} />}
accessory={recipeData && filmSimulation &&
<AdminShowRecipeButton
title={recipe}
recipe={recipeData}
simulation={filmSimulation}
/>
}
>
<AdminRecipeForm {...{ recipe, photos }}>
<PhotoLightbox
{...{ count, photos, recipe }}
maxPhotosToShow={MAX_PHOTO_TO_SHOW}
moreLink={pathForRecipe(recipe)}
/>
</AdminRecipeForm>
</AdminChildPage>
);
};

View File

@ -0,0 +1,18 @@
import AdminRecipeTable from '@/admin/AdminRecipeTable';
import SiteGrid from '@/components/SiteGrid';
import { getUniqueRecipes } from '@/photo/db/query';
export default async function AdminRecipesPage() {
const recipes = await getUniqueRecipes().catch(() => []);
return (
<SiteGrid
contentMain={
<div className="space-y-6">
<div className="space-y-4">
<AdminRecipeTable {...{ recipes }} />
</div>
</div>}
/>
);
}

View File

@ -1,7 +1,7 @@
import AdminChildPage from '@/components/AdminChildPage'; import AdminChildPage from '@/components/AdminChildPage';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getPhotosCached } from '@/photo/cache'; import { getPhotosCached } from '@/photo/cache';
import TagForm from '@/tag/TagForm'; import AdminTagForm from '@/admin/AdminTagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/app/paths'; import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/app/paths';
import PhotoLightbox from '@/photo/PhotoLightbox'; import PhotoLightbox from '@/photo/PhotoLightbox';
import { getPhotosMeta } from '@/photo/db/query'; import { getPhotosMeta } from '@/photo/db/query';
@ -36,13 +36,13 @@ export default async function PhotoPageEdit({
backLabel="Tags" backLabel="Tags"
breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />} breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />}
> >
<TagForm {...{ tag, photos }}> <AdminTagForm {...{ tag, photos }}>
<PhotoLightbox <PhotoLightbox
{...{ count, photos, tag }} {...{ count, photos, tag }}
maxPhotosToShow={MAX_PHOTO_TO_SHOW} maxPhotosToShow={MAX_PHOTO_TO_SHOW}
moreLink={pathForTag(tag)} moreLink={pathForTag(tag)}
/> />
</TagForm> </AdminTagForm>
</AdminChildPage> </AdminChildPage>
); );
}; };

View File

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

View File

@ -5,6 +5,7 @@ import {
PATH_ADMIN_CONFIGURATION, PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS, PATH_ADMIN_INSIGHTS,
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
PATH_GRID_INFERRED, PATH_GRID_INFERRED,
@ -13,7 +14,7 @@ import { useAppState } from '@/state/AppState';
import { ImCheckboxUnchecked } from 'react-icons/im'; import { ImCheckboxUnchecked } from 'react-icons/im';
import { IoArrowDown, IoArrowUp, IoCloseSharp } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp, IoCloseSharp } from 'react-icons/io5';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { TbPhoto } from 'react-icons/tb'; import { TbChecklist, TbPhoto } from 'react-icons/tb';
import { FiTag } from 'react-icons/fi'; import { FiTag } from 'react-icons/fi';
import { BiLockAlt } from 'react-icons/bi'; import { BiLockAlt } from 'react-icons/bi';
import AdminAppInfoIcon from './AdminAppInfoIcon'; import AdminAppInfoIcon from './AdminAppInfoIcon';
@ -37,6 +38,7 @@ export default function AdminAppMenu({
photosCountTotal = 0, photosCountTotal = 0,
uploadsCount = 0, uploadsCount = 0,
tagsCount = 0, tagsCount = 0,
recipesCount = 0,
selectedPhotoIds, selectedPhotoIds,
startUpload, startUpload,
setSelectedPhotoIds, setSelectedPhotoIds,
@ -103,6 +105,18 @@ export default function AdminAppMenu({
}); });
} }
if (recipesCount) {
items.push({
label: 'Manage Recipes',
annotation: `${recipesCount}`,
icon: <TbChecklist
size={17}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>,
href: PATH_ADMIN_RECIPES,
});
}
if (photosCountTotal) { if (photosCountTotal) {
items.push({ items.push({
label: isSelecting label: isSelecting

View File

@ -2,10 +2,12 @@ import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
import { import {
getPhotosMetaCached, getPhotosMetaCached,
getPhotosMostRecentUpdateCached, getPhotosMostRecentUpdateCached,
getUniqueRecipesCached,
getUniqueTagsCached, getUniqueTagsCached,
} from '@/photo/cache'; } from '@/photo/cache';
import { import {
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
} from '@/app/paths'; } from '@/app/paths';
@ -14,21 +16,24 @@ import AdminNavClient from './AdminNavClient';
export default async function AdminNav() { export default async function AdminNav() {
const [ const [
countPhotos, countPhotos,
countTags,
countUploads, countUploads,
countTags,
countRecipes,
mostRecentPhotoUpdateTime, mostRecentPhotoUpdateTime,
] = await Promise.all([ ] = await Promise.all([
getPhotosMetaCached({ hidden: 'include' }) getPhotosMetaCached({ hidden: 'include' })
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),
getUniqueTagsCached().then(tags => tags.length)
.catch(() => 0),
getStorageUploadUrlsNoStore() getStorageUploadUrlsNoStore()
.then(urls => urls.length) .then(urls => urls.length)
.catch(e => { .catch(e => {
console.error(`Error getting blob upload urls: ${e}`); console.error(`Error getting blob upload urls: ${e}`);
return 0; return 0;
}), }),
getUniqueTagsCached().then(tags => tags.length)
.catch(() => 0),
getUniqueRecipesCached().then(recipes => recipes.length)
.catch(() => 0),
getPhotosMostRecentUpdateCached().catch(() => undefined), getPhotosMostRecentUpdateCached().catch(() => undefined),
]); ]);
@ -55,6 +60,13 @@ export default async function AdminNav() {
count: countTags, count: countTags,
}); } }); }
// Recipes
if (countRecipes > 0) { items.push({
label: 'Recipes',
href: PATH_ADMIN_RECIPES,
count: countRecipes,
}); }
return ( return (
<AdminNavClient {...{ <AdminNavClient {...{
items, items,

View File

@ -0,0 +1,35 @@
import { photoLabelForCount } from '@/photo';
import { clsx } from 'clsx/lite';
import Badge from '@/components/Badge';
import PhotoRecipe from '@/recipe/PhotoRecipe';
export default function AdminRecipeBadge({
recipe,
count,
hideBadge,
}: {
recipe: string,
count: number,
hideBadge?: boolean,
}) {
const renderBadgeContent = () =>
<div className={clsx(
'inline-flex items-center gap-2',
'translate-y-[1.5px]',
)}>
<PhotoRecipe {...{ recipe }} />
<div className="text-dim uppercase">
<span>{count}</span>
<span className="hidden xs:inline-block">
&nbsp;
{photoLabelForCount(count)}
</span>
</div>
</div>;
return (
hideBadge
? renderBadgeContent()
: <Badge className="py-[3px]!">{renderBadgeContent()}</Badge>
);
}

View File

@ -0,0 +1,74 @@
'use client';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link';
import { PATH_ADMIN_RECIPES } from '@/app/paths';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { ReactNode, useMemo, useState } from 'react';
import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string';
import { useAppState } from '@/state/AppState';
export default function AdminRecipeForm({
recipe,
children,
}: {
recipe: string
children?: ReactNode
}) {
const { invalidateSwr } = useAppState();
const [updatedRecipeRaw, setUpdatedRecipeRaw] = useState(recipe);
const updatedRecipe = useMemo(() =>
parameterize(updatedRecipeRaw)
, [updatedRecipeRaw]);
const isFormValid = (
updatedRecipe &&
updatedRecipe !== recipe
);
return (
<form
action={renamePhotoRecipeGloballyAction}
className="space-y-8"
>
<FieldSetWithStatus
id="updatedRecipeRaw"
label="New Recipe Name"
value={updatedRecipeRaw}
onChange={setUpdatedRecipeRaw}
/>
{/* Form data: recipe to be replaced */}
<input
name="recipe"
value={recipe}
hidden
readOnly
/>
{/* Form data: updated recipe */}
<input
name="updatedRecipe"
value={updatedRecipe}
hidden
readOnly
/>
{children}
<div className="flex gap-3">
<Link
className="button"
href={PATH_ADMIN_RECIPES}
>
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid}
onFormSubmit={invalidateSwr}
>
Update
</SubmitButtonWithStatus>
</div>
</form>
);
}

View File

@ -0,0 +1,43 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import { deletePhotoRecipeGloballyAction } from '@/photo/actions';
import AdminTable from '@/admin/AdminTable';
import { Fragment } from 'react';
import DeleteFormButton from '@/admin/DeleteFormButton';
import { photoQuantityText } from '@/photo';
import EditButton from '@/admin/EditButton';
import { pathForAdminRecipeEdit } from '@/app/paths';
import { clsx } from 'clsx/lite';
import { formatRecipe, Recipes, sortRecipesWithCount } from '@/recipe';
import AdminRecipeBadge from './AdminRecipeBadge';
export default function AdminRecipeTable({
recipes,
}: {
recipes: Recipes
}) {
return (
<AdminTable>
{sortRecipesWithCount(recipes).map(({ recipe, count }) =>
<Fragment key={recipe}>
<div className="pr-2 col-span-2">
<AdminRecipeBadge {...{ recipe, count }} />
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton path={pathForAdminRecipeEdit(recipe)} />
<FormWithConfirm
action={deletePhotoRecipeGloballyAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatRecipe(recipe)}" from ${photoQuantityText(count, false).toLowerCase()}?`}
>
<input type="hidden" name="recipe" value={recipe} />
<DeleteFormButton clearLocalState />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminTable>
);
}

View File

@ -0,0 +1,35 @@
'use client';
import LoaderButton from '@/components/primitives/LoaderButton';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { useAppState } from '@/state/AppState';
import { TbChecklist } from 'react-icons/tb';
export default function AdminShowRecipeButton({
title,
recipe,
simulation,
}: {
title: string
recipe: FujifilmRecipe
simulation: FujifilmSimulation
}) {
const { setRecipeModalProps } = useAppState();
return (
<LoaderButton
icon={<TbChecklist
size={17}
className="translate-y-[1px]"
/>}
onClick={() => setRecipeModalProps?.({
title,
recipe,
simulation,
})}
>
Preview
</LoaderButton>
);
}

View File

@ -9,7 +9,7 @@ import { renamePhotoTagGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
export default function TagForm({ export default function AdminTagForm({
tag, tag,
children, children,
}: { }: {

View File

@ -8,7 +8,11 @@ import { testStorageConnection } from '@/platforms/storage';
import { APP_CONFIGURATION } from '@/app/config'; import { APP_CONFIGURATION } from '@/app/config';
import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
import { getInsightsIndicatorStatus } from '@/admin/insights/server'; import { getInsightsIndicatorStatus } from '@/admin/insights/server';
import { getPhotosMeta, getUniqueTags } from '@/photo/db/query'; import {
getPhotosMeta,
getUniqueTags,
getUniqueRecipes,
} from '@/photo/db/query';
export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>; export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>;
@ -17,8 +21,9 @@ export const getAdminDataAction = async () =>
const [ const [
photosCount, photosCount,
photosCountHidden, photosCountHidden,
tagsCount,
uploadsCount, uploadsCount,
tagsCount,
recipesCount,
insightsIndicatorStatus, insightsIndicatorStatus,
] = await Promise.all([ ] = await Promise.all([
getPhotosMeta() getPhotosMeta()
@ -27,15 +32,18 @@ export const getAdminDataAction = async () =>
getPhotosMeta({ hidden: 'only' }) getPhotosMeta({ hidden: 'only' })
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),
getUniqueTags()
.then(tags => tags.length)
.catch(() => 0),
getStorageUploadUrlsNoStore() getStorageUploadUrlsNoStore()
.then(urls => urls.length) .then(urls => urls.length)
.catch(e => { .catch(e => {
console.error(`Error getting blob upload urls: ${e}`); console.error(`Error getting blob upload urls: ${e}`);
return 0; return 0;
}), }),
getUniqueTags()
.then(tags => tags.length)
.catch(() => 0),
getUniqueRecipes()
.then(recipes => recipes.length)
.catch(() => 0),
getInsightsIndicatorStatus(), getInsightsIndicatorStatus(),
]); ]);
@ -50,8 +58,9 @@ export const getAdminDataAction = async () =>
photosCount, photosCount,
photosCountHidden, photosCountHidden,
photosCountTotal, photosCountTotal,
tagsCount,
uploadsCount, uploadsCount,
tagsCount,
recipesCount,
insightsIndicatorStatus, insightsIndicatorStatus,
}; };
}); });

View File

@ -41,6 +41,7 @@ export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`; export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`;
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`; export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`; export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`;
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`; export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`; export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`;
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`; export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
@ -63,6 +64,7 @@ export const PATHS_ADMIN = [
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ADMIN_RECIPES,
PATH_ADMIN_INSIGHTS, PATH_ADMIN_INSIGHTS,
PATH_ADMIN_CONFIGURATION, PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_BASELINE, PATH_ADMIN_BASELINE,
@ -99,6 +101,9 @@ export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) =>
export const pathForAdminTagEdit = (tag: string) => export const pathForAdminTagEdit = (tag: string) =>
`${PATH_ADMIN_TAGS}/${tag}/${EDIT}`; `${PATH_ADMIN_TAGS}/${tag}/${EDIT}`;
export const pathForAdminRecipeEdit = (recipe: string) =>
`${PATH_ADMIN_RECIPES}/${recipe}/${EDIT}`;
type PhotoOrPhotoId = Photo | string; type PhotoOrPhotoId = Photo | string;
const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) => const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>

View File

@ -23,8 +23,8 @@ export default function Badge({
return clsx( return clsx(
'px-1.5 h-[26px]', 'px-1.5 h-[26px]',
'rounded-md', 'rounded-md',
'bg-gray-100/80 dark:bg-gray-900/80', 'bg-gray-100/40 dark:bg-gray-900/60',
'border border-gray-200/60 dark:border-gray-800/75', 'border border-medium',
); );
case 'small': case 'small':
return clsx( return clsx(

View File

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

View File

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

View File

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

View File

@ -25,13 +25,15 @@ export default function RecipeImageResponse({
fontFamily: string fontFamily: string
smallText?: boolean smallText?: boolean
}) { }) {
const photo = getPhotoWithRecipeFromPhotos(photos); const {
recipeData,
filmSimulation,
} = getPhotoWithRecipeFromPhotos(photos) ?? {};
let recipeLines = photo?.recipeData && photo.filmSimulation let recipeLines = recipeData && filmSimulation
? generateRecipeText({ ? generateRecipeText({
recipe: photo.recipeData, recipe: recipeData,
simulation: photo.filmSimulation!, simulation: filmSimulation,
iso: photo.iso!.toString(),
}, true) }, true)
: []; : [];
@ -72,7 +74,7 @@ export default function RecipeImageResponse({
/>, />,
title: formatTag(recipe).toLocaleUpperCase(), title: formatTag(recipe).toLocaleUpperCase(),
}}> }}>
{photo?.recipeData && {recipeData &&
<div <div
// tw="opacity-70" // tw="opacity-70"
style={{ style={{
@ -108,10 +110,10 @@ export default function RecipeImageResponse({
flexGrow: 1, flexGrow: 1,
}}> }}>
{text} {text}
{isStringFilmSimulation(text) && {isStringFilmSimulation(text) && filmSimulation &&
<div tw="flex"> <div tw="flex">
<PhotoFilmSimulationIcon <PhotoFilmSimulationIcon
simulation={photo.filmSimulation} simulation={filmSimulation}
height={height * .06} height={height * .06}
style={{ transform: `translateY(${-height * .001}px)`}} style={{ transform: `translateY(${-height * .001}px)`}}
/> />

View File

@ -10,6 +10,8 @@ import {
getPhotos, getPhotos,
addTagsToPhotos, addTagsToPhotos,
getUniqueTags, getUniqueTags,
deletePhotoRecipeGlobally,
renamePhotoRecipeGlobally,
} from '@/photo/db/query'; } from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db'; import { GetPhotosOptions, areOptionsSensitive } from './db';
import { import {
@ -25,10 +27,12 @@ import {
revalidateAllKeysAndPaths, revalidateAllKeysAndPaths,
revalidatePhoto, revalidatePhoto,
revalidatePhotosKey, revalidatePhotosKey,
revalidateRecipesKey,
revalidateTagsKey, revalidateTagsKey,
} from '@/photo/cache'; } from '@/photo/cache';
import { import {
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ROOT, PATH_ROOT,
pathForPhoto, 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[]) => export const deleteUploadsAction = async (urls: string[]) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
await Promise.all(urls.map(url => deleteFile(url))); await Promise.all(urls.map(url => deleteFile(url)));

View File

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

View File

@ -265,6 +265,23 @@ export const addTagsToPhotos = (tags: string[], photoIds: string[]) =>
convertArrayToPostgresString(photoIds), convertArrayToPostgresString(photoIds),
]), 'addTagsToPhotos'); ]), '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) => export const deletePhoto = (id: string) =>
safelyQueryPhotos(() => sql` safelyQueryPhotos(() => sql`
DELETE FROM photos WHERE id=${id} DELETE FROM photos WHERE id=${id}

View File

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

View File

@ -324,6 +324,18 @@
@layer components { @layer components {
.component-surface { .component-surface {
@apply @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
} }
} }