60
common/resources/client/users/follow-button.tsx
Executable file
60
common/resources/client/users/follow-button.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import {Button, ButtonProps} from '@common/ui/buttons/button';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {User} from '@common/auth/user';
|
||||
import {useIsUserFollowing} from '@common/users/queries/use-followed-users';
|
||||
import {useFollowUser} from '@common/users/queries/use-follow-user';
|
||||
import {useUnfollowUser} from '@common/users/queries/use-unfollow-user';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props extends Omit<ButtonProps, 'onClick' | 'disabled'> {
|
||||
user: User;
|
||||
minWidth?: string | null;
|
||||
}
|
||||
export function FollowButton({
|
||||
user,
|
||||
className,
|
||||
minWidth = 'min-w-82',
|
||||
...buttonProps
|
||||
}: Props) {
|
||||
const {user: currentUser} = useAuth();
|
||||
const {isFollowing, isLoading} = useIsUserFollowing(user);
|
||||
const followUser = useFollowUser();
|
||||
const unfollowUser = useUnfollowUser();
|
||||
|
||||
const mergedClassName = clsx(className, minWidth);
|
||||
|
||||
if (isFollowing) {
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
className={mergedClassName}
|
||||
onClick={() => unfollowUser.mutate({user})}
|
||||
disabled={
|
||||
!currentUser ||
|
||||
currentUser?.id === user.id ||
|
||||
unfollowUser.isPending ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
<Trans message="Unfollow" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
className={mergedClassName}
|
||||
onClick={() => followUser.mutate({user})}
|
||||
disabled={
|
||||
!currentUser ||
|
||||
currentUser?.id === user.id ||
|
||||
followUser.isPending ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
<Trans message="Follow" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
28
common/resources/client/users/queries/use-follow-user.ts
Executable file
28
common/resources/client/users/queries/use-follow-user.ts
Executable file
@@ -0,0 +1,28 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {User} from '@common/auth/user';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function useFollowUser() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => followUser(payload),
|
||||
onSuccess: async (response, {user}) => {
|
||||
await queryClient.invalidateQueries({queryKey: ['users']});
|
||||
toast(message('Following :name', {values: {name: user.display_name}}));
|
||||
},
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function followUser({user}: Payload): Promise<Response> {
|
||||
return apiClient.post(`users/${user.id}/follow`).then(r => r.data);
|
||||
}
|
||||
32
common/resources/client/users/queries/use-followed-users.ts
Executable file
32
common/resources/client/users/queries/use-followed-users.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {User} from '@common/auth/user';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export function useFollowedUsers() {
|
||||
const {user} = useAuth();
|
||||
return useQuery({
|
||||
queryKey: ['users', 'followed', 'ids'],
|
||||
queryFn: () => fetchIds(),
|
||||
enabled: !!user,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIsUserFollowing(user: User) {
|
||||
const {data, isLoading} = useFollowedUsers();
|
||||
return {
|
||||
isLoading,
|
||||
isFollowing: !!data?.ids.includes(user.id),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchIds() {
|
||||
return apiClient
|
||||
.get<Response>(`users/me/followed-users/ids`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
27
common/resources/client/users/queries/use-normalized-model.ts
Executable file
27
common/resources/client/users/queries/use-normalized-model.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {NormalizedModel} from '../../datatable/filters/normalized-model';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
model: NormalizedModel;
|
||||
}
|
||||
|
||||
export function useNormalizedModel(
|
||||
endpoint: string,
|
||||
queryParams?: Record<string, string>,
|
||||
queryOptions?: {enabled?: boolean},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [endpoint, queryParams],
|
||||
queryFn: () => fetchModel(endpoint, queryParams),
|
||||
...queryOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchModel(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
return apiClient.get(endpoint, {params}).then(r => r.data);
|
||||
}
|
||||
38
common/resources/client/users/queries/use-normalized-models.ts
Executable file
38
common/resources/client/users/queries/use-normalized-models.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import {NormalizedModel} from '../../datatable/filters/normalized-model';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
results: NormalizedModel[];
|
||||
}
|
||||
|
||||
interface Params {
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
with?: string;
|
||||
}
|
||||
|
||||
export function useNormalizedModels(
|
||||
endpoint: string,
|
||||
queryParams: Params,
|
||||
queryOptions?: Omit<
|
||||
UseQueryOptions<Response, unknown, Response, [string, Params]>,
|
||||
'queryKey' | 'queryFn'
|
||||
> | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [endpoint, queryParams],
|
||||
queryFn: () => fetchModels(endpoint, queryParams),
|
||||
placeholderData: keepPreviousData,
|
||||
...queryOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchModels(endpoint: string, params: Params) {
|
||||
return apiClient.get<Response>(endpoint, {params}).then(r => r.data);
|
||||
}
|
||||
30
common/resources/client/users/queries/use-unfollow-user.ts
Executable file
30
common/resources/client/users/queries/use-unfollow-user.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {User} from '@common/auth/user';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function useUnfollowUser() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => unfollowUser(payload),
|
||||
onSuccess: async (response, {user}) => {
|
||||
await queryClient.invalidateQueries({queryKey: ['users']});
|
||||
toast(
|
||||
message('Stopped following :name', {values: {name: user.display_name}}),
|
||||
);
|
||||
},
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function unfollowUser({user}: Payload): Promise<Response> {
|
||||
return apiClient.post(`users/${user.id}/unfollow`).then(r => r.data);
|
||||
}
|
||||
108
common/resources/client/users/select-user-dialog.tsx
Executable file
108
common/resources/client/users/select-user-dialog.tsx
Executable file
@@ -0,0 +1,108 @@
|
||||
import {Dialog} from '../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '../i18n/trans';
|
||||
import {DialogBody} from '../ui/overlays/dialog/dialog-body';
|
||||
import {TextField} from '../ui/forms/input-field/text-field/text-field';
|
||||
import {SearchIcon} from '../icons/material/Search';
|
||||
import {useState} from 'react';
|
||||
import {useTrans} from '../i18n/use-trans';
|
||||
import {message} from '../i18n/message';
|
||||
import {Avatar} from '../ui/images/avatar';
|
||||
import {NormalizedModel} from '../datatable/filters/normalized-model';
|
||||
import {IllustratedMessage} from '../ui/images/illustrated-message';
|
||||
import {SvgImage} from '../ui/images/svg-image/svg-image';
|
||||
import teamSvg from '../admin/roles/team.svg';
|
||||
import {useDialogContext} from '../ui/overlays/dialog/dialog-context';
|
||||
import {useNormalizedModels} from './queries/use-normalized-models';
|
||||
|
||||
interface SelectUserDialogProps {
|
||||
onUserSelected: (user: NormalizedModel) => void;
|
||||
}
|
||||
|
||||
export function SelectUserDialog({onUserSelected}: SelectUserDialogProps) {
|
||||
const {close} = useDialogContext();
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const {trans} = useTrans();
|
||||
const query = useNormalizedModels('normalized-models/user', {
|
||||
query: searchTerm,
|
||||
perPage: 14,
|
||||
});
|
||||
const users = query.data?.results || [];
|
||||
|
||||
const emptyStateMessage = (
|
||||
<IllustratedMessage
|
||||
className="pt-20"
|
||||
size="sm"
|
||||
title={<Trans message="No matching users" />}
|
||||
description={<Trans message="Try another search query" />}
|
||||
image={<SvgImage src={teamSvg} />}
|
||||
/>
|
||||
);
|
||||
|
||||
const selectUser = (user: NormalizedModel) => {
|
||||
close();
|
||||
onUserSelected(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Select a user" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<TextField
|
||||
autoFocus
|
||||
className="mb-20"
|
||||
startAdornment={<SearchIcon />}
|
||||
placeholder={trans(message('Search for user by name or email'))}
|
||||
value={searchTerm}
|
||||
onChange={e => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{!query.isLoading && !users.length && emptyStateMessage}
|
||||
<div className="grid grid-cols-2 gap-x-10">
|
||||
{users.map(user => (
|
||||
<UserListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
onUserSelected={selectUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserListItemProps {
|
||||
user: NormalizedModel;
|
||||
onUserSelected: (user: NormalizedModel) => void;
|
||||
}
|
||||
function UserListItem({user, onUserSelected}: UserListItemProps) {
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-10 rounded p-10 outline-none ring-offset-4 hover:bg-hover focus-visible:ring"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onUserSelected(user);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onUserSelected(user);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar src={user.image} />
|
||||
<div className="overflow-hidden">
|
||||
<div className="overflow-hidden text-ellipsis">{user.name}</div>
|
||||
<div className="overflow-hidden text-ellipsis text-muted">
|
||||
{user.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
common/resources/client/users/user-profile-link.tsx
Executable file
30
common/resources/client/users/user-profile-link.tsx
Executable file
@@ -0,0 +1,30 @@
|
||||
import {Link, LinkProps} from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import React, {useContext, useMemo} from 'react';
|
||||
import {User} from '@common/auth/user';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
interface UserProfileLinkProps extends Omit<LinkProps, 'to'> {
|
||||
user: User;
|
||||
className?: string;
|
||||
}
|
||||
export function UserProfileLink({
|
||||
user,
|
||||
className,
|
||||
...linkProps
|
||||
}: UserProfileLinkProps) {
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
const finalUri = useMemo(() => {
|
||||
return auth.getUserProfileLink!(user);
|
||||
}, [auth, user]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...linkProps}
|
||||
className={clsx('hover:underline', className)}
|
||||
to={finalUri}
|
||||
>
|
||||
{user.display_name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user