diff --git a/app/admin/recipes/[recipe]/edit/page.tsx b/app/admin/recipes/[recipe]/edit/page.tsx new file mode 100644 index 00000000..4c85519a --- /dev/null +++ b/app/admin/recipes/[recipe]/edit/page.tsx @@ -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 ( + } + accessory={recipeData && filmSimulation && + + } + > + + + + + ); +}; diff --git a/app/admin/recipes/page.tsx b/app/admin/recipes/page.tsx new file mode 100644 index 00000000..85653f3a --- /dev/null +++ b/app/admin/recipes/page.tsx @@ -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 ( + +
+ +
+ } + /> + ); +} 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/app/admin/tags/page.tsx b/app/admin/tags/page.tsx index c70d03e7..915b8241 100644 --- a/app/admin/tags/page.tsx +++ b/app/admin/tags/page.tsx @@ -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 ( , + 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 ( +
+ +
+ {count} + +   + {photoLabelForCount(count)} + +
+
; + + return ( + hideBadge + ? renderBadgeContent() + : {renderBadgeContent()} + ); +} \ No newline at end of file diff --git a/src/admin/AdminRecipeForm.tsx b/src/admin/AdminRecipeForm.tsx new file mode 100644 index 00000000..99cadfbf --- /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 { 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 data: recipe to be replaced */} + + {/* Form data: updated recipe */} + + {children} +
+ + Cancel + + + Update + +
+ + ); +} diff --git a/src/admin/AdminRecipeTable.tsx b/src/admin/AdminRecipeTable.tsx new file mode 100644 index 00000000..7840a8fd --- /dev/null +++ b/src/admin/AdminRecipeTable.tsx @@ -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 ( + + {sortRecipesWithCount(recipes).map(({ recipe, count }) => + +
+ +
+
+ + + + + +
+
)} +
+ ); +} 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/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, }: { diff --git a/src/admin/actions.ts b/src/admin/actions.ts index c234788d..e551121c 100644 --- a/src/admin/actions.ts +++ b/src/admin/actions.ts @@ -8,7 +8,11 @@ import { testStorageConnection } from '@/platforms/storage'; import { APP_CONFIGURATION } from '@/app/config'; import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; 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>; @@ -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..9d99ede5 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`; @@ -63,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, @@ -99,6 +101,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) => 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/components/CopyButton.tsx b/src/components/CopyButton.tsx index 4c8a4fd8..e0743f16 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -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['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/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 &&
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 } }