Mobile Sidebar (#330)
* Show top entities on mobile * Add config * Localize 'more'/'less' text
This commit is contained in:
parent
64c4b21f75
commit
3b6001602a
@ -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
|
||||
|
||||
|
||||
18
package.json
18
package.json
@ -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
1422
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,7 @@ export default function SignInOrUploadClient({
|
||||
)}>
|
||||
<div>
|
||||
{isCheckingAuth
|
||||
? appText.misc.loading
|
||||
? appText.utility.loading
|
||||
: isUserSignedIn
|
||||
? appText.onboarding.setupFirstPhoto
|
||||
: appText.onboarding.setupSignIn}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
49
src/category/mobile.ts
Normal 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;
|
||||
};
|
||||
@ -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 ?? {};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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}}',
|
||||
},
|
||||
|
||||
@ -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}}',
|
||||
},
|
||||
|
||||
@ -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}}',
|
||||
},
|
||||
|
||||
@ -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}}',
|
||||
},
|
||||
|
||||
@ -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}}',
|
||||
},
|
||||
|
||||
@ -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}}',
|
||||
},
|
||||
|
||||
@ -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}}',
|
||||
},
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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}} 页',
|
||||
},
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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) =>
|
||||
@ -64,6 +65,8 @@ export default function InfinitePhotoScroll({
|
||||
} & PhotoSetCategory) {
|
||||
const { isUserSignedIn } = useAppState();
|
||||
|
||||
const { utility } = useAppText();
|
||||
|
||||
const keyGenerator = useCallback(
|
||||
(size: number, prev: Photo[]) => prev && prev.length === 0
|
||||
? null
|
||||
@ -168,10 +171,10 @@ export default function InfinitePhotoScroll({
|
||||
)}
|
||||
>
|
||||
{error
|
||||
? 'Try Again'
|
||||
? utility.tryAgain
|
||||
: isLoadingOrValidating
|
||||
? <Spinner size={20} />
|
||||
: 'Load More'}
|
||||
: utility.loadMore}
|
||||
</button>
|
||||
</div>;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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</>}
|
||||
|
||||
136
src/photo/TopPhotoEntities.tsx
Normal file
136
src/photo/TopPhotoEntities.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user