148
resources/client/profile/edit-user-profile-dialog.tsx
Executable file
148
resources/client/profile/edit-user-profile-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
37
resources/client/profile/follower-list-item.tsx
Executable file
37
resources/client/profile/follower-list-item.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
29
resources/client/profile/header/profile-description.tsx
Executable file
29
resources/client/profile/header/profile-description.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
43
resources/client/profile/header/profile-links.tsx
Executable file
43
resources/client/profile/header/profile-links.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
69
resources/client/profile/header/profile-page-header.tsx
Executable file
69
resources/client/profile/header/profile-page-header.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
79
resources/client/profile/header/profile-stats-list.tsx
Executable file
79
resources/client/profile/header/profile-stats-list.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
89
resources/client/profile/panels/profile-comments-panel.tsx
Executable file
89
resources/client/profile/panels/profile-comments-panel.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
46
resources/client/profile/panels/profile-followed-users-panel.tsx
Executable file
46
resources/client/profile/panels/profile-followed-users-panel.tsx
Executable 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} />;
|
||||
}
|
||||
46
resources/client/profile/panels/profile-followers-panel.tsx
Executable file
46
resources/client/profile/panels/profile-followers-panel.tsx
Executable 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} />;
|
||||
}
|
||||
51
resources/client/profile/panels/profile-lists-panel.tsx
Executable file
51
resources/client/profile/panels/profile-lists-panel.tsx
Executable 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} />;
|
||||
}
|
||||
69
resources/client/profile/panels/profile-ratings-panel.tsx
Executable file
69
resources/client/profile/panels/profile-ratings-panel.tsx
Executable 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} />;
|
||||
}
|
||||
93
resources/client/profile/panels/profile-reviews-panel.tsx
Executable file
93
resources/client/profile/panels/profile-reviews-panel.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
61
resources/client/profile/profile-links-form.tsx
Executable file
61
resources/client/profile/profile-links-form.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/profile/requests/use-profile-comments.ts
Executable file
12
resources/client/profile/requests/use-profile-comments.ts
Executable 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',
|
||||
});
|
||||
}
|
||||
12
resources/client/profile/requests/use-profile-followed-users.ts
Executable file
12
resources/client/profile/requests/use-profile-followed-users.ts
Executable 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',
|
||||
});
|
||||
}
|
||||
12
resources/client/profile/requests/use-profile-followers.ts
Executable file
12
resources/client/profile/requests/use-profile-followers.ts
Executable 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',
|
||||
});
|
||||
}
|
||||
12
resources/client/profile/requests/use-profile-lists.ts
Executable file
12
resources/client/profile/requests/use-profile-lists.ts
Executable 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',
|
||||
});
|
||||
}
|
||||
12
resources/client/profile/requests/use-profile-ratings.ts
Executable file
12
resources/client/profile/requests/use-profile-ratings.ts
Executable 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',
|
||||
});
|
||||
}
|
||||
12
resources/client/profile/requests/use-profile-reviews.ts
Executable file
12
resources/client/profile/requests/use-profile-reviews.ts
Executable 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',
|
||||
});
|
||||
}
|
||||
54
resources/client/profile/requests/use-update-user-profile.ts
Executable file
54
resources/client/profile/requests/use-update-user-profile.ts
Executable 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);
|
||||
}
|
||||
29
resources/client/profile/requests/use-user-profile.ts
Executable file
29
resources/client/profile/requests/use-user-profile.ts
Executable 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);
|
||||
}
|
||||
4
resources/client/profile/user-link.ts
Executable file
4
resources/client/profile/user-link.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export interface UserLink {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
88
resources/client/profile/user-profile-page.tsx
Executable file
88
resources/client/profile/user-profile-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
5
resources/client/profile/user-profile.ts
Executable file
5
resources/client/profile/user-profile.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
export interface UserProfile {
|
||||
city: string;
|
||||
country: string;
|
||||
description: string;
|
||||
}
|
||||
Reference in New Issue
Block a user