mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-17 15:53:28 +00:00
optimize_file_details (#6868)
* optimize_file_details * feat: optimize ui * feat: optimize ui * feat: optimize ui --------- Co-authored-by: zheng.shen <zheng.shen@seafile.com> Co-authored-by: 杨国璇 <ygx@Hello-word.local>
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
.file-details-collapse {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details-collapse .file-details-collapse-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 28px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(69, 170, 242, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details-collapse .file-details-collapse-header:hover {
|
||||||
|
background-color: #F0F0F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details-collapse .file-details-collapse-header .file-details-collapse-header-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details-collapse .file-details-collapse-header .file-details-collapse-header-operation {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details-collapse .file-details-collapse-header .file-details-collapse-header-operation:hover {
|
||||||
|
background-color: #DBDBDB;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details-collapse .file-details-collapse-header .sf3-font-down {
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details-collapse .file-details-collapse-body {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const Collapse = ({ className, title, children, isCollapse = true }) => {
|
||||||
|
const [showChildren, setShowChildren] = useState(isCollapse);
|
||||||
|
|
||||||
|
const toggleShowChildren = useCallback(() => {
|
||||||
|
setShowChildren(!showChildren);
|
||||||
|
}, [showChildren]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classnames('file-details-collapse', className)}>
|
||||||
|
<div className="file-details-collapse-header">
|
||||||
|
<div className="file-details-collapse-header-title">{title}</div>
|
||||||
|
<div className="file-details-collapse-header-operation" onClick={toggleShowChildren}>
|
||||||
|
<i className={`sf3-font sf3-font-down ${showChildren ? '' : 'rotate-90'}`}></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showChildren && (
|
||||||
|
<div className="file-details-collapse-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Collapse.propTypes = {
|
||||||
|
isCollapse: PropTypes.bool,
|
||||||
|
className: PropTypes.string,
|
||||||
|
title: PropTypes.string,
|
||||||
|
children: PropTypes.any,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Collapse;
|
@@ -0,0 +1,20 @@
|
|||||||
|
.sf-metadata-property-detail-capture-information-item .dirent-detail-item-name {
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-metadata-property-detail-capture-information-item .dirent-detail-item-value {
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
display: block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-metadata-property-detail-capture-information-item .dirent-detail-item-value:empty::before {
|
||||||
|
content: attr(placeholder);
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
@@ -3,19 +3,65 @@ import PropTypes from 'prop-types';
|
|||||||
import { v4 as uuidV4 } from 'uuid';
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
import { Formatter } from '@seafile/sf-metadata-ui-component';
|
import { Formatter } from '@seafile/sf-metadata-ui-component';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { getDirentPath } from './utils';
|
import { getDirentPath } from '../utils';
|
||||||
import DetailItem from '../detail-item';
|
import DetailItem from '../../detail-item';
|
||||||
import { CellType } from '../../../metadata/constants';
|
import { CellType, GEOLOCATION_FORMAT, PRIVATE_COLUMN_KEY } from '../../../../metadata/constants';
|
||||||
import { gettext } from '../../../utils/constants';
|
import { gettext } from '../../../../utils/constants';
|
||||||
import EditFileTagPopover from '../../popover/edit-filetag-popover';
|
import EditFileTagPopover from '../../../popover/edit-filetag-popover';
|
||||||
import FileTagList from '../../file-tag-list';
|
import FileTagList from '../../../file-tag-list';
|
||||||
import { Utils } from '../../../utils/utils';
|
import { Utils } from '../../../../utils/utils';
|
||||||
import { MetadataDetails, useMetadata } from '../../../metadata';
|
import { MetadataDetails, useMetadata } from '../../../../metadata';
|
||||||
import ObjectUtils from '../../../metadata/utils/object-utils';
|
import ObjectUtils from '../../../../metadata/utils/object-utils';
|
||||||
|
import { getCellValueByColumn, getDateDisplayString, getGeolocationDisplayString,
|
||||||
|
decimalToExposureTime,
|
||||||
|
} from '../../../../metadata/utils/cell';
|
||||||
|
import Collapse from './collapse';
|
||||||
|
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const getImageInfoName = (key) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'Dimensions':
|
||||||
|
return gettext('Dimensions');
|
||||||
|
case 'Device make':
|
||||||
|
return gettext('Device make');
|
||||||
|
case 'Device model':
|
||||||
|
return gettext('Device model');
|
||||||
|
case 'Color space':
|
||||||
|
return gettext('Color space');
|
||||||
|
case 'Capture time':
|
||||||
|
return gettext('Capture time');
|
||||||
|
case 'Focal length':
|
||||||
|
return gettext('Focal length');
|
||||||
|
case 'F number':
|
||||||
|
return gettext('F number');
|
||||||
|
case 'Exposure time':
|
||||||
|
return gettext('Exposure time');
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageInfoValue = (key, value) => {
|
||||||
|
if (!value) return value;
|
||||||
|
switch (key) {
|
||||||
|
case 'Dimensions':
|
||||||
|
return value.replace('x', ' x ');
|
||||||
|
case 'Capture time':
|
||||||
|
return getDateDisplayString(value, 'YYYY-MM-DD HH:mm:ss');
|
||||||
|
case 'Focal length':
|
||||||
|
return value.replace('mm', ' ' + gettext('mm'));
|
||||||
|
case 'Exposure time':
|
||||||
|
return decimalToExposureTime(value) + ' ' + gettext('s');
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const FileDetails = React.memo(({ repoID, repoInfo, dirent, path, direntDetail, onFileTagChanged, repoTags, fileTagList }) => {
|
const FileDetails = React.memo(({ repoID, repoInfo, dirent, path, direntDetail, onFileTagChanged, repoTags, fileTagList }) => {
|
||||||
const [isEditFileTagShow, setEditFileTagShow] = useState(false);
|
const [isEditFileTagShow, setEditFileTagShow] = useState(false);
|
||||||
const { enableMetadata } = useMetadata();
|
const { enableMetadata } = useMetadata();
|
||||||
|
const [record, setRecord] = useState(null);
|
||||||
|
|
||||||
const direntPath = useMemo(() => getDirentPath(dirent, path), [dirent, path]);
|
const direntPath = useMemo(() => getDirentPath(dirent, path), [dirent, path]);
|
||||||
const tagListTitleID = useMemo(() => `detail-list-view-tags-${uuidV4()}`, []);
|
const tagListTitleID = useMemo(() => `detail-list-view-tags-${uuidV4()}`, []);
|
||||||
@@ -32,7 +78,11 @@ const FileDetails = React.memo(({ repoID, repoInfo, dirent, path, direntDetail,
|
|||||||
onFileTagChanged(dirent, direntPath);
|
onFileTagChanged(dirent, direntPath);
|
||||||
}, [dirent, direntPath, onFileTagChanged]);
|
}, [dirent, direntPath, onFileTagChanged]);
|
||||||
|
|
||||||
return (
|
const updateRecord = useCallback((record) => {
|
||||||
|
setRecord(record);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dom = (
|
||||||
<>
|
<>
|
||||||
<DetailItem field={sizeField} className="sf-metadata-property-detail-formatter">
|
<DetailItem field={sizeField} className="sf-metadata-property-detail-formatter">
|
||||||
<Formatter field={sizeField} value={Utils.bytesToSize(direntDetail.size)} />
|
<Formatter field={sizeField} value={Utils.bytesToSize(direntDetail.size)} />
|
||||||
@@ -68,8 +118,47 @@ const FileDetails = React.memo(({ repoID, repoInfo, dirent, path, direntDetail,
|
|||||||
</DetailItem>
|
</DetailItem>
|
||||||
)}
|
)}
|
||||||
{window.app.pageOptions.enableMetadataManagement && enableMetadata && (
|
{window.app.pageOptions.enableMetadataManagement && enableMetadata && (
|
||||||
<MetadataDetails repoID={repoID} filePath={direntPath} repoInfo={repoInfo} direntType="file" />
|
<MetadataDetails repoID={repoID} filePath={direntPath} repoInfo={repoInfo} direntType="file" updateRecord={updateRecord} />
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
let component = dom;
|
||||||
|
if (Utils.imageCheck(dirent.name)) {
|
||||||
|
const fileDetails = getCellValueByColumn(record, { key: PRIVATE_COLUMN_KEY.FILE_DETAILS });
|
||||||
|
const fileDetailsJson = JSON.parse(fileDetails?.slice(9, -7) || '{}');
|
||||||
|
const fileLocation = getCellValueByColumn(record, { key: PRIVATE_COLUMN_KEY.LOCATION });
|
||||||
|
|
||||||
|
component = (
|
||||||
|
<>
|
||||||
|
<Collapse title={gettext('General information')}>
|
||||||
|
{dom}
|
||||||
|
</Collapse>
|
||||||
|
<Collapse title={gettext('Capture information')}>
|
||||||
|
{Object.entries(fileDetailsJson).map(item => {
|
||||||
|
return (
|
||||||
|
<div className="dirent-detail-item sf-metadata-property-detail-capture-information-item" key={item[0]}>
|
||||||
|
<div className="dirent-detail-item-name">{getImageInfoName(item[0])}</div>
|
||||||
|
<div className="dirent-detail-item-value" placeholder={gettext('Empty')}>{getImageInfoValue(item[0], item[1])}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{fileLocation && (
|
||||||
|
<div className="dirent-detail-item sf-metadata-property-detail-capture-information-item" key={'location'}>
|
||||||
|
<div className="dirent-detail-item-name">{gettext('Location')}</div>
|
||||||
|
<div className="dirent-detail-item-value" placeholder={gettext('Empty')}>
|
||||||
|
{getGeolocationDisplayString(fileLocation, { geo_format: GEOLOCATION_FORMAT.LNG_LAT })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{component}
|
||||||
{isEditFileTagShow &&
|
{isEditFileTagShow &&
|
||||||
<EditFileTagPopover
|
<EditFileTagPopover
|
||||||
repoID={repoID}
|
repoID={repoID}
|
@@ -45,7 +45,7 @@ const TreeSection = ({ title, children, moreKey, moreOperations, moreOperationCl
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames('tree-section', { [className]: className })}>
|
<div className={classnames('tree-section', className)}>
|
||||||
<div
|
<div
|
||||||
className={classnames('tree-section-header', { 'tree-section-header-hover': highlight })}
|
className={classnames('tree-section-header', { 'tree-section-header-hover': highlight })}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
|
@@ -17,6 +17,8 @@ export const NOT_DISPLAY_COLUMN_KEYS = [
|
|||||||
PRIVATE_COLUMN_KEY.OBJ_ID,
|
PRIVATE_COLUMN_KEY.OBJ_ID,
|
||||||
PRIVATE_COLUMN_KEY.SIZE,
|
PRIVATE_COLUMN_KEY.SIZE,
|
||||||
PRIVATE_COLUMN_KEY.SUFFIX,
|
PRIVATE_COLUMN_KEY.SUFFIX,
|
||||||
|
PRIVATE_COLUMN_KEY.FILE_DETAILS,
|
||||||
|
PRIVATE_COLUMN_KEY.LOCATION
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SYSTEM_FOLDERS = [
|
export const SYSTEM_FOLDERS = [
|
||||||
|
@@ -16,7 +16,7 @@ import { SYSTEM_FOLDERS } from './constants';
|
|||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const MetadataDetails = ({ repoID, filePath, repoInfo, direntType }) => {
|
const MetadataDetails = ({ repoID, filePath, repoInfo, direntType, updateRecord }) => {
|
||||||
const [isLoading, setLoading] = useState(true);
|
const [isLoading, setLoading] = useState(true);
|
||||||
const [metadata, setMetadata] = useState({ record: {}, fields: [] });
|
const [metadata, setMetadata] = useState({ record: {}, fields: [] });
|
||||||
const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]);
|
const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]);
|
||||||
@@ -37,10 +37,8 @@ const MetadataDetails = ({ repoID, filePath, repoInfo, direntType }) => {
|
|||||||
metadataAPI.getMetadataRecordInfo(repoID, parentDir, fileName).then(res => {
|
metadataAPI.getMetadataRecordInfo(repoID, parentDir, fileName).then(res => {
|
||||||
const { results, metadata } = res.data;
|
const { results, metadata } = res.data;
|
||||||
const record = Array.isArray(results) && results.length > 0 ? results[0] : {};
|
const record = Array.isArray(results) && results.length > 0 ? results[0] : {};
|
||||||
let fields = normalizeFields(metadata).map(field => new Column(field));
|
const fields = normalizeFields(metadata).map(field => new Column(field));
|
||||||
if (!Utils.imageCheck(fileName)) {
|
updateRecord && updateRecord(record);
|
||||||
fields = fields.filter(filed => filed.key !== PRIVATE_COLUMN_KEY.LOCATION);
|
|
||||||
}
|
|
||||||
setMetadata({ record, fields });
|
setMetadata({ record, fields });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -48,7 +46,7 @@ const MetadataDetails = ({ repoID, filePath, repoInfo, direntType }) => {
|
|||||||
toaster.danger(errMessage);
|
toaster.danger(errMessage);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [repoID, filePath, direntType]);
|
}, [repoID, filePath, direntType, updateRecord]);
|
||||||
|
|
||||||
const onChange = useCallback((fieldKey, newValue) => {
|
const onChange = useCallback((fieldKey, newValue) => {
|
||||||
const { record, fields } = metadata;
|
const { record, fields } = metadata;
|
||||||
@@ -132,6 +130,7 @@ MetadataDetails.propTypes = {
|
|||||||
repoInfo: PropTypes.object,
|
repoInfo: PropTypes.object,
|
||||||
direntType: PropTypes.string,
|
direntType: PropTypes.string,
|
||||||
direntDetail: PropTypes.object,
|
direntDetail: PropTypes.object,
|
||||||
|
updateRecord: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MetadataDetails;
|
export default MetadataDetails;
|
||||||
|
@@ -1,6 +1,32 @@
|
|||||||
import { GROUP_GEOLOCATION_GRANULARITY, GEOLOCATION_FORMAT } from '../../../constants';
|
import { GROUP_GEOLOCATION_GRANULARITY, GEOLOCATION_FORMAT } from '../../../constants';
|
||||||
import { isValidPosition } from '../../validate';
|
import { isValidPosition } from '../../validate';
|
||||||
|
|
||||||
|
const _convertLatitudeDecimalToDMS = (latitudeDecimal) => {
|
||||||
|
if (!latitudeDecimal && latitudeDecimal !== 0) return '';
|
||||||
|
if (latitudeDecimal < -90 || latitudeDecimal > 90) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const degrees = Math.floor(Math.abs(latitudeDecimal));
|
||||||
|
const minutesDecimal = (Math.abs(latitudeDecimal) - degrees) * 60;
|
||||||
|
const minutes = Math.floor(minutesDecimal);
|
||||||
|
const seconds = Math.round((minutesDecimal - minutes) * 60);
|
||||||
|
const latitudeNS = latitudeDecimal >= 0 ? 'N' : 'S';
|
||||||
|
return `${latitudeNS}${degrees}°${minutes}'${seconds}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _convertLongitudeDecimalToDMS = (longitudeDecimal) => {
|
||||||
|
if (!longitudeDecimal && longitudeDecimal !== 0) return '';
|
||||||
|
if (longitudeDecimal < -180 || longitudeDecimal > 180) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const degrees = Math.floor(Math.abs(longitudeDecimal));
|
||||||
|
const minutesDecimal = (Math.abs(longitudeDecimal) - degrees) * 60;
|
||||||
|
const minutes = Math.floor(minutesDecimal);
|
||||||
|
const seconds = Math.round((minutesDecimal - minutes) * 60);
|
||||||
|
const longitudeNS = longitudeDecimal >= 0 ? 'E' : 'W';
|
||||||
|
return `${longitudeNS}${degrees}°${minutes}'${seconds}"`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get formatted geolocation
|
* Get formatted geolocation
|
||||||
* @param {object} loc
|
* @param {object} loc
|
||||||
@@ -16,7 +42,9 @@ const getGeolocationDisplayString = (loc, formats, { isBaiduMap = true, hyphen =
|
|||||||
case GEOLOCATION_FORMAT.LNG_LAT: {
|
case GEOLOCATION_FORMAT.LNG_LAT: {
|
||||||
const { lng, lat } = loc;
|
const { lng, lat } = loc;
|
||||||
if (!isValidPosition(lng, lat)) return '';
|
if (!isValidPosition(lng, lat)) return '';
|
||||||
return isBaiduMap ? `${lng}, ${lat}` : `${lat}, ${lng}`;
|
const lngDMS = _convertLongitudeDecimalToDMS(lng);
|
||||||
|
const latDMS = _convertLatitudeDecimalToDMS(lat);
|
||||||
|
return `${latDMS}, ${lngDMS}`;
|
||||||
}
|
}
|
||||||
case GEOLOCATION_FORMAT.COUNTRY_REGION: {
|
case GEOLOCATION_FORMAT.COUNTRY_REGION: {
|
||||||
const { country_region } = loc;
|
const { country_region } = loc;
|
||||||
|
@@ -6,6 +6,7 @@ export {
|
|||||||
formatStringToNumber,
|
formatStringToNumber,
|
||||||
formatTextToNumber,
|
formatTextToNumber,
|
||||||
getFloatNumber,
|
getFloatNumber,
|
||||||
|
decimalToExposureTime,
|
||||||
} from './number';
|
} from './number';
|
||||||
export {
|
export {
|
||||||
checkIsPredefinedOption,
|
checkIsPredefinedOption,
|
||||||
|
@@ -281,6 +281,15 @@ const formatTextToNumber = (value) => {
|
|||||||
return isNaN(newData) ? null : newData;
|
return isNaN(newData) ? null : newData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decimalToExposureTime = (decimal) => {
|
||||||
|
if (!decimal) return 0;
|
||||||
|
const integerPart = Math.floor(decimal);
|
||||||
|
const decimalPart = decimal - integerPart;
|
||||||
|
if (integerPart > 0) return integerPart;
|
||||||
|
const denominator = Math.round(1 / decimalPart);
|
||||||
|
return '1/' + denominator;
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getPrecisionNumber,
|
getPrecisionNumber,
|
||||||
getNumberDisplayString,
|
getNumberDisplayString,
|
||||||
@@ -288,4 +297,5 @@ export {
|
|||||||
formatStringToNumber,
|
formatStringToNumber,
|
||||||
formatTextToNumber,
|
formatTextToNumber,
|
||||||
getFloatNumber,
|
getFloatNumber,
|
||||||
|
decimalToExposureTime,
|
||||||
};
|
};
|
||||||
|
@@ -32,6 +32,7 @@ export {
|
|||||||
getFloatNumber,
|
getFloatNumber,
|
||||||
getColumnOptionNamesByIds,
|
getColumnOptionNamesByIds,
|
||||||
getColumnOptionIdsByNames,
|
getColumnOptionIdsByNames,
|
||||||
|
decimalToExposureTime,
|
||||||
} from './column';
|
} from './column';
|
||||||
|
|
||||||
export { isCellValueChanged } from './cell-comparer';
|
export { isCellValueChanged } from './cell-comparer';
|
||||||
|
Reference in New Issue
Block a user