Show category counts on hover
This commit is contained in:
parent
35b61a79af
commit
69ec607e37
@ -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 =
|
||||
|
||||
@ -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
42
src/category/actions.ts
Normal 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>),
|
||||
};
|
||||
};
|
||||
@ -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)
|
||||
|
||||
49
src/category/useCategoryCounts.ts
Normal file
49
src/category/useCategoryCounts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
47
src/category/useCategoryCountsForPhoto.ts
Normal file
47
src/category/useCategoryCountsForPhoto.ts
Normal 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;
|
||||
}
|
||||
@ -12,6 +12,7 @@ export interface EntityLinkExternalProps {
|
||||
type?: LabeledIconType
|
||||
badged?: boolean
|
||||
contrast?: ComponentProps<typeof Badge>['contrast']
|
||||
uppercase?: boolean
|
||||
prefetch?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -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>>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user