Merge pull request #6951 from viown/react-plugins-available
Migrate plugins catalog to React
This commit is contained in:
@@ -9,6 +9,7 @@ import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Link, To } from 'react-router-dom';
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
@@ -16,13 +17,14 @@ interface IProps {
|
||||
text?: string;
|
||||
image?: string | null;
|
||||
icon?: React.ReactNode;
|
||||
to?: To;
|
||||
onClick?: () => void;
|
||||
action?: boolean;
|
||||
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
onActionClick?: () => void;
|
||||
};
|
||||
|
||||
const BaseCard = ({ title, secondaryTitle, text, image, icon, onClick, action, actionRef, onActionClick }: IProps) => {
|
||||
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: IProps) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
@@ -31,11 +33,18 @@ const BaseCard = ({ title, secondaryTitle, text, image, icon, onClick, action, a
|
||||
height: 240
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={onClick} sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
<CardActionArea
|
||||
{...(to && {
|
||||
component: Link,
|
||||
to: to
|
||||
})}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<CardMedia
|
||||
sx={{ flexGrow: 1 }}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<div id="pluginCatalogPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent" data-title="${TabCatalog}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${TabCatalog}</h2>
|
||||
<a is="emby-linkbutton" class="fab" href="#/dashboard/plugins/repositories" style="margin-left:1em;" title="${Settings}">
|
||||
<span class="material-icons settings" aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
|
||||
</div>
|
||||
<div id="noPlugins" class="hide">${MessageNoAvailablePlugins}</div>
|
||||
<div id="pluginTiles" style="text-align:left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,163 +0,0 @@
|
||||
import escapeHTML from 'escape-html';
|
||||
|
||||
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-select/emby-select';
|
||||
|
||||
function reloadList(page) {
|
||||
loading.show();
|
||||
const promise1 = ApiClient.getAvailablePlugins();
|
||||
const promise2 = ApiClient.getInstalledPlugins();
|
||||
Promise.all([promise1, promise2]).then(function (responses) {
|
||||
populateList({
|
||||
catalogElement: page.querySelector('#pluginTiles'),
|
||||
noItemsElement: page.querySelector('#noPlugins'),
|
||||
availablePlugins: responses[0],
|
||||
installedPlugins: responses[1]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getHeaderText(category) {
|
||||
const categoryKey = category.replaceAll(' ', '');
|
||||
|
||||
if (CATEGORY_LABELS[categoryKey]) {
|
||||
return globalize.translate(CATEGORY_LABELS[categoryKey]);
|
||||
}
|
||||
|
||||
console.warn('[AvailablePlugins] unmapped category label', category);
|
||||
return category;
|
||||
}
|
||||
|
||||
function populateList(options) {
|
||||
const availablePlugins = options.availablePlugins;
|
||||
const installedPlugins = options.installedPlugins;
|
||||
|
||||
availablePlugins.forEach(function (plugin, index, array) {
|
||||
plugin.category = plugin.category || 'Other';
|
||||
plugin.categoryDisplayName = getHeaderText(plugin.category);
|
||||
array[index] = plugin;
|
||||
});
|
||||
|
||||
availablePlugins.sort(function (a, b) {
|
||||
if (a.category > b.category) {
|
||||
return 1;
|
||||
} else if (b.category > a.category) {
|
||||
return -1;
|
||||
}
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
} else if (b.name > a.name) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
let currentCategory = null;
|
||||
let html = '';
|
||||
|
||||
for (const plugin of availablePlugins) {
|
||||
const category = plugin.categoryDisplayName;
|
||||
if (category != currentCategory) {
|
||||
if (currentCategory) {
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="verticalSection">';
|
||||
html += '<h2 class="sectionTitle sectionTitle-cards">' + escapeHTML(category) + '</h2>';
|
||||
html += '<div class="itemsContainer vertical-wrap">';
|
||||
currentCategory = category;
|
||||
}
|
||||
html += getPluginHtml(plugin, options, installedPlugins);
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
if (!availablePlugins.length && options.noItemsElement) {
|
||||
options.noItemsElement.classList.remove('hide');
|
||||
}
|
||||
|
||||
const searchBar = document.getElementById('txtSearchPlugins');
|
||||
if (searchBar) {
|
||||
searchBar.addEventListener('input', () => onSearchBarType(searchBar));
|
||||
}
|
||||
|
||||
options.catalogElement.innerHTML = html;
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function onSearchBarType(searchBar) {
|
||||
const filter = searchBar.value.toLowerCase();
|
||||
for (const header of document.querySelectorAll('div .verticalSection')) {
|
||||
// keep track of shown cards after each search
|
||||
let shown = 0;
|
||||
for (const card of header.querySelectorAll('div .card')) {
|
||||
if (filter && filter != '' && !card.textContent.toLowerCase().includes(filter)) {
|
||||
card.style.display = 'none';
|
||||
} else {
|
||||
card.style.display = 'unset';
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
// hide title if no cards are shown
|
||||
if (shown <= 0) {
|
||||
header.style.display = 'none';
|
||||
} else {
|
||||
header.style.display = 'unset';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginHtml(plugin, options, installedPlugins) {
|
||||
let html = '';
|
||||
let href = plugin.externalUrl ? plugin.externalUrl :
|
||||
`#/dashboard/plugins/${plugin.guid}?name=${encodeURIComponent(plugin.name)}`;
|
||||
|
||||
if (options.context) {
|
||||
href += '&context=' + options.context;
|
||||
}
|
||||
|
||||
const target = plugin.externalUrl ? ' target="_blank"' : '';
|
||||
html += "<div class='card backdropCard'>";
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += '<div class="cardContent">';
|
||||
html += `<a class="cardImageContainer" is="emby-linkbutton" style="margin:0;padding:0" href="${href}" ${target}>`;
|
||||
|
||||
if (plugin.imageUrl) {
|
||||
html += `<img src="${escapeHTML(plugin.imageUrl)}" style="width:100%" />`;
|
||||
} else {
|
||||
html += `<div class="cardImage flex align-items-center justify-content-center ${getDefaultBackgroundClass()}">`;
|
||||
html += '<span class="cardImageIcon material-icons extension" aria-hidden="true"></span>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</a>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter">';
|
||||
html += "<div class='cardText'>";
|
||||
html += escapeHTML(plugin.name);
|
||||
html += '</div>';
|
||||
const installedPlugin = installedPlugins.find(installed => installed.Id === plugin.guid);
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += installedPlugin ? globalize.translate('LabelVersionInstalled', installedPlugin.Version) : ' ';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function (view) {
|
||||
view.addEventListener('viewshow', function () {
|
||||
reloadList(this);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export enum QueryKey {
|
||||
ConfigurationPages = 'ConfigurationPages',
|
||||
PackageInfo = 'PackageInfo',
|
||||
Packages = 'Packages',
|
||||
Plugins = 'Plugins',
|
||||
Repositories = 'Repositories'
|
||||
}
|
||||
|
||||
@@ -7,12 +7,24 @@ import type { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const fetchPackageInfo = async (
|
||||
api: Api,
|
||||
params: PackageApiGetPackageInfoRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const packagesData = queryClient.getQueryData([ QueryKey.Packages ]) as PackageInfo[];
|
||||
if (packagesData && params.assemblyGuid) {
|
||||
// Use cached query to avoid re-fetching
|
||||
const pkg = packagesData.find(v => v.guid === params.assemblyGuid);
|
||||
|
||||
if (pkg) {
|
||||
return pkg;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await getPackageApi(api)
|
||||
.getPackageInfo(params, options);
|
||||
return response.data;
|
||||
@@ -24,7 +36,7 @@ const getPackageInfoQuery = (
|
||||
) => queryOptions({
|
||||
// Don't retry since requests for plugins not available in repos fail
|
||||
retry: false,
|
||||
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||
queryKey: [ QueryKey.Packages, params?.name, params?.assemblyGuid ],
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api!, params!, { signal }),
|
||||
enabled: !!params && !!api && !!params.name
|
||||
});
|
||||
|
||||
32
src/apps/dashboard/features/plugins/api/usePackages.ts
Normal file
32
src/apps/dashboard/features/plugins/api/usePackages.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPackages = async (
|
||||
api: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getPackageApi(api)
|
||||
.getPackages(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPackagesQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Packages ],
|
||||
queryFn: ({ signal }) => fetchPackages(api!, { signal }),
|
||||
enabled: !!api,
|
||||
staleTime: 15 * 60 * 1000 // 15 minutes
|
||||
});
|
||||
|
||||
export const usePackages = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPackagesQuery(api));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
type IProps = {
|
||||
pkg: PackageInfo;
|
||||
};
|
||||
|
||||
const PackageCard = ({ pkg }: IProps) => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={pkg.name}
|
||||
image={pkg.imageUrl}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
to={{
|
||||
pathname: `/dashboard/plugins/${pkg.guid}`,
|
||||
search: `?name=${encodeURIComponent(pkg.name || '')}`,
|
||||
hash: location.hash
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageCard;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||
@@ -37,15 +37,17 @@ const PluginCard = ({ plugin, configurationPage }: IProps) => {
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const { api } = useApi();
|
||||
|
||||
const navigateToPluginSettings = useCallback(() => {
|
||||
if (configurationPage) {
|
||||
navigate({
|
||||
pathname: '/configurationpage',
|
||||
search: `?name=${encodeURIComponent(configurationPage.Name || '')}`,
|
||||
hash: location.hash
|
||||
});
|
||||
const pluginPage = useMemo(() => (
|
||||
{
|
||||
pathname: '/configurationpage',
|
||||
search: `?name=${encodeURIComponent(configurationPage?.Name || '')}`,
|
||||
hash: location.hash
|
||||
}
|
||||
}, [ navigate, location, configurationPage ]);
|
||||
), [ location, configurationPage ]);
|
||||
|
||||
const navigateToPluginSettings = useCallback(() => {
|
||||
navigate(pluginPage);
|
||||
}, [ navigate, pluginPage ]);
|
||||
|
||||
const onEnablePlugin = useCallback(() => {
|
||||
if (plugin.Id && plugin.Version) {
|
||||
@@ -105,12 +107,12 @@ const PluginCard = ({ plugin, configurationPage }: IProps) => {
|
||||
<BaseCard
|
||||
title={plugin.Name}
|
||||
secondaryTitle={plugin.Version}
|
||||
to={pluginPage}
|
||||
text={`${globalize.translate('LabelStatus')} ${plugin.Status}`}
|
||||
image={plugin.HasImage ? api?.getUri(`/Plugins/${plugin.Id}/${plugin.Version}/Image`) : null}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onClick={navigateToPluginSettings}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
<Menu
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** A mapping of category names used by the plugin repository to translation keys. */
|
||||
export const CATEGORY_LABELS = {
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
Administration: 'HeaderAdmin',
|
||||
Anime: 'Anime',
|
||||
Authentication: 'LabelAuthProvider', // Legacy
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const getPackageCategories = (packages?: PackageInfo[]) => {
|
||||
if (!packages) return [];
|
||||
|
||||
const categories: string[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (pkg.category && !categories.includes(pkg.category)) {
|
||||
categories.push(pkg.category);
|
||||
}
|
||||
}
|
||||
|
||||
return categories.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
export default getPackageCategories;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const getPackagesByCategory = (packages: PackageInfo[] | undefined, category: string) => {
|
||||
if (!packages) return [];
|
||||
|
||||
return packages
|
||||
.filter(pkg => pkg.category === category)
|
||||
.sort((a, b) => {
|
||||
if (a.name && b.name) {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default getPackagesByCategory;
|
||||
@@ -20,6 +20,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'playback/trickplay', type: AppType.Dashboard },
|
||||
{ path: 'plugins', type: AppType.Dashboard },
|
||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
|
||||
{ path: 'plugins/catalog', type: AppType.Dashboard },
|
||||
{ path: 'plugins/repositories', type: AppType.Dashboard },
|
||||
{ path: 'tasks', type: AppType.Dashboard },
|
||||
{ path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },
|
||||
|
||||
@@ -23,13 +23,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'library',
|
||||
view: 'library.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/catalog',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'plugins/available/index',
|
||||
view: 'plugins/available/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/guide',
|
||||
pageProps: {
|
||||
|
||||
96
src/apps/dashboard/routes/plugins/catalog.tsx
Normal file
96
src/apps/dashboard/routes/plugins/catalog.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { usePackages } from 'apps/dashboard/features/plugins/api/usePackages';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import getPackageCategories from 'apps/dashboard/features/plugins/utils/getPackageCategories';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import getPackagesByCategory from 'apps/dashboard/features/plugins/utils/getPackagesByCategory';
|
||||
import PackageCard from 'apps/dashboard/features/plugins/components/PackageCard';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
|
||||
|
||||
export const Component = () => {
|
||||
const { data: packages, isPending: isPackagesPending } = usePackages();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
|
||||
const filteredPackages = useMemo(() => {
|
||||
return packages?.filter(i => i.name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
|
||||
}, [ packages, searchQuery ]);
|
||||
|
||||
const packageCategories = getPackageCategories(filteredPackages);
|
||||
|
||||
const updateSearchQuery = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}, []);
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const categoryKey = category.replace(/\s/g, '');
|
||||
|
||||
if (CATEGORY_LABELS[categoryKey]) {
|
||||
return globalize.translate(CATEGORY_LABELS[categoryKey]);
|
||||
}
|
||||
|
||||
console.warn('[AvailablePlugins] unmapped category label', category);
|
||||
return category;
|
||||
};
|
||||
|
||||
if (isPackagesPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='pluginCatalogPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
title={globalize.translate('TabCatalog')}
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction='row' gap={1}>
|
||||
<Typography variant='h1'>{globalize.translate('TabCatalog')}</Typography>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
sx={{
|
||||
backgroundColor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={updateSearchQuery}
|
||||
/>
|
||||
|
||||
{packageCategories.map(category => (
|
||||
<Stack key={category} spacing={2}>
|
||||
<Typography variant='h2'>{getCategoryLabel(category)}</Typography>
|
||||
|
||||
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
|
||||
{getPackagesByCategory(filteredPackages, category).map(pkg => (
|
||||
<Grid key={pkg.guid} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'PluginsCatalogPage';
|
||||
Reference in New Issue
Block a user