Refine core photo/category behavior
This commit is contained in:
parent
6768f15212
commit
e1ee7ff7da
@ -1,7 +1,7 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import FilmSimulationImageResponse from
|
||||
'@/image-response/FilmSimulationImageResponse';
|
||||
@ -39,7 +39,7 @@ export async function GET(
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, simulation }),
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, simulation }),
|
||||
getIBMPlexMonoMedium(),
|
||||
getImageResponseCacheControlHeaders(),
|
||||
]);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import { getIBMPlexMonoMedium } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
@ -41,7 +41,7 @@ export async function GET(
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, focal }),
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, focal }),
|
||||
getIBMPlexMonoMedium(),
|
||||
getImageResponseCacheControlHeaders(),
|
||||
]);
|
||||
|
||||
@ -62,7 +62,7 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PhotoTagPage({
|
||||
export default async function PhotoRecipePage({
|
||||
params,
|
||||
}: PhotoRecipeProps) {
|
||||
const { photoId, recipe: recipeFromParams } = await params;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import { getIBMPlexMonoMedium } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
@ -37,7 +37,7 @@ export async function GET(
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, recipe }),
|
||||
getPhotosCached({ recipe, limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY }),
|
||||
getIBMPlexMonoMedium(),
|
||||
getImageResponseCacheControlHeaders(),
|
||||
]);
|
||||
|
||||
@ -2,7 +2,7 @@ import { getPhotosCached } from '@/photo/cache';
|
||||
import { Camera, CameraProps, getCameraFromParams } from '@/camera';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import CameraImageResponse from '@/image-response/CameraImageResponse';
|
||||
import { getIBMPlexMonoMedium } from '@/app/font';
|
||||
@ -39,7 +39,7 @@ export async function GET(
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
camera: camera,
|
||||
}),
|
||||
getIBMPlexMonoMedium(),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import TagImageResponse from '@/image-response/TagImageResponse';
|
||||
import { getIBMPlexMonoMedium } from '@/app/font';
|
||||
@ -37,7 +37,7 @@ export async function GET(
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, tag }),
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, tag }),
|
||||
getIBMPlexMonoMedium(),
|
||||
getImageResponseCacheControlHeaders(),
|
||||
]);
|
||||
|
||||
@ -14,11 +14,13 @@ export default function ImagePhotoGrid({
|
||||
height,
|
||||
imagePosition = 'center',
|
||||
gap = 4,
|
||||
imageStyle,
|
||||
}: ({
|
||||
photos: Photo[]
|
||||
height: number
|
||||
imagePosition?: 'center' | 'top'
|
||||
gap?: number
|
||||
imageStyle?: React.CSSProperties
|
||||
} & (
|
||||
{ width: NextImageSize, widthArbitrary?: undefined } |
|
||||
{ width?: undefined, widthArbitrary: number }
|
||||
@ -46,13 +48,15 @@ export default function ImagePhotoGrid({
|
||||
(rows - 1) * gap / rows;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
{photos.slice(0, count).map(({ id, url }) =>
|
||||
<div
|
||||
key={id}
|
||||
@ -73,6 +77,7 @@ export default function ImagePhotoGrid({
|
||||
IS_PREVIEW,
|
||||
),
|
||||
style: {
|
||||
...imageStyle,
|
||||
width: '100%',
|
||||
...imagePosition === 'center' && {
|
||||
height: '100%',
|
||||
|
||||
@ -2,7 +2,7 @@ import { NextImageSize } from '@/platforms/next-image';
|
||||
import { getDimensionsFromSize } from '@/utility/size';
|
||||
|
||||
export const MAX_PHOTOS_TO_SHOW_OG = 12;
|
||||
export const MAX_PHOTOS_TO_SHOW_PER_TAG = 6;
|
||||
export const MAX_PHOTOS_TO_SHOW_PER_CATEGORY = 6;
|
||||
export const MAX_PHOTOS_TO_SHOW_TEMPLATE = 16;
|
||||
export const MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT = 12;
|
||||
|
||||
|
||||
@ -42,8 +42,8 @@ import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls';
|
||||
import { TbChecklist } from 'react-icons/tb';
|
||||
import { IoCloseSharp } from 'react-icons/io5';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import useRecipeState from '../recipe/useRecipeState';
|
||||
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeGrid';
|
||||
import useRecipeOverlay from '../recipe/useRecipeOverlay';
|
||||
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
|
||||
import PhotoRecipe from '@/recipe/PhotoRecipe';
|
||||
|
||||
export default function PhotoLarge({
|
||||
@ -110,10 +110,10 @@ export default function PhotoLarge({
|
||||
const refRecipeButton = useRef<HTMLButtonElement>(null);
|
||||
const refTriggers = useMemo(() => [refRecipeButton], []);
|
||||
const {
|
||||
shouldShowRecipe,
|
||||
toggleRecipe,
|
||||
hideRecipe,
|
||||
} = useRecipeState({
|
||||
shouldShowRecipeOverlay,
|
||||
toggleRecipeOverlay,
|
||||
hideRecipeOverlay,
|
||||
} = useRecipeOverlay({
|
||||
ref: refRecipe,
|
||||
refTriggers,
|
||||
});
|
||||
@ -193,9 +193,12 @@ export default function PhotoLarge({
|
||||
<div className={clsx(
|
||||
'absolute inset-0',
|
||||
'flex items-center justify-center',
|
||||
(shouldShowRecipeOverlay || shouldDebugRecipeOverlays)
|
||||
? 'z-[1]'
|
||||
: 'z-[-1]',
|
||||
)}>
|
||||
<AnimatePresence>
|
||||
{(shouldShowRecipe || shouldDebugRecipeOverlays) &&
|
||||
{(shouldShowRecipeOverlay || shouldDebugRecipeOverlays) &&
|
||||
photo.recipeData &&
|
||||
photo.filmSimulation &&
|
||||
<PhotoRecipeOverlay
|
||||
@ -204,7 +207,7 @@ export default function PhotoLarge({
|
||||
simulation={photo.filmSimulation}
|
||||
iso={photo.isoFormatted}
|
||||
exposure={photo.exposureCompensationFormatted}
|
||||
onClose={hideRecipe}
|
||||
onClose={hideRecipeOverlay}
|
||||
/>}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@ -214,6 +217,17 @@ export default function PhotoLarge({
|
||||
'flex items-center justify-center aspect-3/2 bg-gray-100',
|
||||
);
|
||||
|
||||
const shouldRenderSimulation = (
|
||||
SHOW_FILM_SIMULATIONS &&
|
||||
showSimulation &&
|
||||
photo.filmSimulation
|
||||
);
|
||||
|
||||
const shouldRenderRecipe = (
|
||||
SHOW_RECIPES &&
|
||||
photo.recipeData
|
||||
);
|
||||
|
||||
return (
|
||||
<SiteGrid
|
||||
containerRef={ref}
|
||||
@ -325,25 +339,14 @@ export default function PhotoLarge({
|
||||
<li>{photo.isoFormatted}</li>
|
||||
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
|
||||
</ul>
|
||||
{(
|
||||
(
|
||||
SHOW_FILM_SIMULATIONS &&
|
||||
showSimulation &&
|
||||
photo.filmSimulation
|
||||
) ||
|
||||
(SHOW_RECIPES && photo.recipeData)
|
||||
) &&
|
||||
{(shouldRenderSimulation || shouldRenderRecipe) &&
|
||||
<div className="flex items-center gap-2 *:w-auto">
|
||||
{(
|
||||
SHOW_FILM_SIMULATIONS &&
|
||||
showSimulation &&
|
||||
photo.filmSimulation
|
||||
) &&
|
||||
{shouldRenderSimulation && photo.filmSimulation &&
|
||||
<PhotoFilmSimulation
|
||||
simulation={photo.filmSimulation}
|
||||
prefetch={prefetchRelatedLinks}
|
||||
/>}
|
||||
{SHOW_RECIPES && photo.recipeData &&
|
||||
{shouldRenderRecipe &&
|
||||
<Tooltip
|
||||
content="Fujifilm Recipe"
|
||||
desktopOnly
|
||||
@ -351,15 +354,16 @@ export default function PhotoLarge({
|
||||
<button
|
||||
ref={refRecipeButton}
|
||||
title="Fujifilm Recipe"
|
||||
onClick={toggleRecipe}
|
||||
onClick={toggleRecipeOverlay}
|
||||
className={clsx(
|
||||
'text-medium',
|
||||
'border-medium rounded-md',
|
||||
'px-[4px] py-[2.5px] my-[-3px]',
|
||||
'translate-y-[1.5px]',
|
||||
'translate-y-[2px]',
|
||||
'hover:bg-dim active:bg-main',
|
||||
!shouldRenderSimulation && 'translate-x-[-2px]',
|
||||
)}>
|
||||
{shouldShowRecipe
|
||||
{shouldShowRecipeOverlay
|
||||
? <IoCloseSharp size={15} />
|
||||
: <TbChecklist
|
||||
className="translate-x-[0.5px]"
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||
import clsx from 'clsx/lite';
|
||||
import { ReactNode, RefObject } from 'react';
|
||||
|
||||
const addSign = (value = 0) => value < 0 ? value : `+${value}`;
|
||||
import { addSign, formatWhiteBalance } from '.';
|
||||
|
||||
export default function PhotoRecipeOGTile({
|
||||
recipe: {
|
||||
recipe,
|
||||
simulation,
|
||||
iso,
|
||||
exposure,
|
||||
}: {
|
||||
ref?: RefObject<HTMLDivElement | null>
|
||||
recipe: FujifilmRecipe
|
||||
simulation: FilmSimulation
|
||||
iso?: string
|
||||
exposure?: string
|
||||
}) {
|
||||
const {
|
||||
dynamicRange,
|
||||
whiteBalance,
|
||||
highISONoiseReduction,
|
||||
@ -24,24 +32,9 @@ export default function PhotoRecipeOGTile({
|
||||
grainEffect,
|
||||
bwAdjustment,
|
||||
bwMagentaGreen,
|
||||
},
|
||||
simulation,
|
||||
iso,
|
||||
exposure,
|
||||
}: {
|
||||
ref?: RefObject<HTMLDivElement | null>
|
||||
recipe: FujifilmRecipe
|
||||
simulation: FilmSimulation
|
||||
iso?: string
|
||||
exposure?: string
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const whiteBalanceTypeFormatted =
|
||||
whiteBalance.type === 'kelvin' && whiteBalance.colorTemperature
|
||||
? `${whiteBalance.colorTemperature}K`
|
||||
: whiteBalance.type
|
||||
.replace(/auto./i, '')
|
||||
.replaceAll('-', ' ');
|
||||
} = recipe;
|
||||
|
||||
const whiteBalanceTypeFormatted = formatWhiteBalance(recipe);
|
||||
|
||||
const renderRow = (children: ReactNode, className?: string) =>
|
||||
<div className={clsx(
|
||||
@ -64,7 +57,7 @@ export default function PhotoRecipeOGTile({
|
||||
label && 'p-1',
|
||||
className,
|
||||
)}>
|
||||
<div className="truncate max-w-full tracking-wide text-lg">
|
||||
<div className="flex truncate max-w-full tracking-wide text-lg">
|
||||
{typeof value === 'number' ? addSign(value) : value}
|
||||
</div>
|
||||
{label && <div className={clsx(
|
||||
|
||||
@ -6,13 +6,20 @@ import clsx from 'clsx/lite';
|
||||
import { ReactNode, RefObject } from 'react';
|
||||
import { IoCloseCircle } from 'react-icons/io5';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RecipeProps } from '.';
|
||||
|
||||
const addSign = (value = 0) => value < 0 ? value : `+${value}`;
|
||||
import { addSign, formatWhiteBalance, RecipeProps } from '.';
|
||||
|
||||
export default function PhotoRecipeOverlay({
|
||||
ref,
|
||||
recipe: {
|
||||
recipe,
|
||||
simulation,
|
||||
iso,
|
||||
exposure,
|
||||
onClose,
|
||||
}: RecipeProps & {
|
||||
ref?: RefObject<HTMLDivElement | null>
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const {
|
||||
dynamicRange,
|
||||
whiteBalance,
|
||||
highISONoiseReduction,
|
||||
@ -27,21 +34,9 @@ export default function PhotoRecipeOverlay({
|
||||
grainEffect,
|
||||
bwAdjustment,
|
||||
bwMagentaGreen,
|
||||
},
|
||||
simulation,
|
||||
iso,
|
||||
exposure,
|
||||
onClose,
|
||||
}: RecipeProps & {
|
||||
ref?: RefObject<HTMLDivElement | null>
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const whiteBalanceTypeFormatted =
|
||||
whiteBalance.type === 'kelvin' && whiteBalance.colorTemperature
|
||||
? `${whiteBalance.colorTemperature}K`
|
||||
: whiteBalance.type
|
||||
.replace(/auto./i, '')
|
||||
.replaceAll('-', ' ');
|
||||
} = recipe;
|
||||
|
||||
const whiteBalanceTypeFormatted = formatWhiteBalance(recipe);
|
||||
|
||||
const renderRow = (children: ReactNode) =>
|
||||
<div className="flex gap-2 *:w-full *:grow">{children}</div>;
|
||||
@ -4,7 +4,7 @@ import { Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import PhotoRecipe from './PhotoRecipe';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { descriptionForRecipePhotos, photoHasRecipe } from '.';
|
||||
import { descriptionForRecipePhotos, getPhotoWithRecipeFromPhotos } from '.';
|
||||
export default function RecipeHeader({
|
||||
recipe,
|
||||
photos,
|
||||
@ -22,9 +22,7 @@ export default function RecipeHeader({
|
||||
}) {
|
||||
const { setRecipeModalProps } = useAppState();
|
||||
|
||||
const photo = photoHasRecipe(selectedPhoto)
|
||||
? selectedPhoto
|
||||
: photos.find(photoHasRecipe);
|
||||
const photo = getPhotoWithRecipeFromPhotos(photos, selectedPhoto);
|
||||
|
||||
return (
|
||||
<PhotoHeader
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import Modal from '@/components/Modal';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import PhotoRecipeOverlay from './PhotoRecipeGrid';
|
||||
import PhotoRecipeOverlay from './PhotoRecipeOverlay';
|
||||
|
||||
export default function ShareModals() {
|
||||
const {
|
||||
|
||||
@ -65,9 +65,17 @@ export const generateMetaForRecipe = (
|
||||
images: absolutePathForRecipeImage(recipe),
|
||||
});
|
||||
|
||||
export const photoHasRecipe = (photo?: Photo) =>
|
||||
const photoHasRecipe = (photo?: Photo) =>
|
||||
photo?.filmSimulation && photo?.recipeData;
|
||||
|
||||
export const getPhotoWithRecipeFromPhotos = (
|
||||
photos: Photo[],
|
||||
preferredPhoto?: Photo,
|
||||
) =>
|
||||
photoHasRecipe(preferredPhoto)
|
||||
? preferredPhoto
|
||||
: photos.find(photoHasRecipe);
|
||||
|
||||
export const sortRecipesWithCount = (recipes: Recipes = []) =>
|
||||
recipes.sort((a, b) => a.recipe.localeCompare(b.recipe));
|
||||
|
||||
@ -78,3 +86,12 @@ export const convertRecipesForForm = (recipes: Recipes = []) =>
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
}));
|
||||
|
||||
export const addSign = (value = 0) => value < 0 ? value : `+${value}`;
|
||||
|
||||
export const formatWhiteBalance = ({ whiteBalance }: FujifilmRecipe) =>
|
||||
whiteBalance.type === 'kelvin' && whiteBalance.colorTemperature
|
||||
? `${whiteBalance.colorTemperature}K`
|
||||
: whiteBalance.type
|
||||
.replace(/auto./i, '')
|
||||
.replaceAll('-', ' ');
|
||||
|
||||
@ -7,7 +7,7 @@ import { RefObject, useCallback, useEffect, useState } from 'react';
|
||||
import { isElementEntirelyInViewport } from '@/utility/dom';
|
||||
import useClickInsideOutside from '@/utility/useClickInsideOutside';
|
||||
|
||||
export default function useRecipeState({
|
||||
export default function useRecipeOverlay({
|
||||
ref,
|
||||
refTriggers = [],
|
||||
}: {
|
||||
@ -21,11 +21,11 @@ export default function useRecipeState({
|
||||
...pathComponents
|
||||
} = getPathComponents(pathname);
|
||||
|
||||
const [shouldShowRecipe, setShouldShowRecipe] = useState(false);
|
||||
const [shouldShowRecipeOverlay, setShouldShowRecipeOverlay] = useState(false);
|
||||
|
||||
const setVisibility = useCallback((shouldShow: boolean) => {
|
||||
if (shouldShow) {
|
||||
setShouldShowRecipe(true);
|
||||
setShouldShowRecipeOverlay(true);
|
||||
// Only add query param for photo details
|
||||
if (photoId) {
|
||||
window.history.pushState(
|
||||
@ -39,7 +39,7 @@ export default function useRecipeState({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setShouldShowRecipe(false);
|
||||
setShouldShowRecipeOverlay(false);
|
||||
// Only remove query param for photo details
|
||||
if (photoId) {
|
||||
window.history.pushState(
|
||||
@ -54,27 +54,29 @@ export default function useRecipeState({
|
||||
}
|
||||
}, [pathComponents, photoId]);
|
||||
|
||||
const showRecipe = useCallback(() => setVisibility(true), [setVisibility]);
|
||||
const hideRecipe = useCallback(() => setVisibility(false), [setVisibility]);
|
||||
const toggleRecipe = useCallback(() =>
|
||||
setVisibility(!shouldShowRecipe),
|
||||
[setVisibility, shouldShowRecipe]);
|
||||
const showRecipeOverlay =
|
||||
useCallback(() => setVisibility(true), [setVisibility]);
|
||||
const hideRecipeOverlay =
|
||||
useCallback(() => setVisibility(false), [setVisibility]);
|
||||
const toggleRecipeOverlay = useCallback(() =>
|
||||
setVisibility(!shouldShowRecipeOverlay),
|
||||
[setVisibility, shouldShowRecipeOverlay]);
|
||||
|
||||
useClickInsideOutside({
|
||||
htmlElements: [ref, ...refTriggers],
|
||||
onClickOutside: hideRecipe,
|
||||
onClickOutside: hideRecipeOverlay,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) {
|
||||
if (shouldShowRecipeOverlay && !isElementEntirelyInViewport(ref?.current)) {
|
||||
ref?.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [ref, shouldShowRecipe]);
|
||||
}, [ref, shouldShowRecipeOverlay]);
|
||||
|
||||
return {
|
||||
shouldShowRecipe,
|
||||
showRecipe,
|
||||
hideRecipe,
|
||||
toggleRecipe,
|
||||
shouldShowRecipeOverlay,
|
||||
showRecipeOverlay,
|
||||
hideRecipeOverlay,
|
||||
toggleRecipeOverlay,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user