diff --git a/proto/api/v2/user_service.proto b/proto/api/v2/user_service.proto index d5558e53..4a5dec8d 100644 --- a/proto/api/v2/user_service.proto +++ b/proto/api/v2/user_service.proto @@ -131,6 +131,8 @@ message ListUsersResponse { } message SearchUsersRequest { + // Filter is used to filter users returned in the list. + // Format: "username == frank" string filter = 1; } diff --git a/proto/gen/api/v2/README.md b/proto/gen/api/v2/README.md index 1d432bda..0a11250b 100644 --- a/proto/gen/api/v2/README.md +++ b/proto/gen/api/v2/README.md @@ -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" | diff --git a/proto/gen/api/v2/user_service.pb.go b/proto/gen/api/v2/user_service.pb.go index 1ab48a83..80c0572e 100644 --- a/proto/gen/api/v2/user_service.pb.go +++ b/proto/gen/api/v2/user_service.pb.go @@ -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"` } diff --git a/server/route/api/v2/apidocs.swagger.yaml b/server/route/api/v2/apidocs.swagger.yaml index 51b7bbaa..5363cd00 100644 --- a/server/route/api/v2/apidocs.swagger.yaml +++ b/server/route/api/v2/apidocs.swagger.yaml @@ -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 diff --git a/server/route/api/v2/user_service.go b/server/route/api/v2/user_service.go index da5e71e7..d595f73c 100644 --- a/server/route/api/v2/user_service.go +++ b/server/route/api/v2/user_service.go @@ -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 } diff --git a/store/db/sqlite/user.go b/store/db/sqlite/user.go index b3f177f5..02689fbc 100644 --- a/store/db/sqlite/user.go +++ b/store/db/sqlite/user.go @@ -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 diff --git a/store/user.go b/store/user.go index cec1c48a..2457a64a 100644 --- a/store/user.go +++ b/store/user.go @@ -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 { diff --git a/web/src/components/ExploreSidebar/ExploreSidebar.tsx b/web/src/components/ExploreSidebar/ExploreSidebar.tsx new file mode 100644 index 00000000..3b2a8a6a --- /dev/null +++ b/web/src/components/ExploreSidebar/ExploreSidebar.tsx @@ -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 ( + + ); +}; + +export default ExploreSidebar; diff --git a/web/src/components/ExploreSidebar/ExploreSidebarDrawer.tsx b/web/src/components/ExploreSidebar/ExploreSidebarDrawer.tsx new file mode 100644 index 00000000..6cc192aa --- /dev/null +++ b/web/src/components/ExploreSidebar/ExploreSidebarDrawer.tsx @@ -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 ( + <> + + + + +
+ +
+
+ + ); +}; + +export default ExploreSidebarDrawer; diff --git a/web/src/components/ExploreSidebar/index.ts b/web/src/components/ExploreSidebar/index.ts new file mode 100644 index 00000000..ab9d0c5e --- /dev/null +++ b/web/src/components/ExploreSidebar/index.ts @@ -0,0 +1,4 @@ +import ExploreSidebar from "./ExploreSidebar"; +import ExploreSidebarDrawer from "./ExploreSidebarDrawer"; + +export { ExploreSidebar, ExploreSidebarDrawer }; diff --git a/web/src/components/HomeSidebar.tsx b/web/src/components/HomeSidebar/HomeSidebar.tsx similarity index 77% rename from web/src/components/HomeSidebar.tsx rename to web/src/components/HomeSidebar/HomeSidebar.tsx index c8548931..c0fec52d 100644 --- a/web/src/components/HomeSidebar.tsx +++ b/web/src/components/HomeSidebar/HomeSidebar.tsx @@ -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; diff --git a/web/src/components/HomeSidebarDrawer.tsx b/web/src/components/HomeSidebar/HomeSidebarDrawer.tsx similarity index 97% rename from web/src/components/HomeSidebarDrawer.tsx rename to web/src/components/HomeSidebar/HomeSidebarDrawer.tsx index 49320f64..faf3c454 100644 --- a/web/src/components/HomeSidebarDrawer.tsx +++ b/web/src/components/HomeSidebar/HomeSidebarDrawer.tsx @@ -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(); diff --git a/web/src/components/HomeSidebar/index.ts b/web/src/components/HomeSidebar/index.ts new file mode 100644 index 00000000..f66cde32 --- /dev/null +++ b/web/src/components/HomeSidebar/index.ts @@ -0,0 +1,4 @@ +import HomeSidebar from "./HomeSidebar"; +import HomeSidebarDrawer from "./HomeSidebarDrawer"; + +export { HomeSidebar, HomeSidebarDrawer }; diff --git a/web/src/components/UserList.tsx b/web/src/components/UserList.tsx new file mode 100644 index 00000000..cabfe24d --- /dev/null +++ b/web/src/components/UserList.tsx @@ -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([]); + + const fetchRecommendUsers = async () => { + const users = await userStore.searchUsers(`random == true && limit == 5`); + setUsers(users); + }; + + useEffect(() => { + fetchRecommendUsers(); + }, []); + + return ( +
+
+ Users + + + +
+ {users.map((user) => ( +
+ + +
+ {user.nickname || user.username} +
+ +
+ ))} +
+ ); +}; + +export default UserList; diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index fa6d313c..fa945a86 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -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 (
- -
- - {sortedMemos.map((memo) => ( - - ))} - {isRequesting ? ( -
- -

{t("memo.fetching-data")}

+ {!md && ( + + + + )} +
+
+
+ + {sortedMemos.map((memo) => ( + + ))} + {isRequesting ? ( +
+ +

{t("memo.fetching-data")}

+
+ ) : !nextPageTokenRef.current ? ( + sortedMemos.length === 0 && ( +
+ +

{t("message.no-data")}

+
+ ) + ) : ( +
+ +
+ )}
- ) : !nextPageTokenRef.current ? ( - sortedMemos.length === 0 && ( -
- -

{t("message.no-data")}

-
- ) - ) : ( -
- +
+ {md && ( +
+
)}
diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 74bb50e7..5041a030 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -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"; diff --git a/web/src/store/v1/user.ts b/web/src/store/v1/user.ts index 67d89c8e..a826993f 100644 --- a/web/src/store/v1/user.ts +++ b/web/src/store/v1/user.ts @@ -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,