Merge pull request #206 from sambecker/admin-recipes
Admin recipe tools
This commit is contained in:
commit
073dee9efd
62
app/admin/recipes/[recipe]/edit/page.tsx
Normal file
62
app/admin/recipes/[recipe]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
app/admin/recipes/page.tsx
Normal file
18
app/admin/recipes/page.tsx
Normal 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>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
35
src/admin/AdminRecipeBadge.tsx
Normal file
35
src/admin/AdminRecipeBadge.tsx
Normal 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">
|
||||||
|
|
||||||
|
{photoLabelForCount(count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
hideBadge
|
||||||
|
? renderBadgeContent()
|
||||||
|
: <Badge className="py-[3px]!">{renderBadgeContent()}</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/admin/AdminRecipeForm.tsx
Normal file
74
src/admin/AdminRecipeForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/admin/AdminRecipeTable.tsx
Normal file
43
src/admin/AdminRecipeTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/admin/AdminShowRecipeButton.tsx
Normal file
35
src/admin/AdminShowRecipeButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}: {
|
}: {
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)`}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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)));
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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} />}
|
||||||
|
|||||||
14
tailwind.css
14
tailwind.css
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user