Show category counts on hover

This commit is contained in:
Sam Becker 2025-03-29 15:44:53 -05:00
parent 35b61a79af
commit 69ec607e37
17 changed files with 211 additions and 64 deletions

View File

@ -241,8 +241,12 @@ export const MATTE_COLOR_DARK =
export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY);
export const SHOW_CAMERAS =
CATEGORY_VISIBILITY.includes('cameras');
export const SHOW_LENSES =
CATEGORY_VISIBILITY.includes('lenses');
export const SHOW_TAGS =
CATEGORY_VISIBILITY.includes('tags');
export const SHOW_RECIPES =
CATEGORY_VISIBILITY.includes('recipes');
export const SHOW_FILM_SIMULATIONS =

View File

@ -10,12 +10,8 @@ import { isCameraApple } from '@/platforms/apple';
export default function PhotoCamera({
camera,
hideAppleIcon,
type,
badged,
contrast,
prefetch,
countOnHover,
className,
...props
}: {
camera: Camera
hideAppleIcon?: boolean
@ -26,6 +22,7 @@ export default function PhotoCamera({
return (
<EntityLink
{...props}
label={formatCameraText(camera)}
href={pathForCamera(camera)}
icon={showAppleIcon
@ -38,11 +35,6 @@ export default function PhotoCamera({
size={15}
className="translate-x-[-0.5px] translate-y-[-0.5px]"
/>}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
);

42
src/category/actions.ts Normal file
View File

@ -0,0 +1,42 @@
'use server';
import { createLensKey } from '@/lens';
import { getDataForCategories } from './data';
export const getCountsForCategoriesAction = async () => {
const [
cameras,
lenses,
tags,
recipes,
filmSimulations,
focalLengths,
] = await Promise.all(getDataForCategories());
return {
cameras: cameras.reduce((acc, camera) => {
acc[camera.cameraKey] = camera.count;
return acc;
}, {} as Record<string, number>),
lenses: lenses.reduce((acc, lens) => {
acc[createLensKey(lens.lens)] = lens.count;
return acc;
}, {} as Record<string, number>),
tags: tags.reduce((acc, tag) => {
acc[tag.tag] = tag.count;
return acc;
}, {} as Record<string, number>),
recipes: recipes.reduce((acc, recipe) => {
acc[recipe.recipe] = recipe.count;
return acc;
}, {} as Record<string, number>),
filmSimulations: filmSimulations.reduce((acc, filmSimulation) => {
acc[filmSimulation.simulation] = filmSimulation.count;
return acc;
}, {} as Record<string, number>),
focalLengths: focalLengths.reduce((acc, focalLength) => {
acc[focalLength.focal] = focalLength.count;
return acc;
}, {} as Record<string, number>),
};
};

View File

@ -11,23 +11,29 @@ import {
SHOW_FOCAL_LENGTHS,
SHOW_LENSES,
SHOW_RECIPES,
SHOW_CAMERAS,
SHOW_TAGS,
} from '@/app/config';
import { sortTagsByCount } from '@/tag';
import { sortCategoriesByCount } from '@/category';
import { sortFocalLengths } from '@/focal';
export const getDataForCategories = () => [
getUniqueCameras()
SHOW_CAMERAS
? getUniqueCameras()
.then(sortCategoriesByCount)
.catch(() => []),
.catch(() => [])
: [],
SHOW_LENSES
? getUniqueLenses()
.then(sortCategoriesByCount)
.catch(() => [])
: [],
getUniqueTags()
SHOW_TAGS
? getUniqueTags()
.then(sortTagsByCount)
.catch(() => []),
.catch(() => [])
: [],
SHOW_RECIPES
? getUniqueRecipes()
.then(sortCategoriesByCount)

View File

@ -0,0 +1,49 @@
import { createCameraKey } from '@/camera';
import { createLensKey } from '@/lens';
import { Camera } from '@/camera';
import { Lens } from '@/lens';
import { useAppState } from '@/state/AppState';
import { useCallback } from 'react';
export default function useCategoryCounts() {
const { categoriesWithCounts } = useAppState();
const getCameraCount = useCallback((camera: Camera) => {
const cameraCounts = categoriesWithCounts?.cameras ?? {};
return cameraCounts[createCameraKey(camera)];
}, [categoriesWithCounts]);
const getLensCount = useCallback((lens: Lens) => {
const lensCounts = categoriesWithCounts?.lenses ?? {};
return lensCounts[createLensKey(lens)];
}, [categoriesWithCounts]);
const getTagCount = useCallback((tag: string) => {
const tagCounts = categoriesWithCounts?.tags ?? {};
return tagCounts[tag];
}, [categoriesWithCounts]);
const getRecipeCount = useCallback((recipe: string) => {
const recipeCounts = categoriesWithCounts?.recipes ?? {};
return recipeCounts[recipe];
}, [categoriesWithCounts]);
const getFilmSimulationCount = useCallback((simulation: string) => {
const filmSimulationCounts = categoriesWithCounts?.filmSimulations ?? {};
return filmSimulationCounts[simulation];
}, [categoriesWithCounts]);
const getFocalLengthCount = useCallback((focalLength: number) => {
const focalLengthCounts = categoriesWithCounts?.focalLengths ?? {};
return focalLengthCounts[focalLength];
}, [categoriesWithCounts]);
return {
getCameraCount,
getLensCount,
getTagCount,
getRecipeCount,
getFilmSimulationCount,
getFocalLengthCount,
};
}

View File

@ -0,0 +1,47 @@
import { Photo } from '@/photo';
import useCategoryCounts from './useCategoryCounts';
import { cameraFromPhoto } from '@/camera';
import { lensFromPhoto } from '@/lens';
import { useMemo } from 'react';
export default function useCategoryCountsForPhoto(photo: Photo) {
const {
getCameraCount,
getLensCount,
getTagCount,
getRecipeCount,
getFilmSimulationCount,
getFocalLengthCount,
} = useCategoryCounts();
const camera = cameraFromPhoto(photo);
const lens = lensFromPhoto(photo);
const categoryCounts = useMemo(() => ({
cameraCount: getCameraCount(camera),
lensCount: getLensCount(lens),
tagCounts: photo.tags.reduce((acc, tag) => {
acc[tag] = getTagCount(tag);
return acc;
}, {} as Record<string, number>),
recipeCount: photo.recipeTitle ? getRecipeCount(photo.recipeTitle) : 0,
simulationCount:
photo.filmSimulation ? getFilmSimulationCount(photo.filmSimulation) : 0,
focalCount: photo.focalLength ? getFocalLengthCount(photo.focalLength) : 0,
}), [
getCameraCount,
getLensCount,
getRecipeCount,
getFilmSimulationCount,
getFocalLengthCount,
getTagCount,
camera,
lens,
photo.tags,
photo.recipeTitle,
photo.filmSimulation,
photo.focalLength,
]);
return categoryCounts;
}

View File

@ -12,6 +12,7 @@ export interface EntityLinkExternalProps {
type?: LabeledIconType
badged?: boolean
contrast?: ComponentProps<typeof Badge>['contrast']
uppercase?: boolean
prefetch?: boolean
className?: string
}

View File

@ -7,26 +7,18 @@ import IconFocalLength from '@/components/icons/IconFocalLength';
export default function PhotoFocalLength({
focal,
type,
badged,
contrast,
prefetch,
countOnHover,
className,
...props
}: {
focal: number
countOnHover?: number
} & EntityLinkExternalProps) {
return (
<EntityLink
{...props}
label={formatFocalLength(focal)}
href={pathForFocalLength(focal)}
icon={<IconFocalLength />}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
);

View File

@ -7,13 +7,9 @@ import IconLens from '@/components/icons/IconLens';
export default function PhotoLens({
lens,
type,
badged,
contrast,
prefetch,
countOnHover,
className,
shortText,
...props
}: {
lens: Lens
countOnHover?: number
@ -21,17 +17,13 @@ export default function PhotoLens({
} & EntityLinkExternalProps) {
return (
<EntityLink
{...props}
label={formatLensText(lens, shortText ? 'short' : 'medium')}
href={pathForLens(lens)}
icon={<IconLens
size={14}
className="translate-x-[-0.5px]"
/>}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
);

View File

@ -51,6 +51,7 @@ import PhotoRecipe from '@/recipe/PhotoRecipe';
import PhotoLens from '@/lens/PhotoLens';
import { lensFromPhoto } from '@/lens';
import MaskedScroll from '@/components/MaskedScroll';
import useCategoryCountsForPhoto from '@/category/useCategoryCountsForPhoto';
export default function PhotoLarge({
photo,
@ -114,6 +115,14 @@ export default function PhotoLarge({
isUserSignedIn,
} = useAppState();
const {
cameraCount,
lensCount,
tagCounts,
recipeCount,
simulationCount,
} = useCategoryCountsForPhoto(photo);
const showZoomControls = showZoomControlsProp && areZoomControlsShown;
const refRecipe = useRef<HTMLDivElement>(null);
@ -306,6 +315,7 @@ export default function PhotoLarge({
camera={camera}
contrast="medium"
prefetch={prefetchRelatedLinks}
countOnHover={cameraCount}
/>}
{showLensContent &&
<PhotoLens
@ -313,6 +323,7 @@ export default function PhotoLarge({
contrast="medium"
prefetch={prefetchRelatedLinks}
shortText
countOnHover={lensCount}
/>}
</div>}
{showRecipeContent && recipeTitle &&
@ -320,10 +331,12 @@ export default function PhotoLarge({
recipe={recipeTitle}
contrast="medium"
prefetch={prefetchRelatedLinks}
countOnHover={recipeCount}
/>}
{showTagsContent &&
<PhotoTags
tags={tags}
tagCounts={tagCounts}
contrast="medium"
prefetch={prefetchRelatedLinks}
/>}
@ -384,6 +397,7 @@ export default function PhotoLarge({
<PhotoFilmSimulation
simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks}
countOnHover={simulationCount}
/>}
{showRecipeButton &&
<Tooltip content="Fujifilm Recipe">

View File

@ -9,15 +9,11 @@ import IconRecipe from '@/components/icons/IconRecipe';
export default function PhotoRecipe({
recipe,
type,
badged,
contrast,
prefetch,
countOnHover,
className,
refButton,
isOpen,
recipeOnClick,
...props
}: {
recipe: string
refButton?: RefObject<HTMLButtonElement | null>
@ -28,22 +24,18 @@ export default function PhotoRecipe({
return (
<div className="flex w-full gap-2">
<EntityLink
{...props}
title="Recipe"
label={formatRecipe(recipe)}
href={pathForRecipe(recipe)}
icon={<IconRecipe
size={16}
className={clsx(
badged
props.badged
? 'translate-x-[-1px] translate-y-[0.5px]'
: 'translate-y-[-0.5px]',
)}
/>}
className={className}
type={type}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
{recipeOnClick &&

View File

@ -13,9 +13,8 @@ export default function PhotoFilmSimulation({
type = 'icon-last',
badged = true,
contrast = 'low',
prefetch,
countOnHover,
className,
...props
}: {
simulation: FilmSimulation
countOnHover?: number
@ -25,6 +24,7 @@ export default function PhotoFilmSimulation({
return (
<EntityLink
{...props}
label={medium}
labelSmall={small}
href={pathForFilmSimulation(simulation)}
@ -39,10 +39,8 @@ export default function PhotoFilmSimulation({
/>}
title={`Film Simulation: ${large}`}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
iconWide
/>

View File

@ -11,7 +11,7 @@ import { InsightsIndicatorStatus } from '@/admin/insights';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
import { AdminData } from '@/admin/actions';
import { RecipeProps } from '@/recipe';
import { getCountsForCategoriesAction } from '@/category/actions';
export type AppStateContext = {
// CORE
previousPathname?: string
@ -24,6 +24,8 @@ export type AppStateContext = {
clearNextPhotoAnimation?: () => void
shouldRespondToKeyboardCommands?: boolean
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
categoriesWithCounts?:
Awaited<ReturnType<typeof getCountsForCategoriesAction>>
// MODAL
isCommandKOpen?: boolean
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>

View File

@ -24,6 +24,7 @@ import { useRouter, usePathname } from 'next/navigation';
import { isPathAdmin, PATH_SIGN_IN } from '@/app/paths';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
import { RecipeProps } from '@/recipe';
import { getCountsForCategoriesAction } from '@/category/actions';
export default function AppStateProvider({
children,
@ -90,6 +91,11 @@ export default function AppStateProvider({
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const { data: categoriesWithCounts } = useSWR(
'getDataForCategories',
getCountsForCategoriesAction,
);
const {
data: auth,
error: authError,
@ -167,6 +173,7 @@ export default function AppStateProvider({
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
shouldRespondToKeyboardCommands,
setShouldRespondToKeyboardCommands,
categoriesWithCounts,
// MODAL
isCommandKOpen,
setIsCommandKOpen,

View File

@ -7,26 +7,18 @@ import IconTag from '@/components/icons/IconTag';
export default function PhotoTag({
tag,
type,
badged,
contrast,
prefetch,
countOnHover,
className,
...props
}: {
tag: string
countOnHover?: number
} & EntityLinkExternalProps) {
return (
<EntityLink
{...props}
label={formatTag(tag)}
href={pathForTag(tag)}
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
);

View File

@ -6,18 +6,27 @@ import { Fragment } from 'react';
export default function PhotoTags({
tags,
tagCounts = {},
contrast,
prefetch,
}: {
tags: string[]
tagCounts?: Record<string, number>
} & EntityLinkExternalProps) {
return (
<div className="flex flex-col">
{tags.map(tag =>
<Fragment key={tag}>
{isTagFavs(tag)
? <FavsTag {...{ contrast, prefetch }} />
: <PhotoTag {...{ tag, contrast, prefetch }} />}
? <FavsTag {...{
contrast,
prefetch,
countOnHover: tagCounts[tag],
}} />
: <PhotoTag {...{
tag,
contrast,
prefetch, countOnHover: tagCounts[tag] }} />}
</Fragment>)}
</div>
);

View File

@ -7,6 +7,14 @@
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant hover {
@media (pointer: fine) {
&:hover {
@slot;
}
}
}
@theme {
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;