Feat filter posts & follow/unfollow user (#485)
* add users api * add user page * update user page * filter posts by user/type * format
This commit is contained in:
@@ -92,7 +92,71 @@ export class Client {
|
||||
return this.api.get("/api/users/rankings", { params: { range } });
|
||||
}
|
||||
|
||||
posts(params?: { page?: number; items?: number }): Promise<
|
||||
users(filter: "following" | "followers" = "followers"): Promise<
|
||||
{
|
||||
users: UserType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/users", { params: { filter } });
|
||||
}
|
||||
|
||||
user(id: string): Promise<UserType> {
|
||||
return this.api.get(`/api/users/${id}`);
|
||||
}
|
||||
|
||||
userFollowing(
|
||||
id: string,
|
||||
options: { page: number }
|
||||
): Promise<
|
||||
{
|
||||
users: UserType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/users/${id}/following`, {
|
||||
params: decamelizeKeys(options),
|
||||
});
|
||||
}
|
||||
|
||||
userFollowers(
|
||||
id: string,
|
||||
options: { page: number }
|
||||
): Promise<
|
||||
{
|
||||
users: UserType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/users/${id}/followers`, {
|
||||
params: decamelizeKeys(options),
|
||||
});
|
||||
}
|
||||
|
||||
follow(id: string): Promise<
|
||||
{
|
||||
user: UserType;
|
||||
} & {
|
||||
following: boolean;
|
||||
}
|
||||
> {
|
||||
return this.api.post(`/api/users/${id}/follow`);
|
||||
}
|
||||
|
||||
unfollow(id: string): Promise<
|
||||
{
|
||||
user: UserType;
|
||||
} & {
|
||||
following: boolean;
|
||||
}
|
||||
> {
|
||||
return this.api.post(`/api/users/${id}/unfollow`);
|
||||
}
|
||||
|
||||
posts(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
userId?: string;
|
||||
type?: "all" | "recording" | "medium" | "story" | "prompt" | "text" | "gpt";
|
||||
by?: "following" | "all";
|
||||
}): Promise<
|
||||
{
|
||||
posts: PostType[];
|
||||
} & PagyResponseType
|
||||
|
||||
@@ -514,5 +514,18 @@
|
||||
"reAnalyze": "re-analyze",
|
||||
"AiDictionary": "AI dictionary",
|
||||
"AiTranslate": "AI translate",
|
||||
"cambridgeDictionary": "Cambridge dictionary"
|
||||
"cambridgeDictionary": "Cambridge dictionary",
|
||||
"following": "following",
|
||||
"followers": "followers",
|
||||
"allUsers": "all users",
|
||||
"allTypes": "all types",
|
||||
"recordingType": "Recording",
|
||||
"mediumType": "Audio/Video",
|
||||
"storyType": "Story",
|
||||
"promptType": "Prompt",
|
||||
"gptType": "GPT",
|
||||
"follow": "follow",
|
||||
"unfollow": "unfollow",
|
||||
"noFollowersYet": "No followers yet",
|
||||
"notFollowingAnyoneYet": "Not following anyone yet"
|
||||
}
|
||||
|
||||
@@ -513,5 +513,18 @@
|
||||
"reAnalyze": "重新分析",
|
||||
"AiDictionary": "智能词典",
|
||||
"AiTranslate": "智能翻译",
|
||||
"cambridgeDictionary": "剑桥词典"
|
||||
"cambridgeDictionary": "剑桥词典",
|
||||
"following": "关注中",
|
||||
"followers": "被关注",
|
||||
"allUsers": "所有用户",
|
||||
"allTypes": "所有类型",
|
||||
"recordingType": "录音",
|
||||
"mediumType": "音视频",
|
||||
"storyType": "文章",
|
||||
"promptType": "提示语",
|
||||
"gptType": "智能助手",
|
||||
"follow": "关注",
|
||||
"unfollow": "取消关注",
|
||||
"noFollowersYet": "还没有人关注",
|
||||
"notFollowingAnyoneYet": "还没有关注任何人"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Avatar, AvatarImage, AvatarFallback } from "@renderer/components/ui";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
import Markdown from "react-markdown";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const PostCard = (props: {
|
||||
post: PostType;
|
||||
@@ -23,12 +24,14 @@ export const PostCard = (props: {
|
||||
<div className="rounded p-4 bg-background space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={post.user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{post.user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Link to={`/users/${post.user.id}`}>
|
||||
<Avatar>
|
||||
<AvatarImage src={post.user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{post.user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="">{post.user.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { PostCard, LoaderSpin } from "@renderer/components";
|
||||
import { toast, Button } from "@renderer/components//ui";
|
||||
import { toast, Button, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@renderer/components//ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const Posts = () => {
|
||||
export const Posts = (props: { userId?: string }) => {
|
||||
const { userId } = props;
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [type, setType] = useState<'all' | 'recording' | 'medium' | 'story' | 'prompt' | 'gpt'>("all");
|
||||
const [by, setBy] = useState<'all' | 'following'>("following");
|
||||
const [posts, setPosts] = useState<PostType[]>([]);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
|
||||
@@ -29,9 +32,16 @@ export const Posts = () => {
|
||||
.posts({
|
||||
page,
|
||||
items: 10,
|
||||
userId,
|
||||
by,
|
||||
type
|
||||
})
|
||||
.then((res) => {
|
||||
setPosts([...posts, ...res.posts]);
|
||||
if (page === 1) {
|
||||
setPosts(res.posts);
|
||||
} else {
|
||||
setPosts([...posts, ...res.posts]);
|
||||
}
|
||||
setNextPage(res.next);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -43,8 +53,8 @@ export const Posts = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
fetchPosts(1);
|
||||
}, [type, by]);
|
||||
|
||||
if (loading) {
|
||||
return <LoaderSpin />;
|
||||
@@ -52,8 +62,36 @@ export const Posts = () => {
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-sm mx-auto">
|
||||
<div className="flex justify-end space-x-4 py-4">
|
||||
{
|
||||
!userId && <Select value={by} onValueChange={(value: 'all' | 'following') => setBy(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="following" value="following">{t('following')}</SelectItem>
|
||||
<SelectItem key="all" value="all">{t('allUsers')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
|
||||
<Select value={type} onValueChange={(value: 'all' | 'recording' | 'medium' | 'story' | 'prompt' | 'gpt') => setType(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="all" value="all">{t('allTypes')}</SelectItem>
|
||||
<SelectItem key="recording" value="recording">{t('recordingType')}</SelectItem>
|
||||
<SelectItem key="prompt" value="prompt">{t('promptType')}</SelectItem>
|
||||
<SelectItem key="gpt" value="gpt">{t('gptType')}</SelectItem>
|
||||
<SelectItem key="medium" value="medium">{t('mediumType')}</SelectItem>
|
||||
<SelectItem key="story" value="story">{t('storyType')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center text-gray-500">{t("noOneSharedYet")}</div>
|
||||
<div className="text-center text-muted-foreground py-4">{t("noOneSharedYet")}</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
218
enjoy/src/renderer/pages/user.tsx
Normal file
218
enjoy/src/renderer/pages/user.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { AppSettingsProviderContext } from "../context";
|
||||
import { LoaderSpin, Posts } from "@renderer/components";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Button, Tabs, TabsContent, TabsList, TabsTrigger } from "@renderer/components/ui";
|
||||
|
||||
export default () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<UserType | null>(null);
|
||||
const { webApi, user: currentUser } = useContext(AppSettingsProviderContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchUser = async () => {
|
||||
if (!id) return;
|
||||
|
||||
webApi.user(id).then((user) => {
|
||||
setUser(user);
|
||||
});
|
||||
}
|
||||
|
||||
const follow = () => {
|
||||
webApi.follow(id).then(() => {
|
||||
setUser({ ...user, following: true });
|
||||
});
|
||||
}
|
||||
|
||||
const unfollow = () => {
|
||||
webApi.unfollow(id).then(() => {
|
||||
setUser({ ...user, following: false });
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
if (!user) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex space-x-1 items-center mb-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Avatar className="w-16 h-16">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
{
|
||||
currentUser.id != user.id && <div className="flex justify-center">{
|
||||
user.following ? <Button variant="link" className="text-destructive" size="sm" onClick={unfollow}>{t('unfollow')}</Button> : <Button size="sm" onClick={follow}>{t('follow')}</Button>
|
||||
}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="max-w-screen-sm mx-auto">
|
||||
<Tabs defaultValue="activities">
|
||||
<div className="w-full flex justify-center">
|
||||
<TabsList>
|
||||
<TabsTrigger value="activities">{t('activities')}</TabsTrigger>
|
||||
<TabsTrigger value="followers"><span className="capitalize">{t('followers')}</span></TabsTrigger>
|
||||
<TabsTrigger value="following"><span className="capitalize">{t('following')}</span></TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="activities">
|
||||
<Posts userId={user.id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="followers"><UserFollowers id={user.id} /></TabsContent>
|
||||
<TabsContent value="following"><UserFollowing id={user.id} /></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserFollowers = (props: { id: string }) => {
|
||||
const { id } = props;
|
||||
const [users, setUsers] = useState<UserType[]>([]);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const fetchFollowers = async () => {
|
||||
if (!page) return;
|
||||
|
||||
webApi.userFollowers(id, { page }).then((res) => {
|
||||
setUsers(res.users);
|
||||
setPage(res.next);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFollowers();
|
||||
return () => {
|
||||
setUsers([]);
|
||||
setPage(1);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (users.length === 0) return <div className="w-full px-4 py-6 text-center text-sm text-muted-foreground">{t("noFollowersYet")}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
{
|
||||
page && (<div className="flex justify-center py-4">
|
||||
<Button onClick={() => fetchFollowers()}>{t("loadMore")}</Button>
|
||||
</div>)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const UserFollowing = (props: { id: string }) => {
|
||||
const { id } = props;
|
||||
const [users, setUsers] = useState<UserType[]>([]);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const fetchFollowing = () => {
|
||||
if (!page) return;
|
||||
|
||||
webApi.userFollowing(id, { page }).then((res) => {
|
||||
setUsers(res.users);
|
||||
setPage(res.next);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFollowing();
|
||||
return () => {
|
||||
setUsers([]);
|
||||
setPage(1);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (users.length === 0) return <div className="w-full px-4 py-6 text-center text-sm text-muted-foreground">{t("notFollowingAnyoneYet")}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
{
|
||||
page && (<div className="flex justify-center py-4">
|
||||
<Button onClick={() => fetchFollowing()}>{t("loadMore")}</Button>
|
||||
</div>)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const UserCard = ({ user }: { user: UserType }) => {
|
||||
const { webApi, user: currentUser } = useContext(AppSettingsProviderContext);
|
||||
const [following, setFollowing] = useState<boolean>(user.following);
|
||||
|
||||
const handleFollow = () => {
|
||||
if (following) {
|
||||
webApi.unfollow(user.id).then(() => {
|
||||
setFollowing(false);
|
||||
});
|
||||
} else {
|
||||
webApi.follow(user.id).then(() => {
|
||||
setFollowing(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 flex items-center space-x-4">
|
||||
<Link to={`/users/${user.id}`}>
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
<div className="">
|
||||
<div className="truncated">{user.name}</div>
|
||||
<div className="text-sm text-muted-foreground">@{user.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
{
|
||||
currentUser.id != user.id && <Button
|
||||
variant={following ? "secondary" : "default"}
|
||||
size="sm"
|
||||
onClick={handleFollow}
|
||||
>
|
||||
{following ? t("unfollow") : t("follow")}
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import Stories from "./pages/stories";
|
||||
import Story from "./pages/story";
|
||||
import Books from "./pages/books";
|
||||
import Profile from "./pages/profile";
|
||||
import User from "./pages/user";
|
||||
import Home from "./pages/home";
|
||||
import Community from "./pages/community";
|
||||
import StoryPreview from "./pages/story-preview";
|
||||
@@ -28,6 +29,10 @@ export default createHashRouter([
|
||||
path: "/community",
|
||||
element: <Community />,
|
||||
},
|
||||
{
|
||||
path: "/users/:id",
|
||||
element: <User />,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
element: <Profile />,
|
||||
|
||||
1
enjoy/src/types/user.d.ts
vendored
1
enjoy/src/types/user.d.ts
vendored
@@ -7,5 +7,6 @@ type UserType = {
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
hasMixin?: boolean;
|
||||
following?: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user