mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: implement shortcut components
This commit is contained in:
135
web/src/components/CreateShortcutDialog.tsx
Normal file
135
web/src/components/CreateShortcutDialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Input, Textarea } from "@mui/joy";
|
||||||
|
import { Button } from "@usememos/mui";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { userServiceClient } from "@/grpcweb";
|
||||||
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
import useLoading from "@/hooks/useLoading";
|
||||||
|
import { useUserStore } from "@/store/v1";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v1/user_service";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import { generateUUID } from "@/utils/uuid";
|
||||||
|
import { generateDialog } from "./Dialog";
|
||||||
|
|
||||||
|
interface Props extends DialogProps {
|
||||||
|
shortcut?: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { destroy } = props;
|
||||||
|
const t = useTranslate();
|
||||||
|
const user = useCurrentUser();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const [shortcut, setShortcut] = useState(Shortcut.fromPartial({ ...props.shortcut }));
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = !props.shortcut;
|
||||||
|
|
||||||
|
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setShortcut({ ...shortcut, title: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onShortcutFilterChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setShortcut({ ...shortcut, filter: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!shortcut.title || !shortcut.filter) {
|
||||||
|
toast.error("Title and filter cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
await userServiceClient.createShortcut({
|
||||||
|
parent: user.name,
|
||||||
|
shortcut: {
|
||||||
|
...shortcut,
|
||||||
|
id: generateUUID(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("Create shortcut successfully");
|
||||||
|
} else {
|
||||||
|
await userServiceClient.updateShortcut({ parent: user.name, shortcut, updateMask: ["title", "filter"] });
|
||||||
|
toast.success("Update shortcut successfully");
|
||||||
|
}
|
||||||
|
// Refresh shortcuts.
|
||||||
|
await userStore.fetchShortcuts();
|
||||||
|
destroy();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.details);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dialog-header-container">
|
||||||
|
<p className="title-text">{`${isCreating ? "Create" : "Edit"} Shortcut`}</p>
|
||||||
|
<Button size="sm" variant="plain" onClick={() => destroy()}>
|
||||||
|
<XIcon className="w-5 h-auto" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-content-container max-w-md min-w-72">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="text-sm whitespace-nowrap mb-1">Title</span>
|
||||||
|
<Input className="w-full" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
|
||||||
|
<span className="text-sm whitespace-nowrap mt-3 mb-1">Filter</span>
|
||||||
|
<Textarea
|
||||||
|
className="w-full"
|
||||||
|
minRows={3}
|
||||||
|
maxRows={5}
|
||||||
|
size="sm"
|
||||||
|
placeholder={"Shortcut filter"}
|
||||||
|
value={shortcut.filter}
|
||||||
|
onChange={onShortcutFilterChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full opacity-70">
|
||||||
|
<p className="text-sm">{t("common.learn-more")}:</p>
|
||||||
|
<ul className="list-disc list-inside text-sm pl-2 mt-1">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
href="https://www.usememos.com/docs/getting-started/shortcuts"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Docs - Shortcuts
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
href="https://www.usememos.com/docs/getting-started/shortcuts#how-to-write-a-filter-in-a-shortcut"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
How to Write a Filter in a Shortcut?
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center space-x-2 mt-2">
|
||||||
|
<Button variant="plain" disabled={requestState.isLoading} onClick={destroy}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function showCreateShortcutDialog(props: Pick<Props, "shortcut">) {
|
||||||
|
generateDialog(
|
||||||
|
{
|
||||||
|
className: "create-shortcut-dialog",
|
||||||
|
dialogName: "create-shortcut-dialog",
|
||||||
|
},
|
||||||
|
CreateShortcutDialog,
|
||||||
|
props,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default showCreateShortcutDialog;
|
@@ -4,6 +4,7 @@ import StatisticsView from "@/components/StatisticsView";
|
|||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { useMemoList, useUserStatsStore } from "@/store/v1";
|
import { useMemoList, useUserStatsStore } from "@/store/v1";
|
||||||
import { cn } from "@/utils";
|
import { cn } from "@/utils";
|
||||||
|
import ShortcutsSection from "./ShortcutsSection";
|
||||||
import TagsSection from "./TagsSection";
|
import TagsSection from "./TagsSection";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,6 +33,7 @@ const HomeSidebar = (props: Props) => {
|
|||||||
>
|
>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<StatisticsView />
|
<StatisticsView />
|
||||||
|
<ShortcutsSection />
|
||||||
<TagsSection />
|
<TagsSection />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
76
web/src/components/HomeSidebar/ShortcutsSection.tsx
Normal file
76
web/src/components/HomeSidebar/ShortcutsSection.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Dropdown, Menu, MenuButton, MenuItem, Tooltip } from "@mui/joy";
|
||||||
|
import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { userServiceClient } from "@/grpcweb";
|
||||||
|
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||||
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
import { useMemoFilterStore, useUserStore } from "@/store/v1";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v1/user_service";
|
||||||
|
import { cn } from "@/utils";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import showCreateShortcutDialog from "../CreateShortcutDialog";
|
||||||
|
|
||||||
|
const ShortcutsSection = () => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const user = useCurrentUser();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const memoFilterStore = useMemoFilterStore();
|
||||||
|
const shortcuts = userStore.getState().shortcuts;
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
await userStore.fetchShortcuts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteShortcut = async (shortcut: Shortcut) => {
|
||||||
|
const confirmed = window.confirm("Are you sure you want to delete this shortcut?");
|
||||||
|
if (confirmed) {
|
||||||
|
await userServiceClient.deleteShortcut({ parent: user.name, id: shortcut.id });
|
||||||
|
await userStore.fetchShortcuts();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar">
|
||||||
|
<div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-gray-400 select-none">
|
||||||
|
<span>{t("common.shortcuts")}</span>
|
||||||
|
<Tooltip title={t("common.create")} placement="top">
|
||||||
|
<PlusIcon className="w-4 h-auto" onClick={() => showCreateShortcutDialog({})} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
|
||||||
|
{shortcuts.map((shortcut) => {
|
||||||
|
const selected = memoFilterStore.shortcut === shortcut.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={shortcut.id}
|
||||||
|
className="shrink-0 w-full text-sm rounded-md leading-6 flex flex-row justify-between items-center select-none gap-2 text-gray-600 dark:text-gray-400 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn("truncate cursor-pointer dark:opacity-80", selected && "font-medium underline")}
|
||||||
|
onClick={() => (selected ? memoFilterStore.setShortcut(undefined) : memoFilterStore.setShortcut(shortcut.id))}
|
||||||
|
>
|
||||||
|
{shortcut.title}
|
||||||
|
</span>
|
||||||
|
<Dropdown>
|
||||||
|
<MenuButton slots={{ root: "div" }}>
|
||||||
|
<MoreVerticalIcon className="w-4 h-auto shrink-0 opacity-40" />
|
||||||
|
</MenuButton>
|
||||||
|
<Menu size="sm" placement="bottom-start">
|
||||||
|
<MenuItem onClick={() => showCreateShortcutDialog({ shortcut })}>
|
||||||
|
<Edit3Icon className="w-4 h-auto" />
|
||||||
|
{t("common.edit")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem color="danger" onClick={() => handleDeleteShortcut(shortcut)}>
|
||||||
|
<TrashIcon className="w-4 h-auto" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutsSection;
|
@@ -16,6 +16,7 @@ interface Props {
|
|||||||
owner?: string;
|
owner?: string;
|
||||||
state?: State;
|
state?: State;
|
||||||
direction?: Direction;
|
direction?: Direction;
|
||||||
|
filter?: string;
|
||||||
oldFilter?: string;
|
oldFilter?: string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ const PagedMemoList = (props: Props) => {
|
|||||||
parent: props.owner || "",
|
parent: props.owner || "",
|
||||||
state: props.state || State.NORMAL,
|
state: props.state || State.NORMAL,
|
||||||
direction: props.direction || Direction.DESC,
|
direction: props.direction || Direction.DESC,
|
||||||
|
filter: props.filter || "",
|
||||||
oldFilter: props.oldFilter || "",
|
oldFilter: props.oldFilter || "",
|
||||||
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
||||||
pageToken: nextPageToken,
|
pageToken: nextPageToken,
|
||||||
@@ -60,7 +62,7 @@ const PagedMemoList = (props: Props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshList();
|
refreshList();
|
||||||
}, [props.owner, props.state, props.direction, props.oldFilter, props.pageSize]);
|
}, [props.owner, props.state, props.direction, props.filter, props.oldFilter, props.pageSize]);
|
||||||
|
|
||||||
const children = (
|
const children = (
|
||||||
<div className="flex flex-col justify-start items-start w-full max-w-full">
|
<div className="flex flex-col justify-start items-start w-full max-w-full">
|
||||||
|
@@ -79,7 +79,8 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"yourself": "Yourself"
|
"yourself": "Yourself",
|
||||||
|
"shortcuts": "Shortcuts"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"fri": "Fri",
|
"fri": "Fri",
|
||||||
@@ -371,6 +372,7 @@
|
|||||||
},
|
},
|
||||||
"version": "Version"
|
"version": "Version"
|
||||||
},
|
},
|
||||||
|
"shortcut": {},
|
||||||
"tag": {
|
"tag": {
|
||||||
"all-tags": "All Tags",
|
"all-tags": "All Tags",
|
||||||
"create-tag": "Create Tag",
|
"create-tag": "Create Tag",
|
||||||
|
@@ -8,7 +8,7 @@ import MobileHeader from "@/components/MobileHeader";
|
|||||||
import PagedMemoList from "@/components/PagedMemoList";
|
import PagedMemoList from "@/components/PagedMemoList";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
import { useMemoFilterStore } from "@/store/v1";
|
import { useMemoFilterStore, useUserStore } from "@/store/v1";
|
||||||
import { Direction, State } from "@/types/proto/api/v1/common";
|
import { Direction, State } from "@/types/proto/api/v1/common";
|
||||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||||
import { cn } from "@/utils";
|
import { cn } from "@/utils";
|
||||||
@@ -16,7 +16,9 @@ import { cn } from "@/utils";
|
|||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { md } = useResponsiveWidth();
|
const { md } = useResponsiveWidth();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
|
const userStore = useUserStore();
|
||||||
const memoFilterStore = useMemoFilterStore();
|
const memoFilterStore = useMemoFilterStore();
|
||||||
|
const selectedShortcut = userStore.shortcuts.find((shortcut) => shortcut.id === memoFilterStore.shortcut);
|
||||||
|
|
||||||
const memoListFilter = useMemo(() => {
|
const memoListFilter = useMemo(() => {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
@@ -79,6 +81,7 @@ const Home = () => {
|
|||||||
}
|
}
|
||||||
owner={user.name}
|
owner={user.name}
|
||||||
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
||||||
|
filter={selectedShortcut?.filter || ""}
|
||||||
oldFilter={memoListFilter}
|
oldFilter={memoListFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -41,6 +41,8 @@ export const stringifyFilters = (filters: MemoFilter[]): string => {
|
|||||||
interface State {
|
interface State {
|
||||||
filters: MemoFilter[];
|
filters: MemoFilter[];
|
||||||
orderByTimeAsc: boolean;
|
orderByTimeAsc: boolean;
|
||||||
|
// The id of selected shortcut.
|
||||||
|
shortcut?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInitialState = (): State => {
|
const getInitialState = (): State => {
|
||||||
@@ -59,5 +61,6 @@ export const useMemoFilterStore = create(
|
|||||||
addFilter: (filter: MemoFilter) => set((state) => ({ filters: uniqBy([...state.filters, filter], getMemoFilterKey) })),
|
addFilter: (filter: MemoFilter) => set((state) => ({ filters: uniqBy([...state.filters, filter], getMemoFilterKey) })),
|
||||||
removeFilter: (filterFn: (f: MemoFilter) => boolean) => set((state) => ({ filters: state.filters.filter((f) => !filterFn(f)) })),
|
removeFilter: (filterFn: (f: MemoFilter) => boolean) => set((state) => ({ filters: state.filters.filter((f) => !filterFn(f)) })),
|
||||||
setOrderByTimeAsc: (orderByTimeAsc: boolean) => set({ orderByTimeAsc }),
|
setOrderByTimeAsc: (orderByTimeAsc: boolean) => set({ orderByTimeAsc }),
|
||||||
|
setShortcut: (shortcut?: string) => set({ shortcut }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
@@ -1,19 +1,21 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { combine } from "zustand/middleware";
|
import { combine } from "zustand/middleware";
|
||||||
import { authServiceClient, userServiceClient } from "@/grpcweb";
|
import { authServiceClient, userServiceClient } from "@/grpcweb";
|
||||||
import { User, UserSetting, User_Role } from "@/types/proto/api/v1/user_service";
|
import { Shortcut, User, UserSetting, User_Role } from "@/types/proto/api/v1/user_service";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
userMapByName: Record<string, User>;
|
userMapByName: Record<string, User>;
|
||||||
// The name of current user. Format: `users/${uid}`
|
// The name of current user. Format: `users/${uid}`
|
||||||
currentUser?: string;
|
currentUser?: string;
|
||||||
userSetting?: UserSetting;
|
userSetting?: UserSetting;
|
||||||
|
shortcuts: Shortcut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultState = (): State => ({
|
const getDefaultState = (): State => ({
|
||||||
userMapByName: {},
|
userMapByName: {},
|
||||||
currentUser: undefined,
|
currentUser: undefined,
|
||||||
userSetting: undefined,
|
userSetting: undefined,
|
||||||
|
shortcuts: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const getDefaultUserSetting = () => {
|
const getDefaultUserSetting = () => {
|
||||||
@@ -129,6 +131,14 @@ export const useUserStore = create(
|
|||||||
set({ userSetting: updatedUserSetting });
|
set({ userSetting: updatedUserSetting });
|
||||||
return updatedUserSetting;
|
return updatedUserSetting;
|
||||||
},
|
},
|
||||||
|
fetchShortcuts: async () => {
|
||||||
|
const { currentUser } = get();
|
||||||
|
if (!currentUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { shortcuts } = await userServiceClient.listShortcuts({ parent: currentUser });
|
||||||
|
set({ shortcuts });
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user