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:
an-lee
2024-04-04 11:52:09 +08:00
committed by GitHub
parent 5470b814a5
commit 0a10909dcc
8 changed files with 370 additions and 15 deletions

View File

@@ -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

View File

@@ -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"
}

View File

@@ -513,5 +513,18 @@
"reAnalyze": "重新分析",
"AiDictionary": "智能词典",
"AiTranslate": "智能翻译",
"cambridgeDictionary": "剑桥词典"
"cambridgeDictionary": "剑桥词典",
"following": "关注中",
"followers": "被关注",
"allUsers": "所有用户",
"allTypes": "所有类型",
"recordingType": "录音",
"mediumType": "音视频",
"storyType": "文章",
"promptType": "提示语",
"gptType": "智能助手",
"follow": "关注",
"unfollow": "取消关注",
"noFollowersYet": "还没有人关注",
"notFollowingAnyoneYet": "还没有关注任何人"
}

View File

@@ -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">

View File

@@ -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">

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

View File

@@ -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 />,

View File

@@ -7,5 +7,6 @@ type UserType = {
recordingsCount?: number;
recordingsDuration?: number;
hasMixin?: boolean;
following?: boolean;
createdAt?: string;
};