chore: add explore sidebar

This commit is contained in:
Steven 2024-03-29 00:01:45 +08:00
parent 192ee7acc0
commit 90679cc33a
17 changed files with 210 additions and 32 deletions

View file

@ -131,6 +131,8 @@ message ListUsersResponse {
}
message SearchUsersRequest {
// Filter is used to filter users returned in the list.
// Format: "username == frank"
string filter = 1;
}

View file

@ -617,7 +617,7 @@ Used internally for obfuscating the page token.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| filter | [string](#string) | | |
| filter | [string](#string) | | Filter is used to filter users returned in the list. Format: "username == frank" |

View file

@ -303,6 +303,8 @@ type SearchUsersRequest struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Filter is used to filter users returned in the list.
// Format: "username == frank"
Filter string `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"`
}

View file

@ -562,6 +562,9 @@ paths:
$ref: '#/definitions/googlerpcStatus'
parameters:
- name: filter
description: |-
Filter is used to filter users returned in the list.
Format: "username == frank"
in: query
required: false
type: string

View file

@ -60,6 +60,12 @@ func (s *APIV2Service) SearchUsers(ctx context.Context, request *apiv2pb.SearchU
if filter.Username != nil {
userFind.Username = filter.Username
}
if filter.Random {
userFind.Random = true
}
if filter.Limit != nil {
userFind.Limit = filter.Limit
}
users, err := s.Store.ListUsers(ctx, userFind)
if err != nil {
@ -540,10 +546,14 @@ func convertUserRoleToStore(role apiv2pb.User_Role) store.Role {
// SearchUsersFilterCELAttributes are the CEL attributes for SearchUsersFilter.
var SearchUsersFilterCELAttributes = []cel.EnvOption{
cel.Variable("username", cel.StringType),
cel.Variable("random", cel.BoolType),
cel.Variable("limit", cel.IntType),
}
type SearchUsersFilter struct {
Username *string
Random bool
Limit *int
}
func parseSearchUsersFilter(expression string) (*SearchUsersFilter, error) {
@ -572,6 +582,12 @@ func findSearchUsersField(callExpr *expr.Expr_Call, filter *SearchUsersFilter) {
if idExpr.Name == "username" {
username := callExpr.Args[1].GetConstExpr().GetStringValue()
filter.Username = &username
} else if idExpr.Name == "random" {
random := callExpr.Args[1].GetConstExpr().GetBoolValue()
filter.Random = random
} else if idExpr.Name == "limit" {
limit := int(callExpr.Args[1].GetConstExpr().GetInt64Value())
filter.Limit = &limit
}
return
}

View file

@ -2,6 +2,8 @@ package sqlite
import (
"context"
"fmt"
"slices"
"strings"
"github.com/usememos/memos/store"
@ -99,6 +101,11 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
where, args = append(where, "nickname = ?"), append(args, *v)
}
orderBy := []string{"created_ts DESC", "row_status DESC"}
if find.Random {
orderBy = slices.Concat([]string{"RANDOM()"}, orderBy)
}
query := `
SELECT
id,
@ -113,9 +120,11 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
updated_ts,
row_status
FROM user
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC, row_status DESC
`
WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ` + strings.Join(orderBy, ", ")
if v := find.Limit; v != nil {
query += fmt.Sprintf(" LIMIT %d", *v)
}
rows, err := d.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err

View file

@ -82,6 +82,12 @@ type FindUser struct {
Role *Role
Email *string
Nickname *string
// Random and limit are used in list users.
// Whether to return random users.
Random bool
// The maximum number of users to return.
Limit *int
}
type DeleteUser struct {

View file

@ -0,0 +1,23 @@
import classNames from "classnames";
import SearchBar from "@/components/SearchBar";
import UserList from "../UserList";
interface Props {
className?: string;
}
const ExploreSidebar = (props: Props) => {
return (
<aside
className={classNames(
"relative w-full h-auto max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start",
props.className,
)}
>
<SearchBar />
<UserList />
</aside>
);
};
export default ExploreSidebar;

View file

@ -0,0 +1,37 @@
import { Drawer, IconButton } from "@mui/joy";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import Icon from "../Icon";
import ExploreSidebar from "./ExploreSidebar";
const ExploreSidebarDrawer = () => {
const location = useLocation();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [location.pathname]);
const toggleDrawer = (inOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (event.type === "keydown" && ((event as React.KeyboardEvent).key === "Tab" || (event as React.KeyboardEvent).key === "Shift")) {
return;
}
setOpen(inOpen);
};
return (
<>
<IconButton onClick={toggleDrawer(true)}>
<Icon.Search className="w-5 h-auto dark:text-gray-400" />
</IconButton>
<Drawer anchor="right" size="sm" open={open} onClose={toggleDrawer(false)}>
<div className="w-full h-full px-5 bg-zinc-100 dark:bg-zinc-900">
<ExploreSidebar className="py-4" />
</div>
</Drawer>
</>
);
};
export default ExploreSidebarDrawer;

View file

@ -0,0 +1,4 @@
import ExploreSidebar from "./ExploreSidebar";
import ExploreSidebarDrawer from "./ExploreSidebarDrawer";
export { ExploreSidebar, ExploreSidebarDrawer };

View file

@ -1,8 +1,8 @@
import classNames from "classnames";
import PersonalStatistics from "@/components/PersonalStatistics";
import SearchBar from "@/components/SearchBar";
import TagList from "@/components/TagList";
import useCurrentUser from "@/hooks/useCurrentUser";
import PersonalStatistics from "./PersonalStatistics";
import SearchBar from "./SearchBar";
import TagList from "./TagList";
interface Props {
className?: string;

View file

@ -1,8 +1,8 @@
import { Drawer, IconButton } from "@mui/joy";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import Icon from "../Icon";
import HomeSidebar from "./HomeSidebar";
import Icon from "./Icon";
const HomeSidebarDrawer = () => {
const location = useLocation();

View file

@ -0,0 +1,4 @@
import HomeSidebar from "./HomeSidebar";
import HomeSidebarDrawer from "./HomeSidebarDrawer";
export { HomeSidebar, HomeSidebarDrawer };

View file

@ -0,0 +1,47 @@
import { IconButton } from "@mui/joy";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useUserStore } from "@/store/v1";
import { User } from "@/types/proto/api/v2/user_service";
import Icon from "./Icon";
import UserAvatar from "./UserAvatar";
const UserList = () => {
const userStore = useUserStore();
const [users, setUsers] = useState<User[]>([]);
const fetchRecommendUsers = async () => {
const users = await userStore.searchUsers(`random == true && limit == 5`);
setUsers(users);
};
useEffect(() => {
fetchRecommendUsers();
}, []);
return (
<div className="w-full mt-2 flex flex-col p-2 bg-gray-50 dark:bg-black rounded-lg">
<div className="w-full flex flex-row justify-between items-center">
<span className="text-gray-400 font-medium text-sm pl-1">Users</span>
<IconButton size="sm" onClick={fetchRecommendUsers}>
<Icon.RefreshCcw className="text-gray-400 w-4 h-auto" />
</IconButton>
</div>
{users.map((user) => (
<div
key={user.name}
className="w-full flex flex-row justify-start items-center px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-zinc-900 rounded-lg"
>
<Link className="w-full flex flex-row items-center" to={`/u/${encodeURIComponent(user.username)}`} unstable_viewTransition>
<UserAvatar className="mr-2 shrink-0" avatarUrl={user.avatarUrl} />
<div className="w-full flex flex-col justify-center items-start">
<span className="text-gray-600 leading-tight max-w-[80%] truncate dark:text-gray-400">{user.nickname || user.username}</span>
</div>
</Link>
</div>
))}
</div>
);
};
export default UserList;

View file

@ -1,6 +1,8 @@
import { Button } from "@mui/joy";
import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import Empty from "@/components/Empty";
import { ExploreSidebar, ExploreSidebarDrawer } from "@/components/ExploreSidebar";
import Icon from "@/components/Icon";
import MemoFilter from "@/components/MemoFilter";
import MemoView from "@/components/MemoView";
@ -9,11 +11,13 @@ import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { getTimeStampByDate } from "@/helpers/datetime";
import useCurrentUser from "@/hooks/useCurrentUser";
import useFilterWithUrlParams from "@/hooks/useFilterWithUrlParams";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { useMemoList, useMemoStore } from "@/store/v1";
import { useTranslate } from "@/utils/i18n";
const Explore = () => {
const t = useTranslate();
const { md } = useResponsiveWidth();
const user = useCurrentUser();
const memoStore = useMemoStore();
const memoList = useMemoList();
@ -52,29 +56,42 @@ const Explore = () => {
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
<MobileHeader />
<div className="relative w-full h-auto flex flex-col justify-start items-start px-4 sm:px-6">
<MemoFilter className="px-2 pb-2" />
{sortedMemos.map((memo) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} />
))}
{isRequesting ? (
<div className="flex flex-row justify-center items-center w-full my-4 text-gray-400">
<Icon.Loader className="w-4 h-auto animate-spin mr-1" />
<p className="text-sm italic">{t("memo.fetching-data")}</p>
{!md && (
<MobileHeader>
<ExploreSidebarDrawer />
</MobileHeader>
)}
<div className={classNames("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
<div className={classNames(md ? "w-[calc(100%-15rem)]" : "w-full")}>
<div className="flex flex-col justify-start items-start w-full max-w-full">
<MemoFilter className="px-2 pb-2" />
{sortedMemos.map((memo) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned />
))}
{isRequesting ? (
<div className="flex flex-row justify-center items-center w-full my-4 text-gray-400">
<Icon.Loader className="w-4 h-auto animate-spin mr-1" />
<p className="text-sm italic">{t("memo.fetching-data")}</p>
</div>
) : !nextPageTokenRef.current ? (
sortedMemos.length === 0 && (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-2 text-gray-600 dark:text-gray-400">{t("message.no-data")}</p>
</div>
)
) : (
<div className="w-full flex flex-row justify-center items-center my-4">
<Button variant="plain" endDecorator={<Icon.ArrowDown className="w-5 h-auto" />} onClick={fetchMemos}>
{t("memo.fetch-more")}
</Button>
</div>
)}
</div>
) : !nextPageTokenRef.current ? (
sortedMemos.length === 0 && (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-2 text-gray-600 dark:text-gray-400">{t("message.no-data")}</p>
</div>
)
) : (
<div className="w-full flex flex-row justify-center items-center my-4">
<Button variant="plain" endDecorator={<Icon.ArrowDown className="w-5 h-auto" />} onClick={fetchMemos}>
{t("memo.fetch-more")}
</Button>
</div>
{md && (
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
<ExploreSidebar className="py-6" />
</div>
)}
</div>

View file

@ -2,8 +2,7 @@ import { Button } from "@mui/joy";
import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react";
import Empty from "@/components/Empty";
import HomeSidebar from "@/components/HomeSidebar";
import HomeSidebarDrawer from "@/components/HomeSidebarDrawer";
import { HomeSidebar, HomeSidebarDrawer } from "@/components/HomeSidebar";
import Icon from "@/components/Icon";
import MemoEditor from "@/components/MemoEditor";
import showMemoEditorDialog from "@/components/MemoEditor/MemoEditorDialog";

View file

@ -62,6 +62,15 @@ export const useUserStore = create(
set({ userMapByName: userMap });
return user;
},
listUsers: async () => {
const { users } = await userServiceClient.listUsers({});
const userMap = get().userMapByName;
for (const user of users) {
userMap[user.name] = user;
}
set({ userMapByName: userMap });
return users;
},
searchUsers: async (filter: string) => {
const { users } = await userServiceClient.searchUsers({
filter,