From 3d57de39970a3ccbb7da409232dd6fd2eb695c17 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 12 Mar 2025 17:43:17 -0500 Subject: [PATCH 1/5] Add recipes to core admin surfaces --- app/admin/recipes/[recipe]/edit/page.tsx | 48 ++++++++++++++++++++++++ app/admin/recipes/page.tsx | 18 +++++++++ src/admin/AdminAppMenu.tsx | 16 +++++++- src/admin/AdminNav.tsx | 18 +++++++-- src/admin/actions.ts | 21 ++++++++--- src/app/paths.ts | 1 + 6 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 app/admin/recipes/[recipe]/edit/page.tsx create mode 100644 app/admin/recipes/page.tsx diff --git a/app/admin/recipes/[recipe]/edit/page.tsx b/app/admin/recipes/[recipe]/edit/page.tsx new file mode 100644 index 00000000..5cdfc425 --- /dev/null +++ b/app/admin/recipes/[recipe]/edit/page.tsx @@ -0,0 +1,48 @@ +import AdminChildPage from '@/components/AdminChildPage'; +import { redirect } from 'next/navigation'; +import { getPhotosCached } from '@/photo/cache'; +import TagForm from '@/tag/TagForm'; +import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/app/paths'; +import PhotoLightbox from '@/photo/PhotoLightbox'; +import { getPhotosMeta } from '@/photo/db/query'; +import AdminTagBadge from '@/admin/AdminTagBadge'; + +const MAX_PHOTO_TO_SHOW = 6; + +interface Props { + params: Promise<{ tag: string }> +} + +export default async function PhotoPageEdit({ + params, +}: Props) { + const { tag: tagFromParams } = await params; + + const tag = decodeURIComponent(tagFromParams); + + const [ + { count }, + photos, + ] = await Promise.all([ + getPhotosMeta({ tag }), + getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW }), + ]); + + if (count === 0) { redirect(PATH_ADMIN); } + + return ( + } + > + + + + + ); +}; diff --git a/app/admin/recipes/page.tsx b/app/admin/recipes/page.tsx new file mode 100644 index 00000000..c70d03e7 --- /dev/null +++ b/app/admin/recipes/page.tsx @@ -0,0 +1,18 @@ +import AdminTagTable from '@/admin/AdminTagTable'; +import SiteGrid from '@/components/SiteGrid'; +import { getUniqueTagsHiddenCached } from '@/photo/cache'; + +export default async function AdminTagsPage() { + const tags = await getUniqueTagsHiddenCached().catch(() => []); + + return ( + +
+ +
+ } + /> + ); +} diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 6f64b0cd..6d4880e8 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -5,6 +5,7 @@ import { PATH_ADMIN_CONFIGURATION, PATH_ADMIN_INSIGHTS, PATH_ADMIN_PHOTOS, + PATH_ADMIN_RECIPES, PATH_ADMIN_TAGS, PATH_ADMIN_UPLOADS, PATH_GRID_INFERRED, @@ -13,7 +14,7 @@ import { useAppState } from '@/state/AppState'; import { ImCheckboxUnchecked } from 'react-icons/im'; import { IoArrowDown, IoArrowUp, IoCloseSharp } from 'react-icons/io5'; 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 { BiLockAlt } from 'react-icons/bi'; import AdminAppInfoIcon from './AdminAppInfoIcon'; @@ -37,6 +38,7 @@ export default function AdminAppMenu({ photosCountTotal = 0, uploadsCount = 0, tagsCount = 0, + recipesCount = 0, selectedPhotoIds, startUpload, setSelectedPhotoIds, @@ -103,6 +105,18 @@ export default function AdminAppMenu({ }); } + if (recipesCount) { + items.push({ + label: 'Manage Recipes', + annotation: `${recipesCount}`, + icon: , + href: PATH_ADMIN_RECIPES, + }); + } + if (photosCountTotal) { items.push({ label: isSelecting diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index eecda0aa..671b1fd6 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -2,10 +2,12 @@ import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; import { getPhotosMetaCached, getPhotosMostRecentUpdateCached, + getUniqueRecipesCached, getUniqueTagsCached, } from '@/photo/cache'; import { PATH_ADMIN_PHOTOS, + PATH_ADMIN_RECIPES, PATH_ADMIN_TAGS, PATH_ADMIN_UPLOADS, } from '@/app/paths'; @@ -14,21 +16,24 @@ import AdminNavClient from './AdminNavClient'; export default async function AdminNav() { const [ countPhotos, - countTags, countUploads, + countTags, + countRecipes, mostRecentPhotoUpdateTime, ] = await Promise.all([ getPhotosMetaCached({ hidden: 'include' }) .then(({ count }) => count) .catch(() => 0), - getUniqueTagsCached().then(tags => tags.length) - .catch(() => 0), getStorageUploadUrlsNoStore() .then(urls => urls.length) .catch(e => { console.error(`Error getting blob upload urls: ${e}`); return 0; }), + getUniqueTagsCached().then(tags => tags.length) + .catch(() => 0), + getUniqueRecipesCached().then(recipes => recipes.length) + .catch(() => 0), getPhotosMostRecentUpdateCached().catch(() => undefined), ]); @@ -55,6 +60,13 @@ export default async function AdminNav() { count: countTags, }); } + // Recipes + if (countRecipes > 0) { items.push({ + label: 'Recipes', + href: PATH_ADMIN_RECIPES, + count: countRecipes, + }); } + return ( >; @@ -17,8 +21,9 @@ export const getAdminDataAction = async () => const [ photosCount, photosCountHidden, - tagsCount, uploadsCount, + tagsCount, + recipesCount, insightsIndicatorStatus, ] = await Promise.all([ getPhotosMeta() @@ -27,15 +32,18 @@ export const getAdminDataAction = async () => getPhotosMeta({ hidden: 'only' }) .then(({ count }) => count) .catch(() => 0), - getUniqueTags() - .then(tags => tags.length) - .catch(() => 0), getStorageUploadUrlsNoStore() .then(urls => urls.length) .catch(e => { console.error(`Error getting blob upload urls: ${e}`); return 0; }), + getUniqueTags() + .then(tags => tags.length) + .catch(() => 0), + getUniqueRecipes() + .then(recipes => recipes.length) + .catch(() => 0), getInsightsIndicatorStatus(), ]); @@ -50,8 +58,9 @@ export const getAdminDataAction = async () => photosCount, photosCountHidden, photosCountTotal, - tagsCount, uploadsCount, + tagsCount, + recipesCount, insightsIndicatorStatus, }; }); diff --git a/src/app/paths.ts b/src/app/paths.ts index 8490f53b..77939527 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -41,6 +41,7 @@ export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`; export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`; 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_INSIGHTS = `${PATH_ADMIN}/insights`; export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`; From 2c35587f5d419596e7f89a4c19c66e4564ce0b82 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 12 Mar 2025 18:04:27 -0500 Subject: [PATCH 2/5] Create /admin/recipes page --- app/admin/recipes/page.tsx | 8 +++---- src/admin/AdminRecipeBadge.tsx | 35 +++++++++++++++++++++++++++ src/admin/AdminRecipeTable.tsx | 43 ++++++++++++++++++++++++++++++++++ src/app/paths.ts | 3 +++ 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/admin/AdminRecipeBadge.tsx create mode 100644 src/admin/AdminRecipeTable.tsx diff --git a/app/admin/recipes/page.tsx b/app/admin/recipes/page.tsx index c70d03e7..7ab25aea 100644 --- a/app/admin/recipes/page.tsx +++ b/app/admin/recipes/page.tsx @@ -1,16 +1,16 @@ -import AdminTagTable from '@/admin/AdminTagTable'; +import AdminRecipeTable from '@/admin/AdminRecipeTable'; import SiteGrid from '@/components/SiteGrid'; -import { getUniqueTagsHiddenCached } from '@/photo/cache'; +import { getUniqueRecipesCached } from '@/photo/cache'; export default async function AdminTagsPage() { - const tags = await getUniqueTagsHiddenCached().catch(() => []); + const recipes = await getUniqueRecipesCached().catch(() => []); return (
- +
} /> diff --git a/src/admin/AdminRecipeBadge.tsx b/src/admin/AdminRecipeBadge.tsx new file mode 100644 index 00000000..0f82213a --- /dev/null +++ b/src/admin/AdminRecipeBadge.tsx @@ -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 = () => +
+ +
+ {count} + +   + {photoLabelForCount(count)} + +
+
; + + return ( + hideBadge + ? renderBadgeContent() + : {renderBadgeContent()} + ); +} \ No newline at end of file diff --git a/src/admin/AdminRecipeTable.tsx b/src/admin/AdminRecipeTable.tsx new file mode 100644 index 00000000..ff4dece4 --- /dev/null +++ b/src/admin/AdminRecipeTable.tsx @@ -0,0 +1,43 @@ +import FormWithConfirm from '@/components/FormWithConfirm'; +import { deletePhotoTagGloballyAction } 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 ( + + {sortRecipesWithCount(recipes).map(({ recipe, count }) => + +
+ +
+
+ + + + + +
+
)} +
+ ); +} diff --git a/src/app/paths.ts b/src/app/paths.ts index 77939527..3a6a2627 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -100,6 +100,9 @@ export const pathForAdminPhotoEdit = (photo: PhotoOrPhotoId) => export const pathForAdminTagEdit = (tag: string) => `${PATH_ADMIN_TAGS}/${tag}/${EDIT}`; +export const pathForAdminRecipeEdit = (recipe: string) => + `${PATH_ADMIN_RECIPES}/${recipe}/${EDIT}`; + type PhotoOrPhotoId = Photo | string; const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) => From 769d6b64bbf73c67bd8777706449174032ebc9f4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 12 Mar 2025 18:11:16 -0500 Subject: [PATCH 3/5] Create admin recipe detail page --- app/admin/recipes/[recipe]/edit/page.tsx | 28 +++---- app/admin/tags/[tag]/edit/page.tsx | 6 +- src/admin/AdminRecipeForm.tsx | 74 +++++++++++++++++++ .../TagForm.tsx => admin/AdminTagForm.tsx} | 2 +- 4 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 src/admin/AdminRecipeForm.tsx rename src/{tag/TagForm.tsx => admin/AdminTagForm.tsx} (97%) diff --git a/app/admin/recipes/[recipe]/edit/page.tsx b/app/admin/recipes/[recipe]/edit/page.tsx index 5cdfc425..dc289b03 100644 --- a/app/admin/recipes/[recipe]/edit/page.tsx +++ b/app/admin/recipes/[recipe]/edit/page.tsx @@ -1,31 +1,31 @@ import AdminChildPage from '@/components/AdminChildPage'; import { redirect } from 'next/navigation'; import { getPhotosCached } from '@/photo/cache'; -import TagForm from '@/tag/TagForm'; -import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/app/paths'; +import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForRecipe } from '@/app/paths'; import PhotoLightbox from '@/photo/PhotoLightbox'; import { getPhotosMeta } from '@/photo/db/query'; -import AdminTagBadge from '@/admin/AdminTagBadge'; +import AdminRecipeBadge from '@/admin/AdminRecipeBadge'; +import AdminRecipeForm from '@/admin/AdminRecipeForm'; const MAX_PHOTO_TO_SHOW = 6; interface Props { - params: Promise<{ tag: string }> + params: Promise<{ recipe: string }> } -export default async function PhotoPageEdit({ +export default async function RecipePageEdit({ params, }: Props) { - const { tag: tagFromParams } = await params; + const { recipe: recipeFromParams } = await params; - const tag = decodeURIComponent(tagFromParams); + const recipe = decodeURIComponent(recipeFromParams); const [ { count }, photos, ] = await Promise.all([ - getPhotosMeta({ tag }), - getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW }), + getPhotosMeta({ recipe }), + getPhotosCached({ recipe, limit: MAX_PHOTO_TO_SHOW }), ]); if (count === 0) { redirect(PATH_ADMIN); } @@ -34,15 +34,15 @@ export default async function PhotoPageEdit({ } + breadcrumb={} > - + - + ); }; diff --git a/app/admin/tags/[tag]/edit/page.tsx b/app/admin/tags/[tag]/edit/page.tsx index 5cdfc425..747dc180 100644 --- a/app/admin/tags/[tag]/edit/page.tsx +++ b/app/admin/tags/[tag]/edit/page.tsx @@ -1,7 +1,7 @@ import AdminChildPage from '@/components/AdminChildPage'; import { redirect } from 'next/navigation'; 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 PhotoLightbox from '@/photo/PhotoLightbox'; import { getPhotosMeta } from '@/photo/db/query'; @@ -36,13 +36,13 @@ export default async function PhotoPageEdit({ backLabel="Tags" breadcrumb={} > - + - + ); }; diff --git a/src/admin/AdminRecipeForm.tsx b/src/admin/AdminRecipeForm.tsx new file mode 100644 index 00000000..f423e44d --- /dev/null +++ b/src/admin/AdminRecipeForm.tsx @@ -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 { renamePhotoTagGloballyAction } 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 data: tag to be replaced */} + + {/* Form data: updated tag */} + + {children} +
+ + Cancel + + + Update + +
+ + ); +} diff --git a/src/tag/TagForm.tsx b/src/admin/AdminTagForm.tsx similarity index 97% rename from src/tag/TagForm.tsx rename to src/admin/AdminTagForm.tsx index 9be11e8d..9023a30b 100644 --- a/src/tag/TagForm.tsx +++ b/src/admin/AdminTagForm.tsx @@ -9,7 +9,7 @@ import { renamePhotoTagGloballyAction } from '@/photo/actions'; import { parameterize } from '@/utility/string'; import { useAppState } from '@/state/AppState'; -export default function TagForm({ +export default function AdminTagForm({ tag, children, }: { From 3684c57dee146a223fd062e7c30333f52ae03211 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 12 Mar 2025 20:15:14 -0500 Subject: [PATCH 4/5] Refine admin recipe detail page --- app/admin/recipes/[recipe]/edit/page.tsx | 14 +++++++++ src/admin/AdminRecipeBadge.tsx | 2 +- src/admin/AdminShowRecipeButton.tsx | 35 ++++++++++++++++++++++ src/components/Badge.tsx | 4 +-- src/image-response/RecipeImageResponse.tsx | 18 ++++++----- 5 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 src/admin/AdminShowRecipeButton.tsx diff --git a/app/admin/recipes/[recipe]/edit/page.tsx b/app/admin/recipes/[recipe]/edit/page.tsx index dc289b03..6f62d74e 100644 --- a/app/admin/recipes/[recipe]/edit/page.tsx +++ b/app/admin/recipes/[recipe]/edit/page.tsx @@ -6,6 +6,8 @@ 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; @@ -28,6 +30,11 @@ export default async function RecipePageEdit({ getPhotosCached({ recipe, limit: MAX_PHOTO_TO_SHOW }), ]); + const { + recipeData, + filmSimulation, + } = getPhotoWithRecipeFromPhotos(photos) ?? {}; + if (count === 0) { redirect(PATH_ADMIN); } return ( @@ -35,6 +42,13 @@ export default async function RecipePageEdit({ backPath={PATH_ADMIN_TAGS} backLabel="Tags" breadcrumb={} + accessory={recipeData && filmSimulation && + + } >
diff --git a/src/admin/AdminShowRecipeButton.tsx b/src/admin/AdminShowRecipeButton.tsx new file mode 100644 index 00000000..52c9f66d --- /dev/null +++ b/src/admin/AdminShowRecipeButton.tsx @@ -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 ( + } + onClick={() => setRecipeModalProps?.({ + title, + recipe, + simulation, + })} + > + Preview + + ); +} \ No newline at end of file diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index f588e850..49a72ea5 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -23,8 +23,8 @@ export default function Badge({ return clsx( 'px-1.5 h-[26px]', 'rounded-md', - 'bg-gray-100/80 dark:bg-gray-900/80', - 'border border-gray-200/60 dark:border-gray-800/75', + 'bg-gray-100/40 dark:bg-gray-900/60', + 'border border-medium', ); case 'small': return clsx( diff --git a/src/image-response/RecipeImageResponse.tsx b/src/image-response/RecipeImageResponse.tsx index 681cac55..e2335cc1 100644 --- a/src/image-response/RecipeImageResponse.tsx +++ b/src/image-response/RecipeImageResponse.tsx @@ -25,13 +25,15 @@ export default function RecipeImageResponse({ fontFamily: string smallText?: boolean }) { - const photo = getPhotoWithRecipeFromPhotos(photos); + const { + recipeData, + filmSimulation, + } = getPhotoWithRecipeFromPhotos(photos) ?? {}; - let recipeLines = photo?.recipeData && photo.filmSimulation + let recipeLines = recipeData && filmSimulation ? generateRecipeText({ - recipe: photo.recipeData, - simulation: photo.filmSimulation!, - iso: photo.iso!.toString(), + recipe: recipeData, + simulation: filmSimulation, }, true) : []; @@ -72,7 +74,7 @@ export default function RecipeImageResponse({ />, title: formatTag(recipe).toLocaleUpperCase(), }}> - {photo?.recipeData && + {recipeData &&
{text} - {isStringFilmSimulation(text) && + {isStringFilmSimulation(text) && filmSimulation &&
From ba18289e0ea547f2e1c794064a7e1c11a3df346c Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 12 Mar 2025 21:08:56 -0500 Subject: [PATCH 5/5] Finalize admin recipe management --- app/admin/recipes/[recipe]/edit/page.tsx | 6 ++--- app/admin/recipes/page.tsx | 6 ++--- app/admin/tags/page.tsx | 4 +-- src/admin/AdminRecipeForm.tsx | 12 ++++----- src/admin/AdminRecipeTable.tsx | 4 +-- src/app/paths.ts | 1 + src/components/CopyButton.tsx | 5 +++- src/components/primitives/MenuSurface.tsx | 8 ++++-- .../primitives/TooltipPrimitive.tsx | 12 ++++++--- src/photo/actions.ts | 27 +++++++++++++++++++ src/photo/cache.ts | 14 ++++++++++ src/photo/db/query.ts | 17 ++++++++++++ src/recipe/PhotoRecipeOverlay.tsx | 3 ++- tailwind.css | 14 +++++++++- 14 files changed, 108 insertions(+), 25 deletions(-) diff --git a/app/admin/recipes/[recipe]/edit/page.tsx b/app/admin/recipes/[recipe]/edit/page.tsx index 6f62d74e..4c85519a 100644 --- a/app/admin/recipes/[recipe]/edit/page.tsx +++ b/app/admin/recipes/[recipe]/edit/page.tsx @@ -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 ( } accessory={recipeData && filmSimulation && []); +export default async function AdminRecipesPage() { + const recipes = await getUniqueRecipes().catch(() => []); return ( []); + const tags = await getUniqueTagsHidden().catch(() => []); return ( - {/* Form data: tag to be replaced */} + {/* Form data: recipe to be replaced */} - {/* Form data: updated tag */} + {/* Form data: updated recipe */} ['color'] className?: string }) { const button = @@ -38,7 +41,7 @@ export default function CopyButton({ return ( tooltip - ? + ? {button} : button diff --git a/src/components/primitives/MenuSurface.tsx b/src/components/primitives/MenuSurface.tsx index 9fdb7c88..bf7ad58d 100644 --- a/src/components/primitives/MenuSurface.tsx +++ b/src/components/primitives/MenuSurface.tsx @@ -5,18 +5,22 @@ export default function MenuSurface({ ref, children, className, + color, }: { ref?: RefObject children: ReactNode className?: string + color?: 'light' | 'dark' | 'frosted' }) { return (
['color'] children: ReactNode }) { const refTrigger = useRef(null); @@ -59,7 +61,7 @@ export default function TooltipPrimitive({ {children} } - + {content && - + {content} } diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 3cdf0e2d..8351b47b 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -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))); diff --git a/src/photo/cache.ts b/src/photo/cache.ts index c9e4c03f..eaf175e2 100644 --- a/src/photo/cache.ts +++ b/src/photo/cache.ts @@ -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'); }; diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 4ee241ed..3e742f4d 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -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} diff --git a/src/recipe/PhotoRecipeOverlay.tsx b/src/recipe/PhotoRecipeOverlay.tsx index 54efd096..b94ddc5c 100644 --- a/src/recipe/PhotoRecipeOverlay.tsx +++ b/src/recipe/PhotoRecipeOverlay.tsx @@ -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" /> } diff --git a/tailwind.css b/tailwind.css index 564b8471..f2f8f7d5 100644 --- a/tailwind.css +++ b/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 } }