feat: add profile friend view

This commit is contained in:
Kentai Radiquum 2025-04-04 14:15:47 +05:00
parent 0730b7c7d4
commit 256ecea885
Signed by: Radiquum
GPG key ID: 858E8EE696525EED
5 changed files with 441 additions and 59 deletions

View file

@ -19,6 +19,14 @@ export const ENDPOINTS = {
history: `${API_PREFIX}/history`, history: `${API_PREFIX}/history`,
favorite: `${API_PREFIX}/favorite`, favorite: `${API_PREFIX}/favorite`,
blocklist: `${API_PREFIX}/profile/blocklist`, blocklist: `${API_PREFIX}/profile/blocklist`,
friend: {
list: `${API_PREFIX}/profile/friend/all`,
add: `${API_PREFIX}/profile/friend/request/send`,
remove: `${API_PREFIX}/profile/friend/request/remove`,
hide: `${API_PREFIX}/profile/friend/request/hide`,
in: `${API_PREFIX}/profile/friend/requests/in`,
out: `${API_PREFIX}/profile/friend/requests/out`,
},
settings: { settings: {
my: `${API_PREFIX}/profile/preference/my`, my: `${API_PREFIX}/profile/preference/my`,
login: { login: {

View file

@ -15,6 +15,8 @@ export function ProfileActivity(props: {
collectionPreview: any; collectionPreview: any;
friendsCount: number; friendsCount: number;
friendsPreview: any; friendsPreview: any;
token: string;
isMyProfile: boolean;
}) { }) {
const [tab, setTab] = useState< const [tab, setTab] = useState<
"collections" | "comments" | "friends" | "videos" "collections" | "comments" | "friends" | "videos"
@ -91,7 +93,14 @@ export function ProfileActivity(props: {
/> />
)} )}
{tab == "comments" && <>comments</>} {tab == "comments" && <>comments</>}
{tab == "friends" && <ProfileActivityFriends content={props.friendsPreview || []} />} {tab == "friends" && (
<ProfileActivityFriends
token={props.token}
content={props.friendsPreview || []}
isMyProfile={props.isMyProfile}
profile_id={props.profile_id}
/>
)}
{tab == "videos" && <>videos</>} {tab == "videos" && <>videos</>}
</Card> </Card>
); );

View file

@ -4,67 +4,84 @@ import "swiper/css/navigation";
import "swiper/css/mousewheel"; import "swiper/css/mousewheel";
import "swiper/css/scrollbar"; import "swiper/css/scrollbar";
import { Navigation, Mousewheel, Scrollbar } from "swiper/modules"; import { Navigation, Mousewheel, Scrollbar } from "swiper/modules";
import { CollectionLink } from "../CollectionLink/CollectionLink";
import Link from "next/link"; import Link from "next/link";
import { Avatar, Button } from "flowbite-react"; import { Avatar, Button } from "flowbite-react";
import { useState } from "react";
import { ProfileFriendModal } from "./Profile.FriendsModal";
export const ProfileActivityFriends = (props: {
content: any;
token: string;
isMyProfile: boolean;
profile_id: number;
}) => {
const [isFriendModalOpen, setIsFriendModalOpen] = useState(false);
export const ProfileActivityFriends = (props: { content: any }) => {
return ( return (
<div className="max-w-full"> <>
<Swiper <div className="max-w-full">
modules={[Navigation, Mousewheel, Scrollbar]} <Swiper
spaceBetween={8} modules={[Navigation, Mousewheel, Scrollbar]}
slidesPerView={"auto"} spaceBetween={8}
direction={"horizontal"} slidesPerView={"auto"}
mousewheel={{ direction={"horizontal"}
enabled: true, mousewheel={{
sensitivity: 4, enabled: true,
}} sensitivity: 4,
scrollbar={{ }}
enabled: true, scrollbar={{
draggable: true, enabled: true,
}} draggable: true,
allowTouchMove={true} }}
style={ allowTouchMove={true}
{ style={
"--swiper-scrollbar-bottom": "0", {
} as React.CSSProperties "--swiper-scrollbar-bottom": "0",
} } as React.CSSProperties
> }
{props.content && >
props.content.length > 0 && {props.content &&
props.content.map((profile) => { props.content.length > 0 &&
return ( props.content.map((profile) => {
<SwiperSlide return (
key={`friend-prev-${profile.id}`} <SwiperSlide
style={{ width: "fit-content" }} key={`friend-prev-${profile.id}`}
className="px-2 py-4" style={{ width: "fit-content" }}
> className="px-2 py-4"
<Link href={`/profile/${profile.id}`}> >
<div className="flex items-center gap-2"> <Link href={`/profile/${profile.id}`}>
<Avatar <div className="flex items-center gap-2">
img={profile.avatar} <Avatar
size="md" img={profile.avatar}
rounded={true} size="md"
bordered={true} rounded={true}
color={profile.is_online ? "success" : "light"} bordered={true}
className="flex-shrink-0" color={profile.is_online ? "success" : "light"}
/> className="flex-shrink-0"
<p className="text-lg">{profile.login}</p> />
</div> <p className="text-lg">{profile.login}</p>
</Link> </div>
</SwiperSlide> </Link>
); </SwiperSlide>
})} );
{props.content && props.content.length > 0 ? })}
<SwiperSlide style={{ width: "fit-content" }} className="px-2 py-4"> {(props.content && props.content.length > 0) || props.isMyProfile ?
<Button> <SwiperSlide style={{ width: "fit-content" }} className="px-2 py-4">
<p className="text-lg">Все друзья</p> <Button onClick={() => setIsFriendModalOpen(true)}>
<span className="w-8 h-8 iconify mdi--arrow-right dark:fill-white"></span> <p className="text-lg">Все друзья {props.isMyProfile ? "и заявки" : ""}</p>
</Button> <span className="w-8 h-8 iconify mdi--arrow-right dark:fill-white"></span>
</SwiperSlide> </Button>
: <p className="text-lg">У пользователя нет друзей</p>} </SwiperSlide>
</Swiper> : <p className="text-lg">У пользователя нет друзей</p>}
</div> </Swiper>
</div>
<ProfileFriendModal
isOpen={isFriendModalOpen}
setIsOpen={setIsFriendModalOpen}
token={props.token}
isMyProfile={props.isMyProfile}
profile_id={props.profile_id}
></ProfileFriendModal>
</>
); );
}; };

View file

@ -0,0 +1,346 @@
import { ENDPOINTS } from "#/api/config";
import { tryCatchAPI, unixToDate, useSWRfetcher } from "#/api/utils";
import {
Avatar,
Button,
Modal,
ModalHeader,
useThemeMode,
} from "flowbite-react";
import { useCallback, useEffect, useState } from "react";
import useSWRInfinite from "swr/infinite";
import { Spinner } from "../Spinner/Spinner";
import { toast } from "react-toastify";
import useSWR, { mutate } from "swr";
import Link from "next/link";
export const ProfileFriendModal = (props: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
token: string;
isMyProfile: boolean;
profile_id: number;
}) => {
const [currentRef, setCurrentRef] = useState<any>(null);
const theme = useThemeMode();
const [actionsDisabled, setActionsDisabled] = useState(false);
// const [requestInUsers, setRequestInUsers] = useState([]);
// const [requestOutUsers, setRequestOutUsers] = useState([]);
const [friends, setFriends] = useState([]);
const modalRef = useCallback((ref) => {
setCurrentRef(ref);
}, []);
const useFetchRequests = (url: string) => {
const { data, error, isLoading } = useSWR(url, useSWRfetcher);
return [data, error, isLoading];
};
const [requestInUsersData, requestInUsersError, requestInUsersIsLoading] =
useFetchRequests(
props.isMyProfile ?
`${ENDPOINTS.user.friend.in}/last?token=${props.token}&count=8`
: ""
);
const [requestOutUsersData, requestOutUsersError, requestOutUsersIsLoading] =
useFetchRequests(
props.isMyProfile ?
`${ENDPOINTS.user.friend.out}/last?token=${props.token}&count=8`
: ""
);
async function _hideRequestIn(profile_id) {
const tid = toast.loading("Скрываем заявку...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
let url = `${ENDPOINTS.user.friend.hide}/${profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: "Ошибка скрытия заявки",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return;
}
toast.update(tid, {
render: "Заявка скрыта",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(`${ENDPOINTS.user.friend.in}/last?token=${props.token}&count=8`);
}
async function _acceptRequestIn(profile_id) {
const tid = toast.loading("Принимаем запрос...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
let url = `${ENDPOINTS.user.friend.add}/${profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: "Ошибка приёма запроса",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return;
}
toast.update(tid, {
render: "Запрос принят",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(`${ENDPOINTS.user.friend.in}/last?token=${props.token}&count=8`);
}
async function _cancelRequestOut(profile_id) {
const tid = toast.loading("Отменяем запрос...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
let url = `${ENDPOINTS.user.friend.remove}/${profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: "Ошибка отмена запроса",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return;
}
toast.update(tid, {
render: "Запрос отменён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(`${ENDPOINTS.user.friend.out}/last?token=${props.token}&count=8`);
}
const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null;
let url = `${ENDPOINTS.user.friend.list}/${props.profile_id}/${pageIndex}?token=${props.token}`;
return url;
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
useSWRfetcher,
{ initialSize: 2 }
);
useEffect(() => {
if (data) {
let allFriends = [];
for (let i = 0; i < data.length; i++) {
allFriends.push(...data[i].content);
}
setFriends(allFriends);
}
}, [data]);
const [scrollPosition, setScrollPosition] = useState(0);
function handleScroll() {
const height = currentRef.scrollHeight - currentRef.clientHeight;
const windowScroll = currentRef.scrollTop;
const scrolled = (windowScroll / height) * 100;
setScrollPosition(Math.floor(scrolled));
}
useEffect(() => {
if (scrollPosition >= 95 && scrollPosition <= 96) {
setSize(size + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]);
return (
<>
<Modal
dismissible
show={props.isOpen}
onClose={() => props.setIsOpen(false)}
size={"4xl"}
>
<ModalHeader>Друзья</ModalHeader>
<div
className="flex flex-col gap-4 p-4 overflow-y-auto"
onScroll={handleScroll}
ref={modalRef}
>
{props.isMyProfile && (
<>
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Входящие заявки</p>
{(
requestInUsersData &&
requestInUsersData.content &&
requestInUsersData.content.length > 0
) ?
requestInUsersData.content.map((user) => {
return (
<div
className="flex items-center justify-between gap-2"
key={`friend_req_in-${user.id}`}
>
<Link href={`/profile/${user.id}`}>
<div className="flex gap-2">
<Avatar
alt=""
img={user.avatar}
rounded={true}
size={"md"}
bordered={true}
color={user.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold">
{user.login}
</p>
<p>Друзей: {user.friend_count}</p>
</div>
</div>
</Link>
<div className="flex gap-2">
<Button
color="blue"
onClick={() => _acceptRequestIn(user.id)}
>
Принять
</Button>
<Button
color="light"
onClick={() => _hideRequestIn(user.id)}
>
Скрыть
</Button>
</div>
</div>
);
})
: <p className="text-sm">Нет входящих заявок</p>}
</div>
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Исходящие заявки</p>
{(
requestOutUsersData &&
requestOutUsersData.content &&
requestOutUsersData.content.length > 0
) ?
requestOutUsersData.content.map((user) => {
return (
<div
className="flex items-center justify-between gap-2"
key={`friend_req_out-${user.id}`}
>
<Link href={`/profile/${user.id}`}>
<div className="flex gap-2">
<Avatar
alt=""
img={user.avatar}
rounded={true}
size={"md"}
bordered={true}
color={user.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold">
{user.login}
</p>
<p>Друзей: {user.friend_count}</p>
</div>
</div>
</Link>
<div className="flex gap-2">
<Button
color="light"
onClick={() => _cancelRequestOut(user.id)}
>
Отменить
</Button>
</div>
</div>
);
})
: <p className="text-sm">Нет исходящих заявок</p>}
</div>
</>
)}
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Все друзья</p>
{friends && friends.length > 0 ?
friends.map((user) => {
return (
<div
className="flex items-center justify-between gap-2"
key={`friend-${user.id}`}
>
<Link href={`/profile/${user.id}`}>
<div className="flex gap-2">
<Avatar
alt=""
img={user.avatar}
rounded={true}
size={"md"}
bordered={true}
color={user.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold">{user.login}</p>
<p>Друзей: {user.friend_count}</p>
</div>
</div>
</Link>
</div>
);
})
: <p className="text-sm">Нет друзей</p>}
</div>
{isLoading && <Spinner />}
</div>
</Modal>
</>
);
};

View file

@ -134,6 +134,8 @@ export const ProfilePage = (props: any) => {
collectionPreview={user.collections_preview || []} collectionPreview={user.collections_preview || []}
friendsCount={user.friend_count} friendsCount={user.friend_count}
friendsPreview={user.friends_preview || []} friendsPreview={user.friends_preview || []}
token={authUser.token}
isMyProfile={isMyProfile || false}
/> />
)} )}
{!user.is_stats_hidden && ( {!user.is_stats_hidden && (