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( export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY); process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY);
export const SHOW_CAMERAS =
CATEGORY_VISIBILITY.includes('cameras');
export const SHOW_LENSES = export const SHOW_LENSES =
CATEGORY_VISIBILITY.includes('lenses'); CATEGORY_VISIBILITY.includes('lenses');
export const SHOW_TAGS =
CATEGORY_VISIBILITY.includes('tags');
export const SHOW_RECIPES = export const SHOW_RECIPES =
CATEGORY_VISIBILITY.includes('recipes'); CATEGORY_VISIBILITY.includes('recipes');
export const SHOW_FILM_SIMULATIONS = export const SHOW_FILM_SIMULATIONS =

View File

@ -10,12 +10,8 @@ import { isCameraApple } from '@/platforms/apple';
export default function PhotoCamera({ export default function PhotoCamera({
camera, camera,
hideAppleIcon, hideAppleIcon,
type,
badged,
contrast,
prefetch,
countOnHover, countOnHover,
className, ...props
}: { }: {
camera: Camera camera: Camera
hideAppleIcon?: boolean hideAppleIcon?: boolean
@ -26,6 +22,7 @@ export default function PhotoCamera({
return ( return (
<EntityLink <EntityLink
{...props}
label={formatCameraText(camera)} label={formatCameraText(camera)}
href={pathForCamera(camera)} href={pathForCamera(camera)}
icon={showAppleIcon icon={showAppleIcon
@ -38,11 +35,6 @@ export default function PhotoCamera({
size={15} size={15}
className="translate-x-[-0.5px] translate-y-[-0.5px]" className="translate-x-[-0.5px] translate-y-[-0.5px]"
/>} />}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover} 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_FOCAL_LENGTHS,
SHOW_LENSES, SHOW_LENSES,
SHOW_RECIPES, SHOW_RECIPES,
SHOW_CAMERAS,
SHOW_TAGS,
} from '@/app/config'; } from '@/app/config';
import { sortTagsByCount } from '@/tag'; import { sortTagsByCount } from '@/tag';
import { sortCategoriesByCount } from '@/category'; import { sortCategoriesByCount } from '@/category';
import { sortFocalLengths } from '@/focal'; import { sortFocalLengths } from '@/focal';
export const getDataForCategories = () => [ export const getDataForCategories = () => [
getUniqueCameras() SHOW_CAMERAS
.then(sortCategoriesByCount) ? getUniqueCameras()
.catch(() => []), .then(sortCategoriesByCount)
.catch(() => [])
: [],
SHOW_LENSES SHOW_LENSES
? getUniqueLenses() ? getUniqueLenses()
.then(sortCategoriesByCount) .then(sortCategoriesByCount)
.catch(() => []) .catch(() => [])
: [], : [],
getUniqueTags() SHOW_TAGS
.then(sortTagsByCount) ? getUniqueTags()
.catch(() => []), .then(sortTagsByCount)
.catch(() => [])
: [],
SHOW_RECIPES SHOW_RECIPES
? getUniqueRecipes() ? getUniqueRecipes()
.then(sortCategoriesByCount) .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 type?: LabeledIconType
badged?: boolean badged?: boolean
contrast?: ComponentProps<typeof Badge>['contrast'] contrast?: ComponentProps<typeof Badge>['contrast']
uppercase?: boolean
prefetch?: boolean prefetch?: boolean
className?: string className?: string
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import { InsightsIndicatorStatus } from '@/admin/insights';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload'; import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
import { AdminData } from '@/admin/actions'; import { AdminData } from '@/admin/actions';
import { RecipeProps } from '@/recipe'; import { RecipeProps } from '@/recipe';
import { getCountsForCategoriesAction } from '@/category/actions';
export type AppStateContext = { export type AppStateContext = {
// CORE // CORE
previousPathname?: string previousPathname?: string
@ -24,6 +24,8 @@ export type AppStateContext = {
clearNextPhotoAnimation?: () => void clearNextPhotoAnimation?: () => void
shouldRespondToKeyboardCommands?: boolean shouldRespondToKeyboardCommands?: boolean
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>> setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
categoriesWithCounts?:
Awaited<ReturnType<typeof getCountsForCategoriesAction>>
// MODAL // MODAL
isCommandKOpen?: boolean isCommandKOpen?: boolean
setIsCommandKOpen?: Dispatch<SetStateAction<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 { isPathAdmin, PATH_SIGN_IN } from '@/app/paths';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload'; import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
import { RecipeProps } from '@/recipe'; import { RecipeProps } from '@/recipe';
import { getCountsForCategoriesAction } from '@/category/actions';
export default function AppStateProvider({ export default function AppStateProvider({
children, children,
@ -90,6 +91,11 @@ export default function AppStateProvider({
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const { data: categoriesWithCounts } = useSWR(
'getDataForCategories',
getCountsForCategoriesAction,
);
const { const {
data: auth, data: auth,
error: authError, error: authError,
@ -167,6 +173,7 @@ export default function AppStateProvider({
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
shouldRespondToKeyboardCommands, shouldRespondToKeyboardCommands,
setShouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands,
categoriesWithCounts,
// MODAL // MODAL
isCommandKOpen, isCommandKOpen,
setIsCommandKOpen, setIsCommandKOpen,

View File

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

View File

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

View File

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