diff --git a/README.md b/README.md index 27f0854e..ce9aa53d 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Application behavior can be changed by configuring the following environment var - `recipes` (default) - `films` (default) - `focal-lengths` +- `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` shows all sidebar category content - `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography) - `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls - `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta diff --git a/app/grid/page.tsx b/app/grid/page.tsx index 34a8d025..f6d8aad3 100644 --- a/app/grid/page.tsx +++ b/app/grid/page.tsx @@ -4,7 +4,7 @@ import { } from '@/photo'; import PhotosEmptyState from '@/photo/PhotosEmptyState'; import { Metadata } from 'next/types'; -import { getPhotoSidebarData } from '@/photo/data'; +import { getDataForCategories } from '@/category/data'; import { getPhotos, getPhotosMeta } from '@/photo/db/query'; import { cache } from 'react'; import PhotoGridPage from '@/photo/PhotoGridPage'; @@ -28,8 +28,8 @@ export default async function GridPage() { cameras, lenses, tags, - simulations, recipes, + simulations, focalLengths, ] = await Promise.all([ getPhotosCached() @@ -37,7 +37,7 @@ export default async function GridPage() { getPhotosMeta() .then(({ count }) => count) .catch(() => 0), - ...getPhotoSidebarData(), + ...getDataForCategories(), ]); return ( diff --git a/app/page.tsx b/app/page.tsx index 0a91f53a..39e9797d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,7 +8,7 @@ import { Metadata } from 'next/types'; import { cache } from 'react'; import { getPhotos, getPhotosMeta } from '@/photo/db/query'; import { GRID_HOMEPAGE_ENABLED } from '@/app/config'; -import { getPhotoSidebarData } from '@/photo/data'; +import { getDataForCategories } from '@/category/data'; import PhotoGridPage from '@/photo/PhotoGridPage'; import PhotoFeedPage from '@/photo/PhotoFeedPage'; @@ -34,8 +34,8 @@ export default async function HomePage() { cameras, lenses, tags, - simulations, recipes, + simulations, focalLengths, ] = await Promise.all([ getPhotosCached() @@ -44,7 +44,7 @@ export default async function HomePage() { .then(({ count }) => count) .catch(() => 0), ...(GRID_HOMEPAGE_ENABLED - ? getPhotoSidebarData() + ? getDataForCategories() : [[], [], [], [], [], [], []]), ]); @@ -58,8 +58,8 @@ export default async function HomePage() { cameras, lenses, tags, - simulations, recipes, + simulations, focalLengths, }} /> diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index e286e57c..437a2baa 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -73,6 +73,7 @@ export default function AdminAppConfigurationClient({ // Display categoryVisibility, hasCategoryVisibility, + collapseSidebarCategories, showExifInfo, showZoomControls, showTakenAtTimeHidden, @@ -520,6 +521,15 @@ export default function AdminAppConfigurationClient({ (default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}): {renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])} + + Set environment variable to {'"1"'} to show all sidebar + category content + {renderEnvVars(['NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES'])} + - {sortRecipesWithCount(recipes).map(({ recipe, count }) => + {sortRecipes(recipes).map(({ recipe, count }) =>
diff --git a/src/admin/AdminTagTable.tsx b/src/admin/AdminTagTable.tsx index 8f961344..759dc495 100644 --- a/src/admin/AdminTagTable.tsx +++ b/src/admin/AdminTagTable.tsx @@ -4,7 +4,7 @@ import AdminTable from '@/admin/AdminTable'; import { Fragment } from 'react'; import DeleteFormButton from '@/admin/DeleteFormButton'; import { photoQuantityText } from '@/photo'; -import { Tags, formatTag, sortTagsObject } from '@/tag'; +import { Tags, formatTag, sortTags } from '@/tag'; import EditButton from '@/admin/EditButton'; import { pathForAdminTagEdit } from '@/app/paths'; import { clsx } from 'clsx/lite'; @@ -17,7 +17,7 @@ export default function AdminTagTable({ }) { return ( - {sortTagsObject(tags).map(({ tag, count }) => + {sortTags(tags).map(({ tag, count }) =>
diff --git a/src/app/CommandK.tsx b/src/app/CommandK.tsx index a32066b0..af1d9413 100644 --- a/src/app/CommandK.tsx +++ b/src/app/CommandK.tsx @@ -1,19 +1,8 @@ import CommandKClient from '@/components/cmdk/CommandKClient'; -import { - getPhotosMetaCached, - getUniqueCamerasCached, - getUniqueFilmSimulationsCached, - getUniqueLensesCached, - getUniqueRecipesCached, - getUniqueTagsCached, -} from '@/photo/cache'; +import { getPhotosMetaCached } from '@/photo/cache'; import { photoQuantityText } from '@/photo'; -import { - ADMIN_DEBUG_TOOLS_ENABLED, - SHOW_FILM_SIMULATIONS, - SHOW_RECIPES, -} from './config'; -import { getUniqueFocalLengths } from '@/photo/db/query'; +import { ADMIN_DEBUG_TOOLS_ENABLED } from './config'; +import { getDataForCategories } from '@/category/data'; export default async function CommandK() { const [ @@ -28,16 +17,7 @@ export default async function CommandK() { getPhotosMetaCached() .then(({ count }) => count) .catch(() => 0), - getUniqueCamerasCached().catch(() => []), - getUniqueLensesCached().catch(() => []), - getUniqueTagsCached().catch(() => []), - SHOW_RECIPES - ? getUniqueRecipesCached().catch(() => []) - : [], - SHOW_FILM_SIMULATIONS - ? getUniqueFilmSimulationsCached().catch(() => []) - : [], - getUniqueFocalLengths().catch(() => []), + ...getDataForCategories(), ]); return [ + getUniqueCameras() + .then(sortCategoriesByCount) + .catch(() => []), + SHOW_LENSES + ? getUniqueLenses() + .then(sortCategoriesByCount) + .catch(() => []) + : [], + getUniqueTags() + .then(sortTagsByCount) + .catch(() => []), + SHOW_RECIPES + ? getUniqueRecipes() + .then(sortCategoriesByCount) + .catch(() => []) + : [], + SHOW_FILM_SIMULATIONS + ? getUniqueFilmSimulations() + .then(sortCategoriesByCount) + .catch(() => []) + : [], + SHOW_FOCAL_LENGTHS + ? getUniqueFocalLengths() + .then(sortCategoriesByCount) + .catch(() => []) + : [], +] as const; diff --git a/src/category/index.ts b/src/category/index.ts index 970c97f9..be9f044a 100644 --- a/src/category/index.ts +++ b/src/category/index.ts @@ -66,3 +66,41 @@ export const getOrderedCategoriesFromString = ( .map(category => category.trim().toLocaleLowerCase() as CategoryKey) .filter(category => CATEGORY_KEYS.includes(category)) : DEFAULT_CATEGORY_KEYS; + +export const sortCategoryByCount = ( + a: { count: number }, + b: { count: number }, +) => b.count - a.count; + +export const sortCategoriesByCount = ( + categories: T[], +) => categories.sort(sortCategoryByCount); + +const convertCategoryKeysToCategoryNames = + (categoryKeys: CategoryKeys): (keyof PhotoSetCategories)[] => { + return categoryKeys.map(key => { + return key === 'films' + ? 'simulations' + : key === 'focal-lengths' + ? 'focalLengths' + : key; + }); + }; + +export const getCategoryItemsCount = ( + categoryKeys: CategoryKeys, + categories: PhotoSetCategories, +) => + convertCategoryKeysToCategoryNames(categoryKeys).reduce((acc, key) => + acc + (categories[key]?.length ?? 0) + , 0); + +export const getCategoriesWithItemsCount = ( + categoryKeys: CategoryKeys, + categories: PhotoSetCategories, +) => + convertCategoryKeysToCategoryNames(categoryKeys).reduce((acc, key) => + (categories[key]?.length ?? 0) > 0 + ? acc + 1 + : acc + , 0); diff --git a/src/components/HeaderList.tsx b/src/components/HeaderList.tsx index b652538a..2becf235 100644 --- a/src/components/HeaderList.tsx +++ b/src/components/HeaderList.tsx @@ -1,23 +1,37 @@ +'use client'; + import { clsx } from 'clsx/lite'; import AnimateItems from './AnimateItems'; -import { ReactNode } from 'react'; +import { ReactNode, useState } from 'react'; +import LoaderButton from './primitives/LoaderButton'; +import { IoChevronDownOutline, IoChevronUpOutline } from 'react-icons/io5'; +import { COLLAPSE_SIDEBAR_CATEGORIES } from '@/app/config'; export default function HeaderList({ title, className, icon, items, + maxItems = 5, }: { title?: string, className?: string, icon?: ReactNode, - items: ReactNode[] + items: ReactNode[], + maxItems?: number, }) { + const [isExpanded, setIsExpanded] = useState(false); + + const hasItemsToExpand = + COLLAPSE_SIDEBAR_CATEGORIES && + // Don't show expand button if it only reveals 1 item + items.length > (maxItems + 1); + return ( {icon && @@ -38,8 +52,44 @@ export default function HeaderList({ } {title}
] - :[] as ReactNode[] - ).concat(items)} + : [] as ReactNode[] + ) + .concat(items.slice( + 0, + hasItemsToExpand && !isExpanded ? maxItems : items.length, + )) + .concat(hasItemsToExpand + ? [ + setIsExpanded(!isExpanded)} + styleAs="link" + className={clsx( + 'mt-1', + 'text-xs font-medium tracking-wider', + 'border-medium rounded-md', + 'px-[5px] h-5!', + 'hover:bg-dim hover:text-main active:bg-main', + 'group', + )} + > + { + {isExpanded + ? 'LESS' + : <> + MORE + + {' '} + {items.length - maxItems} + + } + {isExpanded + ? + : } + } + , + ] + : null)} classNameItem="text-dim uppercase" /> ); diff --git a/src/photo/PhotoGridPage.tsx b/src/photo/PhotoGridPage.tsx index bf5c775f..b5857e3a 100644 --- a/src/photo/PhotoGridPage.tsx +++ b/src/photo/PhotoGridPage.tsx @@ -4,10 +4,11 @@ import { Photo } from '.'; import { PATH_GRID_INFERRED } from '@/app/paths'; import PhotoGridSidebar from './PhotoGridSidebar'; import PhotoGridContainer from './PhotoGridContainer'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useAppState } from '@/state/AppState'; import clsx from 'clsx/lite'; import { PhotoSetCategories } from '@/category'; +import useElementHeight from '@/utility/useElementHeight'; export default function PhotoGridPage({ photos, @@ -17,6 +18,8 @@ export default function PhotoGridPage({ photos: Photo[] photosCount: number }) { + const ref = useRef(null); + const { setSelectedPhotoIds } = useAppState(); useEffect( @@ -24,6 +27,8 @@ export default function PhotoGridPage({ [setSelectedPhotoIds], ); + const containerHeight = useElementHeight(ref); + return (
diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 2ac56a48..356f07be 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -1,16 +1,14 @@ 'use client'; -import { sortCamerasWithCount } from '@/camera'; import PhotoCamera from '@/camera/PhotoCamera'; import HeaderList from '@/components/HeaderList'; import PhotoTag from '@/tag/PhotoTag'; import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.'; import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags } from '@/tag'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; -import { sortFilmSimulationsWithCount } from '@/simulation'; import FavsTag from '../tag/FavsTag'; import { useAppState } from '@/state/AppState'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import HiddenTag from '@/tag/HiddenTag'; import { CATEGORY_VISIBILITY, SITE_ABOUT } from '@/app/config'; import { @@ -18,32 +16,62 @@ import { safelyParseFormattedHtml, } from '@/utility/html'; import { clsx } from 'clsx/lite'; -import { sortRecipesWithCount } from '@/recipe'; import PhotoRecipe from '@/recipe/PhotoRecipe'; import IconCamera from '@/components/icons/IconCamera'; import IconRecipe from '@/components/icons/IconRecipe'; import IconTag from '@/components/icons/IconTag'; import IconFilmSimulation from '@/components/icons/IconFilmSimulation'; import IconLens from '@/components/icons/IconLens'; -import { sortLensesWithCount } from '@/lens'; import PhotoLens from '@/lens/PhotoLens'; import IconFocalLength from '@/components/icons/IconFocalLength'; -import { PhotoSetCategories } from '@/category'; +import { + getCategoriesWithItemsCount, + PhotoSetCategories, +} from '@/category'; import PhotoFocalLength from '@/focal/PhotoFocalLength'; +import useElementHeight from '@/utility/useElementHeight'; + +const APPROXIMATE_ITEM_HEIGHT = 34; +const ABOUT_HEIGHT_OFFSET = 80; export default function PhotoGridSidebar({ - cameras, - lenses, - tags, - simulations, - recipes, - focalLengths, photosCount, photosDateRange, + containerHeight, + ...categories }: PhotoSetCategories & { photosCount: number photosDateRange?: PhotoDateRange + containerHeight?: number }) { + const { + cameras, + lenses, + tags, + simulations, + recipes, + focalLengths, + } = categories; + + const categoriesCount = getCategoriesWithItemsCount( + CATEGORY_VISIBILITY, + categories, + ); + + const aboutRef = useRef(null); + const aboutHeight = useElementHeight(aboutRef); + const height = containerHeight + ? containerHeight - (aboutHeight ? aboutHeight + ABOUT_HEIGHT_OFFSET : 0) + : undefined; + + const maxItemsPerCategory = height + ? Math.max( + Math.floor(height / categoriesCount / APPROXIMATE_ITEM_HEIGHT), + // Always show at least 2 items + 2, + ) + : undefined; + const { start, end } = dateRangeForPhotos(undefined, photosDateRange); const { photosCountHidden } = useAppState(); @@ -56,9 +84,12 @@ export default function PhotoGridSidebar({ ? } + icon={} + maxItems={maxItemsPerCategory} items={cameras - .sort(sortCamerasWithCount) .map(({ cameraKey, camera, count }) => } + maxItems={maxItemsPerCategory} items={lenses - .sort(sortLensesWithCount) .map(({ lensKey, lens, count }) => } - items={tagsIncludingHidden.map(({ tag, count }) => { - switch (tag) { - case TAG_FAVS: - return ; - case TAG_HIDDEN: - return ; - default: - return ; - } - })} + maxItems={maxItemsPerCategory} + items={tagsIncludingHidden + .map(({ tag, count }) => { + switch (tag) { + case TAG_FAVS: + return ; + case TAG_HIDDEN: + return ; + default: + return ; + } + })} /> : null; @@ -144,7 +177,8 @@ export default function PhotoGridSidebar({ size={16} className="translate-x-[-1px]" />} - items={sortRecipesWithCount(recipes) + maxItems={maxItemsPerCategory} + items={recipes .map(({ recipe, count }) => } + maxItems={maxItemsPerCategory} items={simulations - .sort(sortFilmSimulationsWithCount) .map(({ simulation, count }) => } + maxItems={maxItemsPerCategory} items={focalLengths.map(({ focal, count }) => [ - getUniqueCameras().catch(() => []), - SHOW_LENSES ? getUniqueLenses().catch(() => []) : [], - getUniqueTags().then(sortTagsObject).catch(() => []), - SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulations().catch(() => []) : [], - SHOW_RECIPES ? getUniqueRecipes().catch(() => []) : [], - SHOW_FOCAL_LENGTHS ? getUniqueFocalLengths().catch(() => []) : [], -] as const; - -export const getPhotoSidebarDataCached = () => [ - getUniqueCamerasCached(), - SHOW_LENSES ? getUniqueLensesCached() : [], - getUniqueTagsCached().then(sortTagsObject), - SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [], - SHOW_RECIPES ? getUniqueRecipesCached() : [], - SHOW_FOCAL_LENGTHS ? getUniqueFocalLengthsCached() : [], -] as const; diff --git a/src/recipe/index.ts b/src/recipe/index.ts index dc0fff5a..832ca371 100644 --- a/src/recipe/index.ts +++ b/src/recipe/index.ts @@ -143,11 +143,11 @@ export const getPhotoWithRecipeFromPhotos = ( ? preferredPhoto : photos.find(photoHasRecipe); -export const sortRecipesWithCount = (recipes: Recipes = []) => +export const sortRecipes = (recipes: Recipes = []) => recipes.sort((a, b) => a.recipe.localeCompare(b.recipe)); export const convertRecipesForForm = (recipes: Recipes = []) => - sortRecipesWithCount(recipes) + sortRecipes(recipes) .map(({ recipe, count }) => ({ value: recipe, annotation: formatCount(count), diff --git a/src/simulation/index.ts b/src/simulation/index.ts index 5b283887..a370083d 100644 --- a/src/simulation/index.ts +++ b/src/simulation/index.ts @@ -22,6 +22,10 @@ export type FilmSimulationWithCount = { export type FilmSimulations = FilmSimulationWithCount[] +export const sortFilmSimulations = ( + simulations: FilmSimulations, +) => simulations.sort(sortFilmSimulationsWithCount); + export const sortFilmSimulationsWithCount = ( a: FilmSimulationWithCount, b: FilmSimulationWithCount, diff --git a/src/tag/index.ts b/src/tag/index.ts index 628df972..2111bcfa 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -15,6 +15,7 @@ import { formatCount, formatCountDescriptive, } from '@/utility/string'; +import { sortCategoryByCount } from '@/category'; // Reserved tags export const TAG_FAVS = 'favs'; @@ -49,25 +50,33 @@ export const titleForTag = ( export const shareTextForTag = (tag: string) => isTagFavs(tag) ? 'Favorite photos' : `Photos tagged '${formatTag(tag)}'`; -export const sortTags = ( +export const sortTagsArray = ( tags: string[], tagToExclude?: string, ) => tags .filter(tag => tag !== tagToExclude) .sort((a, b) => isTagFavs(a) ? -1 : a.localeCompare(b)); -export const sortTagsObject = ( +export const sortTags = ( tags: Tags, - tagToHide?: string, + tagToExclude?: string, ) => tags - .filter(({ tag }) => tag!== tagToHide) + .filter(({ tag }) => tag!== tagToExclude) .sort(({ tag: a }, { tag: b }) => isTagFavs(a) ? -1 : a.localeCompare(b)); +export const sortTagsByCount = ( + tags: Tags, + tagToExclude?: string, +) => tags + .filter(({ tag }) => tag !== tagToExclude) + .sort(({ tag: tagA, count: a }, { count: b }) => + isTagFavs(tagA) ? -1 : b - a); + export const sortTagsWithoutFavs = (tags: string[]) => - sortTags(tags, TAG_FAVS); + sortTagsArray(tags, TAG_FAVS); export const sortTagsObjectWithoutFavs = (tags: Tags) => - sortTagsObject(tags, TAG_FAVS); + sortTags(tags, TAG_FAVS); export const descriptionForTaggedPhotos = ( photos: Photo[] = [], @@ -105,16 +114,16 @@ export const isPathFavs = (pathname?: string) => export const isTagHidden = (tag: string) => tag.toLowerCase() === TAG_HIDDEN; -export const addHiddenToTags = (tags: Tags, photosCountHidden = 0) => { - if (photosCountHidden > 0) { - return tags +export const addHiddenToTags = (tags: Tags, photosCountHidden = 0) => + photosCountHidden > 0 + ? tags .filter(({ tag }) => tag === TAG_FAVS) .concat({ tag: TAG_HIDDEN, count: photosCountHidden }) - .concat(tags.filter(({ tag }) => tag !== TAG_FAVS)); - } else { - return tags; - } -}; + .concat(tags + .filter(({ tag }) => tag !== TAG_FAVS) + .sort(sortCategoryByCount), + ) + : tags; export const convertTagsForForm = (tags: Tags = []) => sortTagsObjectWithoutFavs(tags) diff --git a/src/utility/useElementHeight.ts b/src/utility/useElementHeight.ts new file mode 100644 index 00000000..8f0d992f --- /dev/null +++ b/src/utility/useElementHeight.ts @@ -0,0 +1,18 @@ +import { useState } from 'react'; + +import { RefObject, useEffect } from 'react'; + +export default function useElementHeight( + element: RefObject, +) { + const [height, setHeight] = useState(element.current?.clientHeight); + + useEffect(() => { + const handleResize = () => setHeight(element.current?.clientHeight); + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [element]); + + return height; +}