Mobile Sidebar (#330)

* Show top entities on mobile

* Add config

* Localize 'more'/'less' text
This commit is contained in:
Sam Becker 2025-10-02 21:46:58 -05:00 committed by GitHub
parent 64c4b21f75
commit 3b6001602a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1197 additions and 777 deletions

View File

@ -138,7 +138,8 @@ Application behavior can be changed by configuring the following environment var
- `recipes` (default)
- `films` (default)
- `focal-lengths`
- `NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS = 1` prevents images displaying when hovering over category links:
- `NEXT_PUBLIC_HIDE_CATEGORIES_ON_MOBILE = 1` prevents categories displaying on mobile grid view
- `NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS = 1` prevents images displaying when hovering over category links
- `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content
- `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos

View File

@ -10,10 +10,10 @@
},
"packageManager": "pnpm@10.17.1",
"dependencies": {
"@ai-sdk/openai": "^2.0.38",
"@ai-sdk/rsc": "^1.0.56",
"@aws-sdk/client-s3": "3.896.0",
"@aws-sdk/s3-request-presigner": "3.896.0",
"@ai-sdk/openai": "^2.0.40",
"@ai-sdk/rsc": "^1.0.59",
"@aws-sdk/client-s3": "3.899.0",
"@aws-sdk/s3-request-presigner": "3.899.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
@ -24,7 +24,7 @@
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^2.0.0",
"@vercel/speed-insights": "^1.2.0",
"ai": "^5.0.56",
"ai": "^5.0.59",
"camelcase-keys": "^10.0.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -65,17 +65,17 @@
"@testing-library/react": "^16.3.0",
"@types/culori": "^4.0.1",
"@types/jest": "^30.0.0",
"@types/node": "^24.5.2",
"@types/node": "^24.6.0",
"@types/pg": "^8.15.5",
"@types/react": "19.1.14",
"@types/react": "19.1.15",
"@types/react-dom": "19.1.9",
"@types/sanitize-html": "^2.16.0",
"cross-fetch": "^4.1.0",
"eslint": "9.36.0",
"eslint-config-next": "15.5.4",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^30.1.3",
"jest-environment-jsdom": "^30.1.2",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "8.5.6",
"tailwindcss": "4.1.13",
"ts-node": "^10.9.2",

1422
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@ export default function SignInOrUploadClient({
)}>
<div>
{isCheckingAuth
? appText.misc.loading
? appText.utility.loading
: isUserSignedIn
? appText.onboarding.setupFirstPhoto
: appText.onboarding.setupSignIn}

View File

@ -88,6 +88,7 @@ export default function AdminAppConfigurationClient({
// Categories
hasCategoryVisibility,
categoryVisibility,
showCategoriesOnMobile,
showCategoryImageHover,
collapseSidebarCategories,
hideTagsWithOnePhoto,
@ -613,6 +614,19 @@ export default function AdminAppConfigurationClient({
</div>
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
</ChecklistRow>
<ChecklistRow
title="Show on mobile"
status={showCategoriesOnMobile}
optional
>
<div className="flex flex-col gap-2">
<div>
Set environment variable to {'"1"'} to prevent categories
displaying on mobile grid view:
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORIES_ON_MOBILE'])}
</div>
</div>
</ChecklistRow>
<ChecklistRow
title="Show image hovers"
status={showCategoryImageHover}

View File

@ -284,6 +284,8 @@ export const SHOW_FILMS =
CATEGORY_VISIBILITY.includes('films');
export const SHOW_FOCAL_LENGTHS =
CATEGORY_VISIBILITY.includes('focal-lengths');
export const SHOW_CATEGORIES_ON_MOBILE =
process.env.NEXT_PUBLIC_HIDE_CATEGORIES_ON_MOBILE !== '1';
export const SHOW_CATEGORY_IMAGE_HOVERS =
process.env.NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS !== '1';
export const COLLAPSE_SIDEBAR_CATEGORIES =
@ -454,6 +456,7 @@ export const APP_CONFIGURATION = {
hasCategoryVisibility:
Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY),
categoryVisibility: CATEGORY_VISIBILITY,
showCategoriesOnMobile: SHOW_CATEGORIES_ON_MOBILE,
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,

49
src/category/mobile.ts Normal file
View File

@ -0,0 +1,49 @@
import { getTopNonFavTags, tagsHaveFavs } from '@/tag';
import { PhotoSetCategories } from '@/category';
import {
SHOW_ALBUMS,
SHOW_CAMERAS,
SHOW_FILMS,
SHOW_FOCAL_LENGTHS,
SHOW_LENSES,
SHOW_RECENTS,
SHOW_RECIPES,
SHOW_TAGS,
} from '@/app/config';
const MAX_ALBUM_TAG_COUNT = 3;
const MINIMUM_TOP_ENTITIES = 3;
export const getTopEntities = ({
tags,
recents,
albums,
recipes,
films,
focalLengths,
cameras,
lenses,
}: PhotoSetCategories) => ({
hasFavs: tagsHaveFavs(tags),
hasRecents: SHOW_RECENTS && recents.length > 0,
albums: SHOW_ALBUMS ? albums.slice(0, MAX_ALBUM_TAG_COUNT) : [],
tags: SHOW_TAGS ? getTopNonFavTags(tags).slice(0, MAX_ALBUM_TAG_COUNT) : [],
recipe: SHOW_RECIPES ? recipes[0]?.recipe : undefined,
film: SHOW_FILMS ? films[0]?.film : undefined,
focal: SHOW_FOCAL_LENGTHS ? focalLengths[0]?.focal : undefined,
camera: SHOW_CAMERAS ? cameras[0]?.camera : undefined,
lens: SHOW_LENSES ? lenses[0]?.lens : undefined,
});
export const hasEnoughTopEntities = (categories: PhotoSetCategories) => {
const entityCount = Object.values(getTopEntities(categories))
.reduce<number>((acc, entity) => {
if (Array.isArray(entity)) {
return acc + entity.length;
} else {
return Boolean(entity) ? acc + 1 : acc;
}
}, 0);
return entityCount > MINIMUM_TOP_ENTITIES;
};

View File

@ -7,7 +7,7 @@ import { Album } from '@/album';
export default function useCategoryCounts() {
const { categoriesWithCounts } = useAppState();
const recentsCount = categoriesWithCounts?.recents[0] ?? 0;
const recentsCount = categoriesWithCounts?.recents?.count ?? 0;
const getYearsCount = useCallback((year: string) => {
const yearCounts = categoriesWithCounts?.years ?? {};

View File

@ -764,12 +764,11 @@ export default function CommandKClient({
? <span className="translate-y-[2px]">
<Spinner size={16} className="-mr-1" />
</span>
: <span className="max-sm:hidden">
: <span>
<LoaderButton
className={clsx(
'h-auto! py-1 -mr-2',
'border-medium shadow-none',
queryLiveRaw ? 'px-1' : 'px-1.5',
'h-auto! py-1 mr-[-9px]',
'px-1',
'text-[12px]',
'text-gray-400/90 dark:text-gray-700',
)}
@ -784,7 +783,14 @@ export default function CommandKClient({
>
{queryLiveRaw
? <IoClose size={17} className="text-dim" />
: 'ESC'}
: <>
<span className="sm:hidden">
<IoClose size={17} className="text-dim" />
</span>
<span className="max-sm:hidden mx-0.5">
ESC
</span>
</>}
</LoaderButton>
</span>}
</div>

View File

@ -11,7 +11,7 @@ export default function Badge({
}: {
children: React.ReactNode
className?: string
type?: 'large' | 'small' | 'text-only'
type?: 'large' | 'medium' | 'small' |'text-only'
dimContent?: boolean
contrast?: 'low' | 'medium' | 'high' | 'frosted'
uppercase?: boolean
@ -27,14 +27,19 @@ export default function Badge({
'border border-medium',
);
case 'small':
case 'medium':
return clsx(
'px-[5px] h-[17px] md:h-[18px]',
'text-[0.7rem] font-medium rounded-md',
type === 'small'
? 'px-[5px] h-[17px] md:h-[18px]'
: 'px-2 h-6.5',
type === 'small'
? 'text-[0.7rem] font-medium rounded-md'
: 'text-[0.9rem] rounded-lg',
contrast === 'high'
? 'text-invert bg-invert'
: contrast === 'frosted'
? 'text-black bg-neutral-100/30 border border-neutral-200/40'
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
: 'text-medium-dark bg-gray-300/30 dark:bg-gray-700/50',
interactive && (contrast === 'high'
? 'hover:opacity-70'
: contrast === 'frosted'
@ -53,7 +58,8 @@ export default function Badge({
'max-w-full',
'inline-flex items-center',
stylesForType(),
uppercase && 'uppercase tracking-wider',
uppercase && 'uppercase',
uppercase && type !== 'medium' && 'tracking-wider',
className,
)}>
<span className={clsx(

View File

@ -31,7 +31,7 @@ export default function CopyButton({
onClick={text
? () => {
navigator.clipboard.writeText(text);
toastSuccess(appText.misc.copyPhrase(label));
toastSuccess(appText.utility.copyPhrase(label));
}
: undefined}
styleAs="link"

View File

@ -6,6 +6,7 @@ import { ReactNode, useState } from 'react';
import LoaderButton from './primitives/LoaderButton';
import { IoChevronDownOutline, IoChevronUpOutline } from 'react-icons/io5';
import { COLLAPSE_SIDEBAR_CATEGORIES } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
export default function HeaderList({
title,
@ -20,6 +21,8 @@ export default function HeaderList({
items: ReactNode[],
maxItems?: number,
}) {
const { utility } = useAppText();
const [isExpanded, setIsExpanded] = useState(false);
const hasItemsToExpand =
@ -70,11 +73,11 @@ export default function HeaderList({
'group',
)}
>
{<span className="flex items-center gap-1">
{<span className="flex items-center gap-1 uppercase">
{isExpanded
? 'LESS'
? utility.less
: <>
MORE
{utility.more}
<span className="hidden group-hover:inline text-dim!">
{' '}
{items.length - maxItems}

View File

@ -5,11 +5,12 @@ import Link from 'next/link';
import { BiLogoGithub } from 'react-icons/bi';
export default function RepoLink() {
const appText = useAppText();
const { footer } = useAppText();
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap">
<span className="hidden sm:inline-block">
{appText.misc.repo}
{footer.madeWith}
</span>
<Link
href={TEMPLATE_REPO_URL}

View File

@ -17,6 +17,7 @@ export interface EntityLinkExternalProps {
ref?: RefObject<HTMLSpanElement | null>
type?: LabeledIconType
badged?: boolean
badgeType?: ComponentProps<typeof Badge>['type']
contrast?: ComponentProps<typeof Badge>['contrast']
uppercase?: boolean
prefetch?: boolean
@ -38,6 +39,7 @@ export default function EntityLink({
iconWide,
type,
badged,
badgeType = 'small',
contrast = 'medium',
path = '', // Make link optional for debugging purposes
hoverCount = 0,
@ -70,7 +72,11 @@ export default function EntityLink({
} & EntityLinkExternalProps) {
const [isLoading, setIsLoading] = useState(false);
const hasBadgeIcon = Boolean(iconBadgeStart || iconBadgeEnd);
const hasBadgeIcon = Boolean(
iconBadgeStart ||
iconBadgeEnd ||
badgeType === 'medium',
);
const classForContrast = () => {
switch (contrast) {
@ -121,10 +127,12 @@ export default function EntityLink({
setIsLoading={setIsLoading}
>
<LabeledIcon {...{
icon:
(badged && hasBadgeIcon && !useForHover) ? undefined : icon,
iconWide:
(badged && hasBadgeIcon && !useForHover) ? undefined : iconWide,
icon: badged && hasBadgeIcon && !useForHover
? undefined
: icon,
iconWide: badged && hasBadgeIcon && !useForHover
? undefined
: iconWide,
prefetch,
title,
type: useForHover ? 'icon-first' : type,
@ -138,18 +146,24 @@ export default function EntityLink({
}}>
{badged && !useForHover
? <Badge
type="small"
type={badgeType}
contrast={contrast}
className={clsx(
'translate-y-[-0.5px]',
hasBadgeIcon && '*:flex *:items-center *:gap-1',
hasBadgeIcon && '*:flex *:items-center',
hasBadgeIcon && badgeType === 'medium'
? '*:gap-[5px]'
: '*:gap-1',
suppressSpinner && isLoading && 'opacity-50',
)}
uppercase
interactive
>
{iconBadgeStart}
{badgeType === 'medium' &&
<span className="translate-y-[0.5px]">{icon}</span>}
{badgeType !== 'medium' && iconBadgeStart}
{renderLabel}
{iconBadgeEnd}
{badgeType !== 'medium' && iconBadgeEnd}
</Badge>
: <span className={clsx(
'text-content',
@ -170,6 +184,7 @@ export default function EntityLink({
'max-w-full overflow-hidden select-none',
// Underline link text when action is hovered
'[&:has(.action:hover)_.text-content]:underline',
!truncate && 'shrink-0',
className,
)}
>

View File

@ -15,7 +15,7 @@ export default function useMaskedScroll({
ref: containerRef,
direction = 'vertical',
fadeSize = 24,
animationDuration = 0.3,
animationDuration = 0.2,
setMaxSize = true,
hideScrollbar = true,
// Disable when calling 'updateMask' explicitly

View File

@ -18,7 +18,7 @@ export default function useNavigateOrRunActionWithToast({
const appText = useAppText();
const toastMessage = _toastMessage ?? appText.misc.loading;
const toastMessage = _toastMessage ?? appText.utility.loading;
const toastId = useRef<string | number>(undefined);

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: 'পরবর্তী',
nextShort: 'পরবর্তী',
},
footer: {
madeWith: 'তৈরি হয়েছে',
},
sort: {
sort: 'সাজান',
newest: 'নতুনতম',
@ -142,14 +145,15 @@ export const TEXT: I18N = {
// eslint-disable-next-line max-len
setupConfig: 'পরিবেশ ভেরিয়েবল সম্পাদনা করে সাইটের নাম এবং অন্যান্য কনফিগারেশন পরিবর্তন করুন',
},
misc: {
utility: {
more: 'আরো',
less: 'কম',
loadMore: 'আরো লোড করুন',
loading: 'লোড হচ্ছে ...',
tryAgain: 'আবার চেষ্টা করুন',
finishing: 'সম্পন্ন হচ্ছে ...',
uploading: 'আপলোড হচ্ছে',
repo: 'তৈরি হয়েছে',
copyPhrase: '{{label}} কপি হয়েছে',
},
utility: {
paginate: '{{index}} / {{count}}',
paginateAction: '{{action}} - {{index}} / {{count}}',
},

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: 'Next',
nextShort: 'Next',
},
footer: {
madeWith: 'Made with',
},
sort: {
sort: 'Sort',
newest: 'Newest',
@ -142,14 +145,15 @@ export const TEXT: I18N = {
// eslint-disable-next-line max-len
setupConfig: 'Change the site name and other configuration by editing environment variables referenced in',
},
misc: {
utility: {
more: 'More',
less: 'Less',
loadMore: 'Load More',
loading: 'Loading ...',
tryAgain: 'Try Again',
finishing: 'Finishing ...',
uploading: 'Uploading',
repo: 'Made with',
copyPhrase: '{{label}} copied',
},
utility: {
paginate: '{{index}} of {{count}}',
paginateAction: '{{action}} {{index}} of {{count}}',
},

View File

@ -54,6 +54,9 @@ export const TEXT = {
next: 'Next',
nextShort: 'Next',
},
footer: {
madeWith: 'Made with',
},
sort: {
sort: 'Sort',
newest: 'Newest',
@ -141,14 +144,15 @@ export const TEXT = {
// eslint-disable-next-line max-len
setupConfig: 'Change the site name and other configuration by editing environment variables referenced in',
},
misc: {
utility: {
more: 'More',
less: 'Less',
loadMore: 'Load More',
loading: 'Loading ...',
tryAgain: 'Try Again',
finishing: 'Finishing ...',
uploading: 'Uploading',
repo: 'Made with',
copyPhrase: '{{label}} copied',
},
utility: {
paginate: '{{index}} of {{count}}',
paginateAction: '{{action}} {{index}} of {{count}}',
},

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: 'अगला',
nextShort: 'अगला',
},
footer: {
madeWith: 'निर्मित',
},
sort: {
sort: 'क्रमबद्ध करें',
newest: 'नवीनतम',
@ -143,14 +146,15 @@ export const TEXT: I18N = {
// eslint-disable-next-line max-len
setupConfig: 'साइट का नाम और अन्य कॉन्फ़िगरेशन बदलने के लिए पर्यावरण चर संपादित करें',
},
misc: {
utility: {
more: 'और',
less: 'कम',
loadMore: 'और लोड करें',
loading: 'लोड हो रहा है...',
tryAgain: 'फिर से कोशिश करें',
finishing: 'समाप्त कर रहे हैं...',
uploading: 'अपलोड हो रहा है',
repo: 'निर्मित',
copyPhrase: '{{label}} कॉपी किया गया',
},
utility: {
paginate: '{{index}} / {{count}}',
paginateAction: '{{action}} - {{index}} / {{count}}',
},

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: 'Berikutnya',
nextShort: 'Brkt',
},
footer: {
madeWith: 'Dibuat dengan',
},
sort: {
sort: 'Urutkan',
newest: 'Terbaru',
@ -141,14 +144,15 @@ export const TEXT: I18N = {
setupFirstPhoto: 'Tambahkan foto pertama Anda',
setupConfig: 'Ubah nama situs dan pengaturan lewat file environment',
},
misc: {
utility: {
more: 'Lebih banyak',
less: 'Lebih sedikit',
loadMore: 'Muat Lebih Banyak',
loading: 'Memuat ...',
tryAgain: 'Coba Lagi',
finishing: 'Menyelesaikan ...',
uploading: 'Mengunggah',
repo: 'Dibuat dengan',
copyPhrase: '{{label}} disalin',
},
utility: {
paginate: '{{index}} dari {{count}}',
paginateAction: '{{action}} {{index}} dari {{count}}',
},

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: 'Próximo',
nextShort: 'Próx',
},
footer: {
madeWith: 'Feito com',
},
sort: {
sort: 'Ordenar',
newest: 'Mais recentes',
@ -142,14 +145,15 @@ export const TEXT: I18N = {
// eslint-disable-next-line max-len
setupConfig: 'Altere o nome do site e outras configurações editando as variáveis de ambiente referenciadas em',
},
misc: {
utility: {
more: 'Mais',
less: 'Menos',
loadMore: 'Carregar Mais',
loading: 'Carregando ...',
tryAgain: 'Tentar Novamente',
finishing: 'Finalizando ...',
uploading: 'Enviando',
repo: 'Feito com',
copyPhrase: '{{label}} copiado',
},
utility: {
paginate: '{{index}} de {{count}}',
paginateAction: '{{action}} {{index}} de {{count}}',
},

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: 'Próximo',
nextShort: 'Próx',
},
footer: {
madeWith: 'Feito com',
},
sort: {
sort: 'Ordenar',
newest: 'Mais recentes',
@ -142,14 +145,15 @@ export const TEXT: I18N = {
// eslint-disable-next-line max-len
setupConfig: 'Altere o nome do sítio e outras configurações ao editar as variáveis de ambiente referenciadas em',
},
misc: {
utility: {
more: 'Mais',
less: 'Menos',
loadMore: 'Carregar Mais',
loading: 'A carregar ...',
tryAgain: 'Tentar Novamente',
finishing: 'A finalizar ...',
uploading: 'A enviar',
repo: 'Feito com',
copyPhrase: '{{label}} copiado',
},
utility: {
paginate: '{{index}} de {{count}}',
paginateAction: '{{action}} {{index}} de {{count}}',
},

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: 'Sonraki',
nextShort: 'Sonraki',
},
footer: {
madeWith: 'Hazırlayan:',
},
sort: {
sort: 'Sırala',
newest: 'En Yeni',
@ -143,14 +146,15 @@ export const TEXT: I18N = {
// eslint-disable-next-line max-len
setupConfig: 'Site adını ve diğer ayarları değiştirmek için şu ortam değişkenlerini düzenleyin:',
},
misc: {
utility: {
more: 'Daha fazla',
less: 'Daha az',
loadMore: 'Daha Fazla Yükle',
loading: 'Yükleniyor ...',
tryAgain: 'Tekrar Dene',
finishing: 'Tamamlanıyor ...',
uploading: 'Yükleniyor',
repo: 'Hazırlayan:',
copyPhrase: '{{label}} kopyalandı',
},
utility: {
paginate: '{{count}} fotoğrafın {{index}}.si',
paginateAction: '{{action}} - {{count}} fotoğrafın {{index}}.si',
},

View File

@ -55,6 +55,9 @@ export const TEXT: I18N = {
next: '下一页',
nextShort: '下一页',
},
footer: {
madeWith: '基于',
},
sort: {
sort: '排序',
newest: '最新',
@ -141,14 +144,15 @@ export const TEXT: I18N = {
setupFirstPhoto: '添加您的第一张照片',
setupConfig: '通过编辑环境变量来更改站点名称和其他配置',
},
misc: {
utility: {
more: '更多',
less: '更少',
loadMore: '加载更多',
loading: '加载中...',
tryAgain: '重试',
finishing: '完成中...',
uploading: '上传中',
repo: '基于',
copyPhrase: '{{label}} 已复制',
},
utility: {
paginate: '第 {{index}} 页,共 {{count}} 页',
paginateAction: '{{action}} 第 {{index}} 页,共 {{count}} 页',
},

View File

@ -33,13 +33,10 @@ export const generateAppTextState = (i18n: I18N) => {
deleteConfirm: (photoTitle: string) =>
i18n.admin.deleteConfirm.replace('{{photoTitle}}', photoTitle),
},
misc: {
...i18n.misc,
copyPhrase: (label: string) =>
i18n.misc.copyPhrase.replace('{{label}}', label),
},
utility: {
...i18n.utility,
copyPhrase: (label: string) =>
i18n.utility.copyPhrase.replace('{{label}}', label),
paginate: (index: number, count: number) =>
i18n.utility.paginate
.replace('{{index}}', index.toString())

View File

@ -19,6 +19,7 @@ import useVisibility from '@/utility/useVisibility';
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
import { SortBy } from './sort';
import { SWR_KEYS } from '@/swr';
import { useAppText } from '@/i18n/state/client';
const SIZE_KEY_SEPARATOR = '__';
const getSizeFromKey = (key: string) =>
@ -63,6 +64,8 @@ export default function InfinitePhotoScroll({
}) => ReactNode
} & PhotoSetCategory) {
const { isUserSignedIn } = useAppState();
const { utility } = useAppText();
const keyGenerator = useCallback(
(size: number, prev: Photo[]) => prev && prev.length === 0
@ -168,10 +171,10 @@ export default function InfinitePhotoScroll({
)}
>
{error
? 'Try Again'
? utility.tryAgain
: isLoadingOrValidating
? <Spinner size={20} />
: 'Load More'}
: utility.loadMore}
</button>
</div>;

View File

@ -19,6 +19,7 @@ export default function PhotoGridContainer({
animateOnFirstLoadOnly,
header,
sidebar,
className,
...categories
}: {
cacheKey: string
@ -28,6 +29,7 @@ export default function PhotoGridContainer({
excludeFromFeeds?: boolean
header?: ReactNode
sidebar?: ReactNode
className?: string
} & ComponentProps<typeof PhotoGrid>) {
const [
shouldAnimateDynamicItems,
@ -40,6 +42,7 @@ export default function PhotoGridContainer({
<AppGrid
contentMain={<div className={clsx(
header && 'space-y-8 mt-1.5',
className,
)}>
{header &&
<AnimateItems

View File

@ -7,9 +7,12 @@ import PhotoGridContainer from './PhotoGridContainer';
import { ComponentProps, useMemo, useRef } from 'react';
import clsx from 'clsx/lite';
import MaskedScroll from '@/components/MaskedScroll';
import { IS_RECENTS_FIRST } from '@/app/config';
import { IS_RECENTS_FIRST, SHOW_CATEGORIES_ON_MOBILE } from '@/app/config';
import { SortBy } from './sort';
import useViewportHeight from '@/utility/useViewportHeight';
import TopPhotoEntities from './TopPhotoEntities';
import AnimateItems from '@/components/AnimateItems';
import { hasEnoughTopEntities } from '@/category/mobile';
export default function PhotoGridPageClient({
photos,
@ -32,34 +35,55 @@ export default function PhotoGridPageClient({
viewPortHeight - (ref.current?.getBoundingClientRect().y ?? 0),
[viewPortHeight]);
const shouldShowTopEntities = useMemo(() =>
SHOW_CATEGORIES_ON_MOBILE && hasEnoughTopEntities(categories),
[categories]);
return (
<PhotoGridContainer
cacheKey={`page-${PATH_GRID_INFERRED}`}
photos={photos}
count={photosCount}
sortBy={sortBy}
sortWithPriority={sortWithPriority}
excludeFromFeeds
prioritizeInitialPhotos
sidebar={
<MaskedScroll
ref={ref}
className={clsx(
'sticky top-0',
// Optical adjustment for headerless recents
IS_RECENTS_FIRST ? '-mb-4.5 -mt-4.5' : '-mb-5 -mt-5',
'max-h-screen py-4',
)}
fadeSize={100}
setMaxSize={false}
>
<PhotoGridSidebar {...{
...categories,
photosCount: photosCountWithExcludes,
containerHeight,
}} />
</MaskedScroll>
}
/>
<div>
{shouldShowTopEntities &&
<AnimateItems
type="bottom"
items={[
<div key="mobile-sidebar" className={clsx(
'flex gap-x-2',
'md:hidden',
'mb-4',
)}>
<TopPhotoEntities
className="grow"
{...categories}
/>
</div>,
]} />}
<PhotoGridContainer
cacheKey={`page-${PATH_GRID_INFERRED}`}
photos={photos}
count={photosCount}
sortBy={sortBy}
sortWithPriority={sortWithPriority}
excludeFromFeeds
prioritizeInitialPhotos
sidebar={
<MaskedScroll
ref={ref}
className={clsx(
'sticky top-0',
// Optical adjustment for headerless recents
IS_RECENTS_FIRST ? '-mb-4.5 -mt-4.5' : '-mb-5 -mt-5',
'max-h-screen py-4',
)}
fadeSize={100}
setMaxSize={false}
>
<PhotoGridSidebar {...{
...categories,
photosCount: photosCountWithExcludes,
containerHeight,
}} />
</MaskedScroll>
}
/>
</div>
);
}

View File

@ -50,12 +50,14 @@ export default function PhotoGridSidebar({
containerHeight,
aboutTextSafelyParsedHtml,
aboutTextHasBrParagraphBreaks,
className,
..._categories
}: PhotoSetCategories & {
photosCount: number
containerHeight?: number
aboutTextSafelyParsedHtml?: string
aboutTextHasBrParagraphBreaks?: boolean
className?: string
}) {
const categories = useMemo(() => HIDE_TAGS_WITH_ONE_PHOTO
? {
@ -327,7 +329,7 @@ export default function PhotoGridSidebar({
: null;
return (
<div className="space-y-4">
<div className={clsx('space-y-4', className)}>
{aboutTextSafelyParsedHtml && <HeaderList
items={[<p
key="about"

View File

@ -3,6 +3,7 @@ import { Photo } from '.';
import { PhotoSetCategory } from '../category';
import PhotoGrid from './PhotoGrid';
import Link from 'next/link';
import { useAppText } from '@/i18n/state/client';
export default function PhotoLightbox({
count,
@ -16,6 +17,8 @@ export default function PhotoLightbox({
maxPhotosToShow?: number
moreLink: string
} & PhotoSetCategory) {
const { utility } = useAppText();
const photoCountToShow = maxPhotosToShow < count
? maxPhotosToShow - 1
: maxPhotosToShow;
@ -44,7 +47,7 @@ export default function PhotoLightbox({
<div className="text-[1.1rem] lg:text-[1.5rem]">
+{countNotShown}
</div>
<div className="text-dim">More</div>
<div className="text-dim">{utility.more}</div>
</Link>
: undefined}
small

View File

@ -167,19 +167,19 @@ export default function PhotoUploadWithStatus({
{isUploading
? isFinishing
? <>
{appText.misc.finishing}
{appText.utility.finishing}
</>
: <>
{!showButton && uploadStatusText
? <>
<ResponsiveText shortText={uploadStatusText}>
{appText.misc.uploading} {uploadStatusText}
{appText.utility.uploading} {uploadStatusText}
</ResponsiveText>
{': '}
{fileUploadName}
</>
: <ResponsiveText shortText={fileUploadName}>
{appText.misc.uploading} {fileUploadName}
{appText.utility.uploading} {fileUploadName}
</ResponsiveText>}
</>
: !showButton && <>Initializing</>}

View File

@ -0,0 +1,136 @@
import PhotoCamera from '@/camera/PhotoCamera';
import { PhotoSetCategories } from '@/category';
import MaskedScroll from '@/components/MaskedScroll';
import PhotoAlbum from '@/album/PhotoAlbum';
import PhotoTag from '@/tag/PhotoTag';
import PhotoFavs from '@/tag/PhotoFavs';
import clsx from 'clsx';
import { CATEGORY_VISIBILITY } from '@/app/config';
import PhotoRecents from '@/recents/PhotoRecents';
import PhotoFilm from '@/film/PhotoFilm';
import PhotoFocalLength from '@/focal/PhotoFocalLength';
import PhotoLens from '@/lens/PhotoLens';
import PhotoRecipe from '@/recipe/PhotoRecipe';
import LoaderButton from '@/components/primitives/LoaderButton';
import { useAppState } from '@/app/AppState';
import { ComponentProps, useMemo } from 'react';
import EntityLink from '@/components/entity/EntityLink';
import { useAppText } from '@/i18n/state/client';
import { getTopEntities } from '@/category/mobile';
import { BiExpandVertical } from 'react-icons/bi';
const ENTITY_LINK_PROPS: Partial<ComponentProps<typeof EntityLink>> = {
badged: true,
badgeType: 'medium',
truncate: false,
suppressSpinner: true,
};
export default function TopPhotoEntities({
className,
...categories
}: PhotoSetCategories & {
className?: string
}) {
const { setIsCommandKOpen } = useAppState();
const { utility } = useAppText();
const {
hasFavs,
hasRecents,
albums,
tags,
camera,
lens,
recipe,
film,
focal,
} = useMemo(() => getTopEntities(categories), [categories]);
return (
<MaskedScroll
direction="horizontal"
className={clsx(
'flex whitespace-nowrap gap-x-3',
// Prevent shadow clipping
'py-1',
className,
)}
fadeSize={50}
>
{hasFavs &&
<PhotoFavs
{...ENTITY_LINK_PROPS}
badgeIconFirst
/>}
{hasRecents &&
<PhotoRecents
key="recents"
{...ENTITY_LINK_PROPS}
/>}
{albums.map(({ album }) =>
<PhotoAlbum
key={album.id}
album={album}
{...ENTITY_LINK_PROPS}
/>,
)}
{tags.map(({ tag }) =>
<PhotoTag
key={tag}
tag={tag}
{...ENTITY_LINK_PROPS}
/>,
)}
{CATEGORY_VISIBILITY
.map(category => {
switch (category) {
case 'cameras': return camera &&
<PhotoCamera
key="cameras"
camera={camera}
{...ENTITY_LINK_PROPS}
/>;
case 'lenses': return lens &&
<PhotoLens
key="lenses"
lens={lens}
{...ENTITY_LINK_PROPS}
/>;
case 'recipes': return recipe &&
<PhotoRecipe
key="recipes"
recipe={recipe}
{...ENTITY_LINK_PROPS}
/>;
case 'films': return film &&
<PhotoFilm
key="films"
film={film}
{...ENTITY_LINK_PROPS}
/>;
case 'focal-lengths': return focal &&
<PhotoFocalLength
key="focal-lengths"
focal={focal}
{...ENTITY_LINK_PROPS}
/>;
}
})}
<LoaderButton
icon={<BiExpandVertical
className="text-medium translate-y-[0.75px] text-[0.9rem]"
/>}
onClick={() => setIsCommandKOpen?.(true)}
hideText="never"
className={clsx(
'h-auto pt-[5px] pb-1.5 pl-1 pr-2.5',
'gap-x-[3px] uppercase tracking-wide',
)}
>
{utility.more}
</LoaderButton>
</MaskedScroll>
);
}

View File

@ -8,7 +8,10 @@ import EntityLink, {
} from '@/components/entity/EntityLink';
import IconFavs from '@/components/icons/IconFavs';
export default function PhotoFavs(props: EntityLinkExternalProps) {
export default function PhotoFavs({
badgeIconFirst,
...props
}: EntityLinkExternalProps & { badgeIconFirst?: boolean }) {
const { getTagCount } = useCategoryCounts();
return (
<EntityLink
@ -21,7 +24,7 @@ export default function PhotoFavs(props: EntityLinkExternalProps) {
className="translate-x-[-0.5px] translate-y-[-0.5px]"
highlight
/>}
iconBadgeEnd={<IconFavs
iconBadgeEnd={!badgeIconFirst && <IconFavs
size={10}
className="translate-y-[-0.5px]"
highlight

View File

@ -93,6 +93,10 @@ export const sortTagsWithoutFavs = (tags: string[]) =>
export const sortTagsObjectWithoutFavs = (tags: Tags) =>
sortTags(tags, TAG_FAVS);
export const getTopNonFavTags = (tags: Tags) => tags
.filter(({ tag }) => tag !== TAG_FAVS)
.slice(0, 3);
export const descriptionForTaggedPhotos = (
photos: Photo[] = [],
appText: AppTextState,
@ -189,3 +193,6 @@ export const limitTagsByCount = (
.toLocaleLowerCase()
.includes(queryToInclude.toLocaleLowerCase()))
));
export const tagsHaveFavs = (tags: Tags) =>
tags.some(({ tag }) => isTagFavs(tag));

View File

@ -114,6 +114,10 @@ html {
@apply
text-light dark:text-dark
}
@utility text-medium-dark {
@apply
text-gray-600 dark:text-gray-300
}
@utility text-medium {
@apply
text-gray-500 dark:text-gray-400