mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
chore: add explore sidebar
This commit is contained in:
@ -131,6 +131,8 @@ message ListUsersResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message SearchUsersRequest {
|
message SearchUsersRequest {
|
||||||
|
// Filter is used to filter users returned in the list.
|
||||||
|
// Format: "username == frank"
|
||||||
string filter = 1;
|
string filter = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -617,7 +617,7 @@ Used internally for obfuscating the page token.
|
|||||||
|
|
||||||
| Field | Type | Label | Description |
|
| Field | Type | Label | Description |
|
||||||
| ----- | ---- | ----- | ----------- |
|
| ----- | ---- | ----- | ----------- |
|
||||||
| filter | [string](#string) | | |
|
| filter | [string](#string) | | Filter is used to filter users returned in the list. Format: "username == frank" |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -303,6 +303,8 @@ type SearchUsersRequest struct {
|
|||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
unknownFields protoimpl.UnknownFields
|
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"`
|
Filter string `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -562,6 +562,9 @@ paths:
|
|||||||
$ref: '#/definitions/googlerpcStatus'
|
$ref: '#/definitions/googlerpcStatus'
|
||||||
parameters:
|
parameters:
|
||||||
- name: filter
|
- name: filter
|
||||||
|
description: |-
|
||||||
|
Filter is used to filter users returned in the list.
|
||||||
|
Format: "username == frank"
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
@ -60,6 +60,12 @@ func (s *APIV2Service) SearchUsers(ctx context.Context, request *apiv2pb.SearchU
|
|||||||
if filter.Username != nil {
|
if filter.Username != nil {
|
||||||
userFind.Username = filter.Username
|
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)
|
users, err := s.Store.ListUsers(ctx, userFind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -540,10 +546,14 @@ func convertUserRoleToStore(role apiv2pb.User_Role) store.Role {
|
|||||||
// SearchUsersFilterCELAttributes are the CEL attributes for SearchUsersFilter.
|
// SearchUsersFilterCELAttributes are the CEL attributes for SearchUsersFilter.
|
||||||
var SearchUsersFilterCELAttributes = []cel.EnvOption{
|
var SearchUsersFilterCELAttributes = []cel.EnvOption{
|
||||||
cel.Variable("username", cel.StringType),
|
cel.Variable("username", cel.StringType),
|
||||||
|
cel.Variable("random", cel.BoolType),
|
||||||
|
cel.Variable("limit", cel.IntType),
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchUsersFilter struct {
|
type SearchUsersFilter struct {
|
||||||
Username *string
|
Username *string
|
||||||
|
Random bool
|
||||||
|
Limit *int
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSearchUsersFilter(expression string) (*SearchUsersFilter, error) {
|
func parseSearchUsersFilter(expression string) (*SearchUsersFilter, error) {
|
||||||
@ -572,6 +582,12 @@ func findSearchUsersField(callExpr *expr.Expr_Call, filter *SearchUsersFilter) {
|
|||||||
if idExpr.Name == "username" {
|
if idExpr.Name == "username" {
|
||||||
username := callExpr.Args[1].GetConstExpr().GetStringValue()
|
username := callExpr.Args[1].GetConstExpr().GetStringValue()
|
||||||
filter.Username = &username
|
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
|
return
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@ package sqlite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/usememos/memos/store"
|
"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)
|
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 := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
@ -113,9 +120,11 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
|
|||||||
updated_ts,
|
updated_ts,
|
||||||
row_status
|
row_status
|
||||||
FROM user
|
FROM user
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ` + strings.Join(orderBy, ", ")
|
||||||
ORDER BY created_ts DESC, row_status DESC
|
if v := find.Limit; v != nil {
|
||||||
`
|
query += fmt.Sprintf(" LIMIT %d", *v)
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -82,6 +82,12 @@ type FindUser struct {
|
|||||||
Role *Role
|
Role *Role
|
||||||
Email *string
|
Email *string
|
||||||
Nickname *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 {
|
type DeleteUser struct {
|
||||||
|
23
web/src/components/ExploreSidebar/ExploreSidebar.tsx
Normal file
23
web/src/components/ExploreSidebar/ExploreSidebar.tsx
Normal 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;
|
37
web/src/components/ExploreSidebar/ExploreSidebarDrawer.tsx
Normal file
37
web/src/components/ExploreSidebar/ExploreSidebarDrawer.tsx
Normal 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;
|
4
web/src/components/ExploreSidebar/index.ts
Normal file
4
web/src/components/ExploreSidebar/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import ExploreSidebar from "./ExploreSidebar";
|
||||||
|
import ExploreSidebarDrawer from "./ExploreSidebarDrawer";
|
||||||
|
|
||||||
|
export { ExploreSidebar, ExploreSidebarDrawer };
|
@ -1,8 +1,8 @@
|
|||||||
import classNames from "classnames";
|
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 useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import PersonalStatistics from "./PersonalStatistics";
|
|
||||||
import SearchBar from "./SearchBar";
|
|
||||||
import TagList from "./TagList";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
@ -1,8 +1,8 @@
|
|||||||
import { Drawer, IconButton } from "@mui/joy";
|
import { Drawer, IconButton } from "@mui/joy";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import Icon from "../Icon";
|
||||||
import HomeSidebar from "./HomeSidebar";
|
import HomeSidebar from "./HomeSidebar";
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
const HomeSidebarDrawer = () => {
|
const HomeSidebarDrawer = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
4
web/src/components/HomeSidebar/index.ts
Normal file
4
web/src/components/HomeSidebar/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import HomeSidebar from "./HomeSidebar";
|
||||||
|
import HomeSidebarDrawer from "./HomeSidebarDrawer";
|
||||||
|
|
||||||
|
export { HomeSidebar, HomeSidebarDrawer };
|
47
web/src/components/UserList.tsx
Normal file
47
web/src/components/UserList.tsx
Normal 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;
|
@ -1,6 +1,8 @@
|
|||||||
import { Button } from "@mui/joy";
|
import { Button } from "@mui/joy";
|
||||||
|
import classNames from "classnames";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Empty from "@/components/Empty";
|
import Empty from "@/components/Empty";
|
||||||
|
import { ExploreSidebar, ExploreSidebarDrawer } from "@/components/ExploreSidebar";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import MemoFilter from "@/components/MemoFilter";
|
import MemoFilter from "@/components/MemoFilter";
|
||||||
import MemoView from "@/components/MemoView";
|
import MemoView from "@/components/MemoView";
|
||||||
@ -9,11 +11,13 @@ import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
|||||||
import { getTimeStampByDate } from "@/helpers/datetime";
|
import { getTimeStampByDate } from "@/helpers/datetime";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import useFilterWithUrlParams from "@/hooks/useFilterWithUrlParams";
|
import useFilterWithUrlParams from "@/hooks/useFilterWithUrlParams";
|
||||||
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
import { useMemoList, useMemoStore } from "@/store/v1";
|
import { useMemoList, useMemoStore } from "@/store/v1";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
const Explore = () => {
|
const Explore = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const { md } = useResponsiveWidth();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const memoStore = useMemoStore();
|
const memoStore = useMemoStore();
|
||||||
const memoList = useMemoList();
|
const memoList = useMemoList();
|
||||||
@ -52,11 +56,17 @@ const Explore = () => {
|
|||||||
|
|
||||||
return (
|
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">
|
<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 />
|
{!md && (
|
||||||
<div className="relative w-full h-auto flex flex-col justify-start items-start px-4 sm:px-6">
|
<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" />
|
<MemoFilter className="px-2 pb-2" />
|
||||||
{sortedMemos.map((memo) => (
|
{sortedMemos.map((memo) => (
|
||||||
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} />
|
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned />
|
||||||
))}
|
))}
|
||||||
{isRequesting ? (
|
{isRequesting ? (
|
||||||
<div className="flex flex-row justify-center items-center w-full my-4 text-gray-400">
|
<div className="flex flex-row justify-center items-center w-full my-4 text-gray-400">
|
||||||
@ -78,6 +88,13 @@ const Explore = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{md && (
|
||||||
|
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
|
||||||
|
<ExploreSidebar className="py-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,8 +2,7 @@ import { Button } from "@mui/joy";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Empty from "@/components/Empty";
|
import Empty from "@/components/Empty";
|
||||||
import HomeSidebar from "@/components/HomeSidebar";
|
import { HomeSidebar, HomeSidebarDrawer } from "@/components/HomeSidebar";
|
||||||
import HomeSidebarDrawer from "@/components/HomeSidebarDrawer";
|
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import MemoEditor from "@/components/MemoEditor";
|
import MemoEditor from "@/components/MemoEditor";
|
||||||
import showMemoEditorDialog from "@/components/MemoEditor/MemoEditorDialog";
|
import showMemoEditorDialog from "@/components/MemoEditor/MemoEditorDialog";
|
||||||
|
@ -62,6 +62,15 @@ export const useUserStore = create(
|
|||||||
set({ userMapByName: userMap });
|
set({ userMapByName: userMap });
|
||||||
return user;
|
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) => {
|
searchUsers: async (filter: string) => {
|
||||||
const { users } = await userServiceClient.searchUsers({
|
const { users } = await userServiceClient.searchUsers({
|
||||||
filter,
|
filter,
|
||||||
|
Reference in New Issue
Block a user