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,148 @@
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {Trans} from '@common/i18n/trans';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {User} from '@common/auth/user';
import {Form} from '@common/ui/forms/form';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {FormImageSelector} from '@common/ui/images/image-selector';
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
import {Option} from '@common/ui/forms/combobox/combobox';
import {useValueLists} from '@common/http/value-lists';
import {useForm} from 'react-hook-form';
import {ProfileLinksForm} from '@app/profile/profile-links-form';
import {
UpdateProfilePayload,
useUpdateUserProfile,
} from '@app/profile/requests/use-update-user-profile';
import {FormSelect} from '@common/ui/forms/select/select';
interface Props {
user: User;
}
export function EditUserProfileDialog({user}: Props) {
const {close, formId} = useDialogContext();
const {data} = useValueLists(['countries']);
const form = useForm<UpdateProfilePayload>({
defaultValues: {
user: {
username: user.username,
avatar: user.avatar,
first_name: user.first_name,
last_name: user.last_name,
},
profile: {
city: user.profile?.city,
country: user.profile?.country,
description: user.profile?.description,
},
links: user.links,
},
});
const updateProfile = useUpdateUserProfile(form);
return (
<Dialog size="xl">
<DialogHeader>
<Trans message="Edit your profile" />
</DialogHeader>
<DialogBody>
<Form
id={formId}
form={form}
onSubmit={values =>
updateProfile.mutate(values, {onSuccess: () => close()})
}
>
<FileUploadProvider>
<div className="md:flex items-start gap-30">
<FormImageSelector
label={<Trans message="Avatar" />}
name="user.avatar"
diskPrefix="avatars"
variant="avatar"
previewSize="w-200 h-200"
className="max-md:mb-20"
/>
<div className="flex-auto">
<FormTextField
name="user.username"
label={<Trans message="Username" />}
className="mb-24"
/>
<div className="flex items-center gap-24">
<FormTextField
name="user.first_name"
label={<Trans message="First name" />}
className="flex-1 mb-24"
/>
<FormTextField
name="user.last_name"
label={<Trans message="Last name" />}
className="flex-1 mb-24"
/>
</div>
<div className="flex items-center gap-24">
<FormTextField
name="profile.city"
label={<Trans message="City" />}
className="flex-1 mb-24"
/>
<FormSelect
showSearchField
className="flex-1 mb-24"
selectionMode="single"
name="profile.country"
label={<Trans message="Country" />}
>
<Option key="none" value={undefined}>
<Trans message="None" />
</Option>
{data?.countries?.map(country => (
<Option key={country.code} value={country.name}>
{country.name}
</Option>
))}
</FormSelect>
</div>
<FormTextField
name="profile.description"
label={<Trans message="Description" />}
inputElementType="textarea"
rows={4}
/>
</div>
</div>
<div className="mt-24">
<div className="mb-16 pb-16 border-b">
<Trans message="Your links" />
</div>
<ProfileLinksForm />
</div>
</FileUploadProvider>
</Form>
</DialogBody>
<DialogFooter>
<Button
type="button"
onClick={() => {
close();
}}
>
<Trans message="Cancel" />
</Button>
<Button
form={formId}
type="submit"
variant="flat"
color="primary"
disabled={updateProfile.isPending}
>
<Trans message="Save" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
import {User} from '@common/auth/user';
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {UserProfileLink} from '@common/users/user-profile-link';
import {UserAvatar} from '@common/ui/images/user-avatar';
import {FollowButton} from '@common/users/follow-button';
interface Props {
follower: User;
}
export function FollowerListItem({follower}: Props) {
return (
<div
key={follower.id}
className="flex items-center gap-16 mb-16 pb-16 border-b"
>
<UserAvatar user={follower} size="lg" />
<div className="text-sm">
<UserProfileLink user={follower} />
{follower.followers_count && follower.followers_count > 0 ? (
<div className="text-xs text-muted">
<Trans
message="[one 1 followers|other :count followers]"
values={{count: follower.followers_count}}
/>
</div>
) : null}
</div>
<FollowButton
variant="outline"
size="xs"
className="ml-auto"
user={follower}
/>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import {UserProfile} from '@app/profile/user-profile';
import clsx from 'clsx';
interface Props {
profile?: UserProfile;
className?: string;
}
export function ProfileDescription({profile, className}: Props) {
if (!profile) return null;
return (
<div className={clsx('text-sm', className)}>
{profile.description && (
<p className="rounded text-secondary whitespace-nowrap overflow-hidden overflow-ellipsis">
{profile.description}
</p>
)}
{profile.city || profile.country ? (
<div className="flex items-center gap-24 justify-between mt-4">
{(profile.city || profile.country) && (
<div className="rounded text-secondary w-max">
{profile.city}
{profile.city && ','} {profile.country}
</div>
)}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,43 @@
import {UserLink} from '@app/profile/user-link';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {RemoteFavicon} from '@common/ui/remote-favicon';
import clsx from 'clsx';
import {ButtonBase} from '@common/ui/buttons/button-base';
import {OpenInNewIcon} from '@common/icons/material/OpenInNew';
interface Props {
links?: UserLink[];
className?: string;
}
export function ProfileLinks({links, className}: Props) {
if (!links?.length) return null;
if (links.length === 1) {
return (
<a
className="flex items-center max-md:justify-center gap-6 mt-24 md:mt-12 hover:text-primary transition-colors"
href={links[0].url}
>
<OpenInNewIcon className="text-muted" size="sm" />
<span className="capitalize">{links[0].title}</span>
</a>
);
}
return (
<div className={clsx('flex items-center', className)}>
{links.map(link => (
<Tooltip label={link.title} key={link.url}>
<ButtonBase
elementType="a"
href={link.url}
target="_blank"
rel="noreferrer"
>
<RemoteFavicon url={link.url} alt={link.title} size="w-20 h-20" />
</ButtonBase>
</Tooltip>
))}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import {UserAvatar} from '@common/ui/images/user-avatar';
import {ProfileDescription} from '@app/profile/header/profile-description';
import {FollowButton} from '@common/users/follow-button';
import React from 'react';
import {useAuth} from '@common/auth/use-auth';
import {User} from '@common/auth/user';
import {Trans} from '@common/i18n/trans';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {Button} from '@common/ui/buttons/button';
import {EditIcon} from '@common/icons/material/Edit';
import {EditUserProfileDialog} from '@app/profile/edit-user-profile-dialog';
import {ProfileStatsList} from '@app/profile/header/profile-stats-list';
import {ProfileLinks} from '@app/profile/header/profile-links';
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
interface Props {
user: User;
}
export function ProfilePageHeader({user}: Props) {
const {user: currentUser} = useAuth();
return (
<div className="flex flex-col md:flex-row items-center gap-24">
<UserAvatar user={user} circle size="w-140 h-140" />
<div className="flex-auto">
<div className="flex items-center mb-8 gap-8">
<h1 className="text-2xl font-bold">{user.display_name}</h1>
{user.is_pro && (
<Chip size="xs" color="primary" radius="rounded" className="mt-2">
<Trans message="PRO" />
</Chip>
)}
</div>
<ProfileDescription profile={user.profile} />
<div className="flex items-center gap-14 mt-12">
{currentUser?.id !== user.id && (
<FollowButton
variant="outline"
color="primary"
size="xs"
user={user}
/>
)}
{currentUser?.id === user.id && <EditButton user={user} />}
</div>
</div>
<div>
<ProfileStatsList user={user} />
<ProfileLinks
links={user.links}
className="flex-shrink-0 ml-auto mt-12"
/>
</div>
</div>
);
}
interface EditButtonProps {
user: User;
}
function EditButton({user}: EditButtonProps) {
return (
<DialogTrigger type="modal">
<Button variant="outline" size="xs" startIcon={<EditIcon />}>
<Trans message="Edit profile" />
</Button>
<EditUserProfileDialog user={user} />
</DialogTrigger>
);
}

View File

@@ -0,0 +1,79 @@
import {User} from '@common/auth/user';
import React, {
Children,
Fragment,
ReactElement,
ReactNode,
useContext,
} from 'react';
import {SiteConfigContext} from '@common/core/settings/site-config-context';
import {Trans} from '@common/i18n/trans';
import {Link} from 'react-router-dom';
import {FormattedNumber} from '@common/i18n/formatted-number';
interface Props {
user: User;
}
export function ProfileStatsList({user}: Props) {
const {
auth: {getUserProfileLink},
} = useContext(SiteConfigContext);
const profileLink = getUserProfileLink!(user);
return (
<StatsItems>
<StatsItem
label={<Trans message="Followers" />}
value={user.followers_count || 0}
link={`${profileLink}/followers`}
/>
<StatsItem
label={<Trans message="Following" />}
value={user.followed_users_count || 0}
link={`${profileLink}/followed-users`}
/>
<StatsItem
label={<Trans message="Lists" />}
value={user.lists_count || 0}
link={`${profileLink}/lists`}
/>
</StatsItems>
);
}
interface StatsItemsProps {
children: ReactNode;
}
function StatsItems(props: StatsItemsProps) {
const children = Children.toArray(props.children);
return (
<div className="flex items-center">
{children.map((child, index) => (
<Fragment key={index}>
{child}
{index < children.length - 1 && (
<div className="mx-10 h-34 w-1 bg-divider" />
)}
</Fragment>
))}
</div>
);
}
interface StatsItemProps {
label: ReactElement;
value: number;
link: string;
}
function StatsItem({label, value, link}: StatsItemProps) {
return (
<Link to={link} className="group block text-center">
<div className="text-lg font-bold">
<FormattedNumber value={value} />
</div>
<div className="text-xs uppercase text-muted transition-colors group-hover:text-primary">
{label}
</div>
</Link>
);
}

View File

@@ -0,0 +1,89 @@
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {Trans} from '@common/i18n/trans';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {PageStatus} from '@common/http/page-status';
import React, {Fragment} from 'react';
import {useUserProfile} from '@app/profile/requests/use-user-profile';
import {RateReviewIcon} from '@common/icons/material/RateReview';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {Title} from '@app/titles/models/title';
import {TitleLink, TitleLinkWithEpisodeNumber} from '@app/titles/title-link';
import {Episode} from '@app/titles/models/episode';
import {useProfileComments} from '@app/profile/requests/use-profile-comments';
import {Comment} from '@common/comments/comment';
import {ThumbUpIcon} from '@common/icons/material/ThumbUp';
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
export function ProfileCommentsPanel() {
const userQuery = useUserProfile();
const user = userQuery.data!.user;
const commentsQuery = useProfileComments();
if (commentsQuery.noResults) {
return (
<IllustratedMessage
imageHeight="h-auto"
imageMargin="mb-14"
image={<RateReviewIcon className="text-muted" />}
size="sm"
title={<Trans message="No comments yet" />}
description={
<Trans
message="Follow :user for updates on comments they post in the future."
values={{user: user.display_name}}
/>
}
/>
);
}
if (commentsQuery.data) {
return (
<Fragment>
{commentsQuery.items.map(comment => (
<CommentListItem key={comment.id} comment={comment} />
))}
<InfiniteScrollSentinel query={commentsQuery} />
</Fragment>
);
}
return <PageStatus query={commentsQuery} />;
}
interface CommentListItemProps {
comment: Comment;
}
function CommentListItem({comment}: CommentListItemProps) {
const commentable = comment.commentable as Title | Episode;
const title =
commentable.model_type === 'episode' ? commentable.title! : commentable;
return (
<div className="mb-24 flex items-start gap-24 border-b pb-24">
<TitlePoster title={title} size="w-90" srcSize="sm" />
<div>
<div className="text-lg font-semibold">
{commentable.model_type === 'episode' ? (
<TitleLinkWithEpisodeNumber
title={title}
episode={commentable}
target="_blank"
/>
) : (
<TitleLink title={title} target="_blank" />
)}
</div>
<time className="mt-12 block text-xs text-muted">
<FormattedRelativeTime date={comment.created_at} />
</time>
<p className="mt-8 whitespace-pre-line text-sm">{comment.content}</p>
{comment.upvotes ? (
<div className="mt-12 flex items-center gap-8 text-muted">
<ThumbUpIcon size="sm" />
<div>{comment.upvotes}</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import {Trans} from '@common/i18n/trans';
import React, {Fragment} from 'react';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {BookmarkBorderIcon} from '@common/icons/material/BookmarkBorder';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {useUserProfile} from '@app/profile/requests/use-user-profile';
import {FollowerListItem} from '@app/profile/follower-list-item';
import {useProfileFollowedUsers} from '@app/profile/requests/use-profile-followed-users';
import {PageStatus} from '@common/http/page-status';
export function ProfileFollowedUsersPanel() {
const userQuery = useUserProfile();
const user = userQuery.data!.user;
const followedUsersQuery = useProfileFollowedUsers();
if (followedUsersQuery.noResults) {
return (
<IllustratedMessage
imageHeight="h-auto"
imageMargin="mb-14"
image={<BookmarkBorderIcon className="text-muted" />}
size="sm"
title={<Trans message="Not following anyone yet" />}
description={
<Trans
message="Check back later to see users :user is following."
values={{user: user.display_name}}
/>
}
/>
);
}
if (followedUsersQuery.data) {
return (
<Fragment>
{followedUsersQuery.items.map(followedUser => (
<FollowerListItem key={followedUser.id} follower={followedUser} />
))}
<InfiniteScrollSentinel query={followedUsersQuery} />
</Fragment>
);
}
return <PageStatus query={followedUsersQuery} />;
}

View File

@@ -0,0 +1,46 @@
import {Trans} from '@common/i18n/trans';
import React, {Fragment} from 'react';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {BookmarkBorderIcon} from '@common/icons/material/BookmarkBorder';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {useUserProfile} from '@app/profile/requests/use-user-profile';
import {useProfileFollowers} from '@app/profile/requests/use-profile-followers';
import {FollowerListItem} from '@app/profile/follower-list-item';
import {PageStatus} from '@common/http/page-status';
export function ProfileFollowersPanel() {
const userQuery = useUserProfile();
const user = userQuery.data!.user;
const followersQuery = useProfileFollowers();
if (followersQuery.noResults) {
return (
<IllustratedMessage
imageHeight="h-auto"
imageMargin="mb-14"
image={<BookmarkBorderIcon className="text-muted" />}
size="sm"
title={<Trans message="No followers yet" />}
description={
<Trans
message="Be the first to follow :name."
values={{name: user.display_name}}
/>
}
/>
);
}
if (followersQuery.data) {
return (
<Fragment>
{followersQuery.items.map(follower => (
<FollowerListItem key={follower.id} follower={follower} />
))}
<InfiniteScrollSentinel query={followersQuery} />
</Fragment>
);
}
return <PageStatus query={followersQuery} />;
}

View File

@@ -0,0 +1,51 @@
import {useProfileLists} from '@app/profile/requests/use-profile-lists';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {ListAltIcon} from '@common/icons/material/ListAlt';
import {Trans} from '@common/i18n/trans';
import {UserListIndexItem} from '@app/user-lists/pages/user-lists-index-page/user-list-index-item';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {PageStatus} from '@common/http/page-status';
import React from 'react';
import {useUserProfile} from '@app/profile/requests/use-user-profile';
export function ProfileListsPanel() {
const userQuery = useUserProfile();
const user = userQuery.data!.user;
const listsQuery = useProfileLists();
if (listsQuery.noResults) {
return (
<IllustratedMessage
imageHeight="h-auto"
imageMargin="mb-14"
image={<ListAltIcon className="text-muted" />}
size="sm"
title={<Trans message="No lists yet" />}
description={
<Trans
message="Follow :user for updates on lists they create in the future."
values={{user: user.display_name}}
/>
}
/>
);
}
if (listsQuery.data) {
return (
<div>
{listsQuery.items.map(list => (
<UserListIndexItem
key={list.id}
list={list}
user={user}
showVisibility={false}
/>
))}
<InfiniteScrollSentinel query={listsQuery} />
</div>
);
}
return <PageStatus query={listsQuery} />;
}

View File

@@ -0,0 +1,69 @@
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {Trans} from '@common/i18n/trans';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {PageStatus} from '@common/http/page-status';
import React, {Fragment} from 'react';
import {StarIcon} from '@common/icons/material/Star';
import {useProfileRatings} from '@app/profile/requests/use-profile-ratings';
import {ContentGridLayout} from '@app/channels/content-grid/content-grid-layout';
import {Title} from '@app/titles/models/title';
import {TitlePortraitGridItem} from '@app/channels/content-grid/title-grid-item';
import {useUserProfile} from '@app/profile/requests/use-user-profile';
import {Episode} from '@app/titles/models/episode';
import {EpisodePortraitGridItem} from '@app/channels/content-grid/episode-grid-item';
export function ProfileRatingsPanel() {
const userQuery = useUserProfile();
const user = userQuery.data!.user;
const ratingsQuery = useProfileRatings();
if (ratingsQuery.noResults) {
return (
<IllustratedMessage
imageHeight="h-auto"
imageMargin="mb-14"
image={<StarIcon className="text-muted" />}
size="sm"
title={<Trans message="No ratings yet" />}
description={
<Trans
message="Follow :user for updates on titles they rate in the future."
values={{user: user.display_name}}
/>
}
/>
);
}
if (ratingsQuery.data) {
return (
<Fragment>
<ContentGridLayout variant="portrait">
{ratingsQuery.items.map(review => {
const reviewable = review.reviewable as Title | Episode;
if (reviewable.model_type === 'episode') {
return (
<EpisodePortraitGridItem
key={review.id}
item={reviewable}
title={reviewable.title!}
rating={review.score}
/>
);
}
return (
<TitlePortraitGridItem
item={review.reviewable as Title}
key={review.id}
rating={review.score}
/>
);
})}
</ContentGridLayout>
<InfiniteScrollSentinel query={ratingsQuery} />
</Fragment>
);
}
return <PageStatus query={ratingsQuery} />;
}

View File

@@ -0,0 +1,93 @@
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {Trans} from '@common/i18n/trans';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {PageStatus} from '@common/http/page-status';
import React, {Fragment} from 'react';
import {useUserProfile} from '@app/profile/requests/use-user-profile';
import {RateReviewIcon} from '@common/icons/material/RateReview';
import {useProfileReviews} from '@app/profile/requests/use-profile-reviews';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {Title} from '@app/titles/models/title';
import {Review} from '@app/titles/models/review';
import {TitleRating} from '@app/reviews/title-rating';
import {TitleLink, TitleLinkWithEpisodeNumber} from '@app/titles/title-link';
import {Episode} from '@app/titles/models/episode';
export function ProfileReviewsPanel() {
const userQuery = useUserProfile();
const user = userQuery.data!.user;
const reviewsQuery = useProfileReviews();
if (reviewsQuery.noResults) {
return (
<IllustratedMessage
imageHeight="h-auto"
imageMargin="mb-14"
image={<RateReviewIcon className="text-muted" />}
size="sm"
title={<Trans message="No reviews yet" />}
description={
<Trans
message="Follow :user for updates on titles they review in the future."
values={{user: user.display_name}}
/>
}
/>
);
}
if (reviewsQuery.data) {
return (
<Fragment>
{reviewsQuery.items.map(review => (
<ReviewListItem key={review.id} review={review} />
))}
<InfiniteScrollSentinel query={reviewsQuery} />
</Fragment>
);
}
return <PageStatus query={reviewsQuery} />;
}
interface ReviewListItemProps {
review: Review;
}
function ReviewListItem({review}: ReviewListItemProps) {
const totalVotes = review.helpful_count + review.not_helpful_count;
const reviewable = review.reviewable as Title | Episode;
const title =
reviewable.model_type === 'episode' ? reviewable.title! : reviewable;
return (
<div className="mb-24 flex items-start gap-24 border-b pb-24">
<TitlePoster title={title} size="w-90" srcSize="sm" />
<div>
<div className="text-lg font-semibold">
{reviewable.model_type === 'episode' ? (
<TitleLinkWithEpisodeNumber
title={title}
episode={reviewable}
target="_blank"
/>
) : (
<TitleLink title={title} target="_blank" />
)}
</div>
<TitleRating className="mb-8 mt-14" score={review.score} />
<div className="text-base font-semibold">{review.title}</div>
<p className="mt-10 whitespace-pre-line text-sm">{review.body}</p>
{totalVotes ? (
<div className="mt-12 text-xs text-muted">
<Trans
message=":helpfulCount out of :total people found this helpful."
values={{
helpfulCount: review.helpful_count,
total: review.helpful_count + review.not_helpful_count,
}}
/>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import {useFieldArray} from 'react-hook-form';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Trans} from '@common/i18n/trans';
import {IconButton} from '@common/ui/buttons/icon-button';
import {CloseIcon} from '@common/icons/material/Close';
import {Button} from '@common/ui/buttons/button';
import {AddIcon} from '@common/icons/material/Add';
import React from 'react';
import {UserLink} from '@app/profile/user-link';
export function ProfileLinksForm() {
const {fields, append, remove} = useFieldArray<{links: UserLink[]}>({
name: 'links',
});
return (
<div>
{fields.map((field, index) => {
return (
<div key={field.id} className="flex gap-10 mb-10 items-end">
<FormTextField
required
type="url"
label={<Trans message="URL" />}
name={`links.${index}.url`}
size="sm"
className="flex-auto"
/>
<FormTextField
required
label={<Trans message="Short title" />}
name={`links.${index}.title`}
size="sm"
className="flex-auto"
/>
<IconButton
size="sm"
color="primary"
className="flex-shrink-0"
onClick={() => {
remove(index);
}}
>
<CloseIcon />
</IconButton>
</div>
);
})}
<Button
variant="text"
color="primary"
startIcon={<AddIcon />}
size="xs"
onClick={() => {
append({url: '', title: ''});
}}
>
<Trans message="Add another link" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {useParams} from 'react-router-dom';
import {Comment} from '@common/comments/comment';
export function useProfileComments() {
const {userId = 'me'} = useParams();
return useInfiniteData<Comment>({
endpoint: `user-profile/${userId}/comments`,
queryKey: ['comment', 'profile-page-comments', userId],
paginate: 'simple',
});
}

View File

@@ -0,0 +1,12 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {useParams} from 'react-router-dom';
import {User} from '@common/auth/user';
export function useProfileFollowedUsers() {
const {userId = 'me'} = useParams();
return useInfiniteData<User>({
endpoint: `users/${userId}/followed-users`,
queryKey: ['users', 'profile-page-followed-users', userId],
paginate: 'simple',
});
}

View File

@@ -0,0 +1,12 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {useParams} from 'react-router-dom';
import {User} from '@common/auth/user';
export function useProfileFollowers() {
const {userId = 'me'} = useParams();
return useInfiniteData<User>({
endpoint: `users/${userId}/followers`,
queryKey: ['users', 'profile-page-followers', userId],
paginate: 'simple',
});
}

View File

@@ -0,0 +1,12 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {useParams} from 'react-router-dom';
import {Channel} from '@common/channels/channel';
export function useProfileLists() {
const {userId = 'me'} = useParams();
return useInfiniteData<Channel>({
endpoint: `user-profile/${userId}/lists`,
queryKey: ['channel', 'profile-lists', userId],
paginate: 'simple',
});
}

View File

@@ -0,0 +1,12 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {useParams} from 'react-router-dom';
import {Review} from '@app/titles/models/review';
export function useProfileRatings() {
const {userId = 'me'} = useParams();
return useInfiniteData<Review>({
endpoint: `user-profile/${userId}/ratings`,
queryKey: ['reviews', 'profile-page-ratings', userId],
paginate: 'simple',
});
}

View File

@@ -0,0 +1,12 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {useParams} from 'react-router-dom';
import {Review} from '@app/titles/models/review';
export function useProfileReviews() {
const {userId = 'me'} = useParams();
return useInfiniteData<Review>({
endpoint: `user-profile/${userId}/reviews`,
queryKey: ['reviews', 'profile-page-reviews', userId],
paginate: 'simple',
});
}

View File

@@ -0,0 +1,54 @@
import {useMutation} from '@tanstack/react-query';
import {useTrans} from '@common/i18n/use-trans';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {apiClient, queryClient} from '@common/http/query-client';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {UseFormReturn} from 'react-hook-form';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useAuth} from '@common/auth/use-auth';
import {UserLink} from '@app/profile/user-link';
import {userProfileQueryKey} from '@app/profile/requests/use-user-profile';
import {User} from '@common/auth/user';
interface Response extends BackendResponse {
user: User;
}
export interface UpdateProfilePayload {
user: {
avatar?: string;
first_name?: string;
last_name?: string;
username?: string;
};
profile: {
city?: string;
country?: string;
description?: string;
};
links: UserLink[];
}
export function useUpdateUserProfile(
form: UseFormReturn<UpdateProfilePayload>,
) {
const {user} = useAuth();
const {trans} = useTrans();
return useMutation({
mutationFn: (payload: UpdateProfilePayload) => updateProfile(payload),
onSuccess: async () => {
if (user) {
await queryClient.invalidateQueries({
queryKey: userProfileQueryKey(user.id),
});
}
toast(trans(message('Profile updated')));
},
onError: err => onFormQueryError(err, form),
});
}
function updateProfile(payload: UpdateProfilePayload): Promise<Response> {
return apiClient.put('user-profile/me', payload).then(r => r.data);
}

View File

@@ -0,0 +1,29 @@
import {useQuery} from '@tanstack/react-query';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {User} from '@common/auth/user';
import {apiClient} from '@common/http/query-client';
import {useParams} from 'react-router-dom';
export interface UseUserProfileResponse extends BackendResponse {
user: User;
}
export const userProfileQueryKey = (userId: number | string) => [
'users',
`${userId}`,
'profile',
];
export function useUserProfile() {
const {userId} = useParams();
return useQuery({
queryKey: userProfileQueryKey(userId!),
queryFn: () => fetchProfile(userId!),
});
}
function fetchProfile(userId: string) {
return apiClient
.get<UseUserProfileResponse>(`user-profile/${userId}`)
.then(response => response.data);
}

View File

@@ -0,0 +1,4 @@
export interface UserLink {
url: string;
title: string;
}

View File

@@ -0,0 +1,88 @@
import {useUserProfile} from '@app/profile/requests/use-user-profile';
import React, {Fragment, useContext} from 'react';
import {PageMetaTags} from '@common/http/page-meta-tags';
import {PageStatus} from '@common/http/page-status';
import {SitePageLayout} from '@app/site-page-layout';
import {User} from '@common/auth/user';
import {Tabs} from '@common/ui/tabs/tabs';
import {TabList} from '@common/ui/tabs/tab-list';
import {Tab} from '@common/ui/tabs/tab';
import {Trans} from '@common/i18n/trans';
import {Link, Outlet, useLocation} from 'react-router-dom';
import {SiteConfigContext} from '@common/core/settings/site-config-context';
import {message} from '@common/i18n/message';
import {ProfilePageHeader} from '@app/profile/header/profile-page-header';
const PageTabs = [
{uri: 'lists', label: message('Lists')},
{uri: 'ratings', label: message('Ratings')},
{uri: 'reviews', label: message('Reviews')},
{uri: 'comments', label: message('Comments')},
{uri: 'followers', label: message('Followers')},
{uri: 'followed-users', label: message('Following')},
];
export function UserProfilePage() {
const query = useUserProfile();
const content = query.data ? (
<Fragment>
<PageMetaTags query={query} />
<PageContent user={query.data.user} />
</Fragment>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
return <SitePageLayout>{content}</SitePageLayout>;
}
interface PageContentProps {
user: User;
}
function PageContent({user}: PageContentProps) {
return (
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
<ProfilePageHeader user={user} />
<ProfileTabs user={user} />
</div>
);
}
interface ProfileTabsProps {
user: User;
}
function ProfileTabs({user}: ProfileTabsProps) {
const {
auth: {getUserProfileLink},
} = useContext(SiteConfigContext);
const profileLink = getUserProfileLink!(user);
const {pathname} = useLocation();
const tabName = pathname.split('/').pop();
let selectedTab = PageTabs.findIndex(tab => tab.uri === tabName);
if (selectedTab === -1) {
selectedTab = 0;
}
return (
<Tabs className="mt-34" selectedTab={selectedTab}>
<TabList>
{PageTabs.map(tab => (
<Tab
key={tab.uri}
width="min-w-132"
elementType={Link}
to={`${profileLink}/${tab.uri}`}
replace
>
<Trans {...tab.label} />
</Tab>
))}
</TabList>
<div className="mt-24">
<Outlet />
</div>
</Tabs>
);
}

View File

@@ -0,0 +1,5 @@
export interface UserProfile {
city: string;
country: string;
description: string;
}