IconGrid: massive refactor including component extraction
This patch extracts InfoPanel from IconGridItem, adds style tweaks to match the spec, and in general reduces prop-drilling by tapping into recoil state for most config, and making use of IconContext to style member icons where appropriate :)
This commit is contained in:
@@ -1,21 +1,28 @@
|
|||||||
.grid {
|
.grid-container {
|
||||||
/* grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); */
|
padding: 0px 16px 8px;
|
||||||
|
/* background-color: #35313D;
|
||||||
|
color: white; */
|
||||||
|
}
|
||||||
|
|
||||||
/* display: grid; */
|
.grid {
|
||||||
/* grid-template-columns: repeat(auto-fill, 160px);
|
/* display: grid;
|
||||||
|
grid-area: auto;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
grid-gap: 10px;
|
grid-gap: 10px;
|
||||||
grid-auto-rows: minmax(160px, auto); */
|
grid-auto-rows: 160px;
|
||||||
|
grid-auto-columns: auto;
|
||||||
|
max-width: 1120px; */
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin: 16px;
|
max-width: 1120px;
|
||||||
|
margin: auto;
|
||||||
/* min-height: 100vh; */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-item {
|
.grid-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -25,20 +32,82 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
/* background-color: rgba(255, 255, 255, 0); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-item:focus {
|
.grid-item:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgb(246, 242, 243);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item p {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #7F7F7F;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 4px;
|
|
||||||
padding: 16px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0px;
|
height: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
padding-right: 10%;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 0 0 2px rgb(0, 0, 0);
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preview {
|
||||||
|
height: 396px;
|
||||||
|
width: 30%;
|
||||||
|
display: flex;
|
||||||
|
text-align: center;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preview p {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-usage {
|
||||||
|
flex: 1;
|
||||||
|
padding: 56px 0px 56px;
|
||||||
|
/* display: flex; */
|
||||||
|
/* flex-direction: column; */
|
||||||
|
/* justify-content: flex-start; */
|
||||||
|
/* vertical-align: middle; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.snippet pre {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
/* overflow-x: auto; */
|
||||||
|
}
|
||||||
|
.snippet button {
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 24px;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button {
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
margin: 0 48px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-list {
|
.empty-list {
|
||||||
|
|||||||
@@ -2,30 +2,30 @@ import React, { useRef, useEffect } from "react";
|
|||||||
import { useRecoilValue } from "recoil";
|
import { useRecoilValue } from "recoil";
|
||||||
import { motion, useAnimation } from "framer-motion";
|
import { motion, useAnimation } from "framer-motion";
|
||||||
import { useWindowSize } from "react-use";
|
import { useWindowSize } from "react-use";
|
||||||
|
import TinyColor from "tinycolor2";
|
||||||
|
import { IconContext, WarningTriangle } from "phosphor-react";
|
||||||
|
|
||||||
import { filteredQueryResultsSelector } from "../../state/selectors";
|
|
||||||
import {
|
import {
|
||||||
iconColorAtom,
|
|
||||||
iconSizeAtom,
|
|
||||||
styleQueryAtom,
|
styleQueryAtom,
|
||||||
|
iconSizeAtom,
|
||||||
|
iconColorAtom,
|
||||||
searchQueryAtom,
|
searchQueryAtom,
|
||||||
} from "../../state/atoms";
|
} from "../../state/atoms";
|
||||||
import "./IconGrid.css";
|
import { filteredQueryResultsSelector } from "../../state/selectors";
|
||||||
|
|
||||||
import GridItem from "./IconGridItem";
|
import GridItem from "./IconGridItem";
|
||||||
import { WarningTriangle, IconProps } from "phosphor-react";
|
import "./IconGrid.css";
|
||||||
|
|
||||||
type IconGridProps = {};
|
type IconGridProps = {};
|
||||||
|
|
||||||
const IconGridAnimated: React.FC<IconGridProps> = () => {
|
const IconGridAnimated: React.FC<IconGridProps> = () => {
|
||||||
const weight = useRecoilValue(styleQueryAtom);
|
const weight = useRecoilValue(styleQueryAtom);
|
||||||
const query = useRecoilValue(searchQueryAtom);
|
|
||||||
const size = useRecoilValue(iconSizeAtom);
|
const size = useRecoilValue(iconSizeAtom);
|
||||||
const color = useRecoilValue(iconColorAtom);
|
const color = useRecoilValue(iconColorAtom);
|
||||||
const iconProps: IconProps = { weight, color, size };
|
const query = useRecoilValue(searchQueryAtom);
|
||||||
|
const isDark = TinyColor(color).isLight();
|
||||||
|
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
const spans = Math.floor((width - 32) / 172);
|
const spans = Math.floor(Math.min(width - 32, 1120) / 168);
|
||||||
|
|
||||||
const filteredQueryResults = useRecoilValue(filteredQueryResultsSelector);
|
const filteredQueryResults = useRecoilValue(filteredQueryResultsSelector);
|
||||||
|
|
||||||
@@ -50,23 +50,30 @@ const IconGridAnimated: React.FC<IconGridProps> = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<IconContext.Provider value={{ weight, size, color, mirrored: false }}>
|
||||||
|
<div
|
||||||
|
className="grid-container"
|
||||||
|
style={{ backgroundColor: isDark ? "#35313D" : "" }}
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="grid"
|
className="grid"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate={controls}
|
animate={controls}
|
||||||
variants={{}}
|
variants={{}}
|
||||||
>
|
>
|
||||||
{filteredQueryResults.map((iconEntry, i) => (
|
{filteredQueryResults.map((iconEntry, index) => (
|
||||||
<GridItem
|
<GridItem
|
||||||
key={i}
|
key={index}
|
||||||
index={i}
|
index={index}
|
||||||
spans={spans}
|
spans={spans}
|
||||||
|
isDark={isDark}
|
||||||
{...iconEntry}
|
{...iconEntry}
|
||||||
{...iconProps}
|
|
||||||
originOffset={originOffset}
|
originOffset={originOffset}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</IconContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
MutableRefObject,
|
MutableRefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useRecoilState } from "recoil";
|
import { useRecoilState } from "recoil";
|
||||||
@@ -9,15 +10,22 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
|
|
||||||
import { iconPreviewOpenAtom } from "../../state/atoms";
|
import { iconPreviewOpenAtom } from "../../state/atoms";
|
||||||
import { IconProps, Icon } from "phosphor-react";
|
import { IconProps, Icon } from "phosphor-react";
|
||||||
|
import InfoPanel from "./InfoPanel";
|
||||||
|
|
||||||
interface IconGridItemProps extends IconProps {
|
interface IconGridItemProps extends IconProps {
|
||||||
index: number;
|
index: number;
|
||||||
|
spans: number;
|
||||||
|
isDark: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
Icon: Icon;
|
Icon: Icon;
|
||||||
originOffset: MutableRefObject<{ top: number; left: number }>;
|
originOffset: MutableRefObject<{ top: number; left: number }>;
|
||||||
spans: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whileTap = { boxShadow: "0 0 0 4px rgb(73, 70, 80)" };
|
||||||
|
const transition = { duration: 0.2 };
|
||||||
|
const originIndex = 0;
|
||||||
|
const delayPerPixel = 0.0004;
|
||||||
|
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: (delayRef: any) => ({
|
visible: (delayRef: any) => ({
|
||||||
@@ -25,30 +33,24 @@ const itemVariants = {
|
|||||||
transition: { delay: delayRef.current },
|
transition: { delay: delayRef.current },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const whileHover = { boxShadow: "0 0 0 2px rgb(0, 0, 0)" };
|
|
||||||
const whileTap = { boxShadow: "0 0 0 4px rgb(139, 0, 139)" };
|
|
||||||
const transition = { duration: 0.2 };
|
|
||||||
const originIndex = 0;
|
|
||||||
const delayPerPixel = 0.0004;
|
|
||||||
|
|
||||||
const infoVariants = {
|
|
||||||
open: { opacity: 1, height: 176, marginTop: 4, marginBottom: 4, padding: 16 },
|
|
||||||
collapsed: {
|
|
||||||
opacity: 0,
|
|
||||||
height: 0,
|
|
||||||
marginTop: 0,
|
|
||||||
marginBottom: 0,
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const IconGridItem: React.FC<IconGridItemProps> = (props) => {
|
const IconGridItem: React.FC<IconGridItemProps> = (props) => {
|
||||||
const { index, spans, originOffset, name, Icon, ...iconProps } = props;
|
const { index, isDark, originOffset, name, Icon } = props;
|
||||||
const [open, setOpen] = useRecoilState(iconPreviewOpenAtom);
|
const [open, setOpen] = useRecoilState(iconPreviewOpenAtom);
|
||||||
|
const isOpen = open === name;
|
||||||
const delayRef = useRef<number>(0);
|
const delayRef = useRef<number>(0);
|
||||||
const offset = useRef({ top: 0, left: 0 });
|
const offset = useRef({ top: 0, left: 0 });
|
||||||
const ref = useRef<any>();
|
const ref = useRef<any>();
|
||||||
|
|
||||||
|
const handleOpen = () => setOpen(isOpen ? false : name);
|
||||||
|
|
||||||
|
const whileHover = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundColor: isDark ? "rgb(73, 70, 80)" : "rgb(246, 242, 243)",
|
||||||
|
}),
|
||||||
|
[isDark]
|
||||||
|
);
|
||||||
|
|
||||||
// The measurement for all elements happens in the layoutEffect cycle
|
// The measurement for all elements happens in the layoutEffect cycle
|
||||||
// This ensures that when we calculate distance in the effect cycle
|
// This ensures that when we calculate distance in the effect cycle
|
||||||
// all elements have already been measured
|
// all elements have already been measured
|
||||||
@@ -64,67 +66,44 @@ const IconGridItem: React.FC<IconGridItemProps> = (props) => {
|
|||||||
if (index === originIndex) {
|
if (index === originIndex) {
|
||||||
originOffset.current = offset.current;
|
originOffset.current = offset.current;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [index, originOffset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dx = Math.abs(offset.current.left - originOffset.current.left);
|
const dx = Math.abs(offset.current.left - originOffset.current.left);
|
||||||
const dy = Math.abs(offset.current.top - originOffset.current.top);
|
const dy = Math.abs(offset.current.top - originOffset.current.top);
|
||||||
const d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
|
const d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
|
||||||
delayRef.current = d * delayPerPixel;
|
delayRef.current = d * delayPerPixel;
|
||||||
}, []);
|
}, [originOffset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="grid-item"
|
className="grid-item"
|
||||||
ref={ref}
|
|
||||||
style={{ order: index }}
|
|
||||||
custom={delayRef}
|
|
||||||
key={name}
|
key={name}
|
||||||
|
ref={ref}
|
||||||
|
tabIndex={0}
|
||||||
|
// onFocus={console.log}
|
||||||
|
style={{
|
||||||
|
order: index,
|
||||||
|
backgroundColor: isDark
|
||||||
|
? "rgba(73, 70, 80, 0)"
|
||||||
|
: "rgba(246, 242, 243, 0)",
|
||||||
|
}}
|
||||||
|
custom={delayRef}
|
||||||
whileHover={whileHover}
|
whileHover={whileHover}
|
||||||
whileTap={whileTap}
|
whileTap={whileTap}
|
||||||
transition={transition}
|
transition={transition}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
onClick={() =>
|
onKeyPress={(e) => e.key === "Enter" && handleOpen()}
|
||||||
setOpen((openName) => (name === openName ? false : name))
|
onClick={handleOpen}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon {...iconProps} />
|
<Icon />
|
||||||
<p>{name}</p>
|
<p>{name}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{open === name && <InfoPanel {...props} />}
|
{isOpen && <InfoPanel {...props} />}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InfoPanel: React.FC<IconGridItemProps> = (props) => {
|
|
||||||
const { index, spans, name, Icon, color, weight } = props;
|
|
||||||
return (
|
|
||||||
<motion.section
|
|
||||||
className="info-box"
|
|
||||||
animate="open"
|
|
||||||
exit="collapsed"
|
|
||||||
variants={infoVariants}
|
|
||||||
style={{ order: index + (spans - (index % spans)) }}
|
|
||||||
>
|
|
||||||
<div style={{ height: "100%" }}>
|
|
||||||
<Icon color={color} weight={weight} size={128} />
|
|
||||||
<p>{name}</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, padding: 32 }}>
|
|
||||||
HTML
|
|
||||||
<pre>{`<i class="ph-${name}${
|
|
||||||
weight === "regular" ? "" : `-${weight}`
|
|
||||||
}"></i>`}</pre>
|
|
||||||
React
|
|
||||||
<pre>{`<${Icon.displayName} ${
|
|
||||||
weight === "regular" ? "" : `weight="${weight}"`
|
|
||||||
}/>`}</pre>
|
|
||||||
</div>
|
|
||||||
</motion.section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IconGridItem;
|
export default IconGridItem;
|
||||||
|
|||||||
134
src/components/IconGrid/InfoPanel.tsx
Normal file
134
src/components/IconGrid/InfoPanel.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useRef } from "react";
|
||||||
|
import { useRecoilValue } from "recoil";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
|
import { styleQueryAtom, iconSizeAtom, iconColorAtom } from "../../state/atoms";
|
||||||
|
import { Icon, ArrowUpRightCircle, Copy } from "phosphor-react";
|
||||||
|
|
||||||
|
const infoVariants = {
|
||||||
|
open: {
|
||||||
|
opacity: 1,
|
||||||
|
height: 396,
|
||||||
|
margin: 4,
|
||||||
|
// transition: { stiffness: 600, damping: 32, duration: 0.2 },
|
||||||
|
},
|
||||||
|
collapsed: {
|
||||||
|
opacity: 0,
|
||||||
|
height: 0,
|
||||||
|
margin: 0,
|
||||||
|
// transition: { stiffness: 600, damping: 32, duration: 0.2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InfoPanelProps {
|
||||||
|
index: number;
|
||||||
|
spans: number;
|
||||||
|
isDark: boolean;
|
||||||
|
name: string;
|
||||||
|
Icon: Icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoPanel: React.FC<InfoPanelProps> = (props) => {
|
||||||
|
const { index, spans, isDark, name, Icon } = props;
|
||||||
|
const weight = useRecoilValue(styleQueryAtom);
|
||||||
|
const size = useRecoilValue(iconSizeAtom);
|
||||||
|
const color = useRecoilValue(iconColorAtom);
|
||||||
|
const ref = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
const htmlString = `<i class="ph-${name}${
|
||||||
|
weight === "regular" ? "" : `-${weight}`
|
||||||
|
}"></i>`;
|
||||||
|
const reactString = `<${Icon.displayName} size={${size}} color="${color}" ${
|
||||||
|
weight === "regular" ? "" : `weight="${weight}" `
|
||||||
|
}/>`;
|
||||||
|
|
||||||
|
const handleCopySnippet = (data: string) => {
|
||||||
|
data && navigator.clipboard.writeText(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadSVG = () => {
|
||||||
|
if (!ref.current?.outerHTML) return;
|
||||||
|
const blob = new Blob([ref.current.outerHTML]);
|
||||||
|
saveAs(blob, `${name}.svg`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopySVG = () => {
|
||||||
|
ref.current && navigator.clipboard.writeText(ref.current.outerHTML);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
className="info-box"
|
||||||
|
animate="open"
|
||||||
|
exit="collapsed"
|
||||||
|
variants={infoVariants}
|
||||||
|
style={{
|
||||||
|
order: index + (spans - (index % spans)),
|
||||||
|
backgroundColor: isDark ? "rgb(73, 70, 80)" : "rgb(246, 242, 243)",
|
||||||
|
color: isDark ? "white" : "black",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="icon-preview">
|
||||||
|
<div>
|
||||||
|
<Icon ref={ref} color={color} weight={weight} size={192} />
|
||||||
|
<p>{name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="icon-usage">
|
||||||
|
<div className="snippet">
|
||||||
|
HTML/CSS
|
||||||
|
<pre style={{ color: "black" }}>
|
||||||
|
{htmlString}
|
||||||
|
<button
|
||||||
|
title="Copy snippet"
|
||||||
|
onClick={() => handleCopySnippet(htmlString)}
|
||||||
|
>
|
||||||
|
<Copy size={24} color="currentColor" weight="regular" />
|
||||||
|
</button>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="snippet">
|
||||||
|
React
|
||||||
|
<pre style={{ color: "black" }}>
|
||||||
|
{reactString}
|
||||||
|
<button
|
||||||
|
title="Copy snippet"
|
||||||
|
onClick={() => handleCopySnippet(reactString)}
|
||||||
|
>
|
||||||
|
<Copy size={24} color="currentColor" weight="regular" />
|
||||||
|
</button>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
style={{ color: isDark ? "white" : "black" }}
|
||||||
|
onClick={handleDownloadSVG}
|
||||||
|
>
|
||||||
|
<ArrowUpRightCircle
|
||||||
|
size={32}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
color="currentColor"
|
||||||
|
weight="regular"
|
||||||
|
/>{" "}
|
||||||
|
Download SVG
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ color: isDark ? "white" : "black" }}
|
||||||
|
onClick={handleCopySVG}
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={32}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
color="currentColor"
|
||||||
|
weight="regular"
|
||||||
|
/>{" "}
|
||||||
|
Copy SVG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoPanel;
|
||||||
Reference in New Issue
Block a user