first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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>
);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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>
);
}

View 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>
);
}