mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 02:13:48 +00:00
feat: operator view
This commit is contained in:
@@ -4,29 +4,26 @@
|
||||
.operatorContainer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: white;
|
||||
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.operatorEvents {
|
||||
flex: 1;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@mixin event-block() {
|
||||
height: 40px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin: 0.1rem;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@mixin block() {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
.block {
|
||||
@include event-block();
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { UIEvent, useState } from 'react';
|
||||
import { UIEvent, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SupportedEvent, UserFields } from 'ontime-types';
|
||||
import { getLastEvent } from 'ontime-utils';
|
||||
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import { OPERATOR_OPTIONS } from '../../common/components/view-params-editor/constants';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useOperator } from '../../common/hooks/useSocket';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
|
||||
import FocusBlock from './focus-block/focus-block';
|
||||
import OpBlock from './op-block/OpBlock';
|
||||
import OpEvent from './op-event/OpEvent';
|
||||
import TimeBlock from './time-block/TimeBlock';
|
||||
import { getRundownEnd } from './operator.utils';
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
import OperatorBlock from './operator-block/OperatorBlock';
|
||||
import OperatorEvent from './operator-event/OperatorEvent';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
|
||||
import style from './Operator.module.scss';
|
||||
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
||||
const featureData = useOperator();
|
||||
const [showChild, setShowChild] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Operator';
|
||||
}, []);
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLElement>) => {
|
||||
const scrollThreshold = 50;
|
||||
const scrollPosition = (event.target as HTMLElement).scrollTop;
|
||||
@@ -29,49 +38,45 @@ export default function Operator() {
|
||||
setShowChild(scrollPosition > scrollThreshold);
|
||||
};
|
||||
|
||||
if (!data || status === 'loading') {
|
||||
return <>loading</>;
|
||||
if (!data || status === 'loading' || !userFields || userFieldsStatus === 'loading') {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
// get fields which the user subscribed to
|
||||
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||
const lastEvent = getRundownEnd(data);
|
||||
let eventIndex = 0;
|
||||
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
||||
const lastEvent = getLastEvent(data);
|
||||
|
||||
const operatorOptions = getOperatorOptions(userFields);
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer}>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={OPERATOR_OPTIONS} />
|
||||
<ViewParamsEditor paramFields={operatorOptions} />
|
||||
|
||||
<div className={style.operatorEvents} onScroll={handleScroll}>
|
||||
{data.map((entry) => {
|
||||
if (entry.type === SupportedEvent.Event) {
|
||||
eventIndex += 1;
|
||||
return (
|
||||
<OpEvent
|
||||
<OperatorEvent
|
||||
key={entry.id}
|
||||
index={eventIndex}
|
||||
cue={entry.cue}
|
||||
data={entry}
|
||||
isSelected={featureData.selectedEventId === entry.id}
|
||||
subscribed={subscribe || undefined}
|
||||
subscribed={subscribe}
|
||||
showSeconds={showSeconds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === SupportedEvent.Block) {
|
||||
return (
|
||||
// @arihanv
|
||||
// we prefer moving these wrapper divs to the block
|
||||
// so that the styles and all attributes can be self-contained
|
||||
<div key={entry.id} className={style.block}>
|
||||
<OpBlock data={entry} />
|
||||
</div>
|
||||
);
|
||||
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{showChild && <FocusBlock />}
|
||||
</div>
|
||||
<TimeBlock playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
|
||||
<FollowButton isVisible={showChild} onClickHandler={() => undefined} />
|
||||
<StatusBar playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
|
||||
import style from './focusBlock.module.scss';
|
||||
|
||||
export default function FocusBlock() {
|
||||
const handleClick = () => console.log('click follow');
|
||||
|
||||
// @arihavn, can we find a way to have a single button here, no need for the div
|
||||
return (
|
||||
<div className={style.focusBlock}>
|
||||
<button className={style.focusButton} onClick={handleClick}>
|
||||
<IoLocate size={16} />
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
|
||||
.focusBlock {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: sticky;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.focusButton {
|
||||
margin: 10px;
|
||||
background-color: $blue-500;
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 99px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
.followButton {
|
||||
position: relative;
|
||||
bottom: 17.5vh;
|
||||
margin: 0 auto;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 1rem;
|
||||
background-color: $blue-700;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
border-radius: 99px;
|
||||
transition: bottom 1s;
|
||||
|
||||
&:active {
|
||||
transition: background-color $transition-time-action;
|
||||
background-color: $blue-900;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
transition: bottom 1s;
|
||||
bottom: -0;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.followButton {
|
||||
bottom: 10vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './FollowButton.module.scss';
|
||||
|
||||
interface FollowButtonProps {
|
||||
isVisible: boolean;
|
||||
onClickHandler: () => void;
|
||||
}
|
||||
|
||||
export default function FollowButton(props: FollowButtonProps) {
|
||||
const { isVisible, onClickHandler } = props;
|
||||
|
||||
const classes = cx([style.followButton, !isVisible && style.hidden]);
|
||||
|
||||
return (
|
||||
<button className={classes} onClick={onClickHandler} type='button'>
|
||||
<IoLocate />
|
||||
Follow
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.OpBlock {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { OntimeBlock } from 'ontime-types';
|
||||
|
||||
import style from './OpBlock.module.scss';
|
||||
|
||||
interface OpBlockProps {
|
||||
data: OntimeBlock;
|
||||
}
|
||||
|
||||
export default function OpBlock({ data }: OpBlockProps) {
|
||||
return <div className={style.OpBlock}>{data.title}</div>;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
.scheduledEvent {
|
||||
@include event-block();
|
||||
background-color: $gray-1300;
|
||||
}
|
||||
|
||||
.runningTimer {
|
||||
background-color: $green-700;
|
||||
}
|
||||
|
||||
.cue {
|
||||
font-size: 0.75rem;
|
||||
background-color: $gray-1050; // to override inline
|
||||
padding: 0.2rem;
|
||||
min-width: 4em; // we do not want these to resize
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
|
||||
&:after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02px;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: $orange-500;
|
||||
letter-spacing: 0.02px;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.65rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.time > div {
|
||||
margin-bottom: -4px; /* Adjust the value as needed */
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { OntimeEvent, UserFields } from 'ontime-types';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import style from './OpEvent.module.scss';
|
||||
|
||||
type OpEventProps = {
|
||||
data: OntimeEvent;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
subscribed?: keyof UserFields;
|
||||
};
|
||||
|
||||
function RollingTime() {
|
||||
const timer = useTimer();
|
||||
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||
}
|
||||
|
||||
export default function OpEvent({ data, index, isSelected, subscribed }: OpEventProps) {
|
||||
const start = formatTime(data.timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(data.timeEnd, { format: 'hh:mm' });
|
||||
|
||||
const cueColours = data.colour && getAccessibleColour(data.colour);
|
||||
const subscribedData = (subscribed ? data?.[subscribed] : undefined) || '';
|
||||
|
||||
// @arihanv when selected, the whole row should become green
|
||||
return (
|
||||
<div className={`${isSelected ? style.runningTimer : undefined}`}>
|
||||
<div className={style.scheduledEvent}>
|
||||
<div className={style.cue} style={{ ...cueColours }}>
|
||||
{index}
|
||||
</div>
|
||||
<div className={style.event}>
|
||||
<div className={style.title}>
|
||||
{data.title} - {data.subtitle}
|
||||
</div>
|
||||
<div className={style.time}>
|
||||
<div>
|
||||
{start} - {end}
|
||||
</div>
|
||||
<div className={style.indicator}>
|
||||
<DelayIndicator delayValue={data.delay} />
|
||||
{isSelected ? <RollingTime /> : formatTime(data.duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/** @arihanv we likely want to animate the height of the fields div */}
|
||||
<div className={style.fields}>{subscribedData}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
.block {
|
||||
@include block();
|
||||
background-color: $gray-1350;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import style from './OperatorBlock.module.scss';
|
||||
|
||||
interface OperatorBlockProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function OperatorBlock({ title }: OperatorBlockProps) {
|
||||
return <div className={style.block}>{title}</div>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
.event {
|
||||
@include block();
|
||||
background-color: $gray-1300;
|
||||
|
||||
&.subscribed {
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
&.running {
|
||||
background-color: $red-700;
|
||||
}
|
||||
}
|
||||
|
||||
.titles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.02px;
|
||||
}
|
||||
|
||||
.cue {
|
||||
font-size: 1rem;
|
||||
background-color: $gray-1050; // to override inline
|
||||
padding: 0 0.5rem;
|
||||
text-align: center;
|
||||
border-radius: $component-border-radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: calc(1rem - 1px);
|
||||
|
||||
.runtime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: $orange-500;
|
||||
|
||||
letter-spacing: 0.02px;
|
||||
height: fit-content;
|
||||
|
||||
transition-property: height;
|
||||
transition-duration: $transition-time-feedback;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.times {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { OntimeEvent, UserFields } from 'ontime-types';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
interface OperatorEventProps {
|
||||
data: OntimeEvent;
|
||||
cue: string;
|
||||
isSelected: boolean;
|
||||
subscribed: keyof UserFields | null;
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
||||
// extract this to contain re-renders
|
||||
function RollingTime() {
|
||||
const timer = useTimer();
|
||||
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||
}
|
||||
|
||||
export default function OperatorEvent(props: OperatorEventProps) {
|
||||
const { data, cue, isSelected, subscribed, showSeconds } = props;
|
||||
|
||||
const start = formatTime(data.timeStart, { showSeconds });
|
||||
const end = formatTime(data.timeEnd, { showSeconds });
|
||||
|
||||
const cueColours = data.colour && getAccessibleColour(data.colour);
|
||||
const subscribedData = (subscribed ? data?.[subscribed] : undefined) || '';
|
||||
|
||||
const operatorClasses = cx([
|
||||
style.event,
|
||||
isSelected ? style.running : null,
|
||||
subscribedData ? style.subscribed : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={operatorClasses}>
|
||||
<div className={style.titles}>
|
||||
<span className={style.title}>
|
||||
{data.title} - {data.subtitle}
|
||||
</span>
|
||||
<span className={style.cue} style={{ ...cueColours }}>
|
||||
{cue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={style.times}>
|
||||
<span className={style.schedule}>
|
||||
{start} - {end}
|
||||
</span>
|
||||
<span className={style.runtime}>
|
||||
<DelayIndicator delayValue={data.delay} />
|
||||
{isSelected ? <RollingTime /> : formatTime(data.duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={style.fields}>{subscribedData}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||
|
||||
export function getRundownEnd(rundown: OntimeRundown): OntimeEvent | null {
|
||||
if (rundown.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = rundown.length - 1; i > 0; i--) {
|
||||
if (rundown[i].type === SupportedEvent.Event) {
|
||||
return rundown[i] as OntimeEvent;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
.statusBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-color: $gray-1350;
|
||||
z-index: 2;
|
||||
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid $white-10;
|
||||
box-shadow: $large-bottom-drawer-shadow;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clock {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: gray;
|
||||
}
|
||||
+8
-15
@@ -1,12 +1,13 @@
|
||||
import { OntimeEvent, Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import styles from './TimeBlock.module.scss';
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
export default function TimeBlock({
|
||||
export default function StatusBar({
|
||||
playback,
|
||||
lastEvent,
|
||||
selectedEventId,
|
||||
@@ -23,27 +24,19 @@ export default function TimeBlock({
|
||||
}
|
||||
|
||||
const timeEnd = lastEvent.id === selectedEventId ? timer.expectedFinish : lastEvent.timeEnd;
|
||||
return formatTime(timeEnd, { showSeconds: true, format: 'hh:mm:ss' });
|
||||
return millisToString(timeEnd);
|
||||
};
|
||||
|
||||
// TODO: format should be user defined
|
||||
// use user defined format
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss',
|
||||
});
|
||||
|
||||
const runningTime = formatTime(timer.current, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss',
|
||||
});
|
||||
|
||||
const elapsedTime = formatTime(timer.elapsed, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss',
|
||||
});
|
||||
const runningTime = millisToString(timer.current);
|
||||
const elapsedTime = millisToString(timer.elapsed);
|
||||
|
||||
return (
|
||||
<div className={styles.TimeBlock}>
|
||||
<div className={styles.statusBar}>
|
||||
<PlaybackIcon state={playback} />
|
||||
<div className={styles.clock}>
|
||||
<div className={styles.column}>
|
||||
@@ -1,25 +0,0 @@
|
||||
.TimeBlock {
|
||||
padding: 0.65rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px
|
||||
}
|
||||
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clock {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: gray;
|
||||
}
|
||||
Reference in New Issue
Block a user