refactor: migrate eslint

This commit is contained in:
Johnny
2025-04-01 00:04:43 +08:00
parent d649d326ef
commit b770042a8a
18 changed files with 810 additions and 886 deletions

View File

@@ -1,50 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint", "prettier"],
"ignorePatterns": ["node_modules", "dist", "public", "src/assets"],
"rules": {
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off",
"react/jsx-no-target-blank": "off",
"no-restricted-syntax": [
"error",
{
"selector": "VariableDeclarator[init.callee.name='useTranslation'] > ObjectPattern > Property[key.name='t']:not([parent.declarations.0.init.callee.object.name='i18n'])",
"message": "Destructuring 't' from useTranslation is not allowed. Please use the 'useTranslate' hook from '@/utils/i18n'."
}
]
},
"settings": {
"react": {
"version": "detect"
}
},
"overrides": [
{
"files": ["src/utils/i18n.ts"],
"rules": {
"no-restricted-syntax": "off"
}
}
]
}

34
web/eslint.config.mjs Normal file
View File

@@ -0,0 +1,34 @@
import eslint from "@eslint/js";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import tseslint from "typescript-eslint";
export default [
...tseslint.config(eslint.configs.recommended, tseslint.configs.recommended),
eslintPluginPrettierRecommended,
{
ignores: ["**/dist/**", "**/node_modules/**", "**/proto/**"],
},
{
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off",
"react/jsx-no-target-blank": "off",
"no-restricted-syntax": [
"error",
{
selector:
"VariableDeclarator[init.callee.name='useTranslation'] > ObjectPattern > Property[key.name='t']:not([parent.declarations.0.init.callee.object.name='i18n'])",
message: "Destructuring 't' from useTranslation is not allowed. Please use the 'useTranslate' hook from '@/utils/i18n'.",
},
],
},
},
{
files: ["src/utils/i18n.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
];

View File

@@ -52,6 +52,7 @@
"devDependencies": { "devDependencies": {
"@bufbuild/buf": "^1.50.1", "@bufbuild/buf": "^1.50.1",
"@bufbuild/protobuf": "^2.2.3", "@bufbuild/protobuf": "^2.2.3",
"@eslint/js": "^9.23.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
@@ -63,15 +64,13 @@
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/textarea-caret": "^3.0.3", "@types/textarea-caret": "^3.0.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-legacy": "^6.0.2", "@vitejs/plugin-legacy": "^6.0.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"code-inspector-plugin": "^0.18.3", "code-inspector-plugin": "^0.18.3",
"eslint": "^8.57.1", "eslint": "^9.23.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.3", "eslint-plugin-prettier": "^5.2.5",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"long": "^5.3.1", "long": "^5.3.1",
"nice-grpc-web": "^3.3.6", "nice-grpc-web": "^3.3.6",
@@ -80,6 +79,7 @@
"protobufjs": "^7.4.0", "protobufjs": "^7.4.0",
"terser": "^5.39.0", "terser": "^5.39.0",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.28.0",
"vite": "^6.2.1" "vite": "^6.2.1"
}, },
"pnpm": { "pnpm": {

584
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,147 +1,147 @@
import { Radio, RadioGroup } from "@mui/joy"; import { Radio, RadioGroup } from "@mui/joy";
import { Button, Input } from "@usememos/mui"; import { Button, Input } from "@usememos/mui";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { userServiceClient } from "@/grpcweb"; import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog"; import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface Props extends DialogProps {
onConfirm: () => void; onConfirm: () => void;
} }
interface State { interface State {
description: string; description: string;
expiration: number; expiration: number;
} }
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => { const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
const { destroy, onConfirm } = props; const { destroy, onConfirm } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [state, setState] = useState({ const [state, setState] = useState({
description: "", description: "",
expiration: 3600 * 8, expiration: 3600 * 8,
}); });
const requestState = useLoading(false); const requestState = useLoading(false);
const expirationOptions = [ const expirationOptions = [
{ {
label: t("setting.access-token-section.create-dialog.duration-8h"), label: t("setting.access-token-section.create-dialog.duration-8h"),
value: 3600 * 8, value: 3600 * 8,
}, },
{ {
label: t("setting.access-token-section.create-dialog.duration-1m"), label: t("setting.access-token-section.create-dialog.duration-1m"),
value: 3600 * 24 * 30, value: 3600 * 24 * 30,
}, },
{ {
label: t("setting.access-token-section.create-dialog.duration-never"), label: t("setting.access-token-section.create-dialog.duration-never"),
value: 0, value: 0,
}, },
]; ];
const setPartialState = (partialState: Partial<State>) => { const setPartialState = (partialState: Partial<State>) => {
setState({ setState({
...state, ...state,
...partialState, ...partialState,
}); });
}; };
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
description: e.target.value, description: e.target.value,
}); });
}; };
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
expiration: Number(e.target.value), expiration: Number(e.target.value),
}); });
}; };
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (!state.description) { if (!state.description) {
toast.error(t("message.description-is-required")); toast.error(t("message.description-is-required"));
return; return;
} }
try { try {
await userServiceClient.createUserAccessToken({ await userServiceClient.createUserAccessToken({
name: currentUser.name, name: currentUser.name,
description: state.description, description: state.description,
expiresAt: state.expiration ? new Date(Date.now() + state.expiration * 1000) : undefined, expiresAt: state.expiration ? new Date(Date.now() + state.expiration * 1000) : undefined,
}); });
onConfirm(); onConfirm();
destroy(); destroy();
} catch (error: any) { } catch (error: any) {
toast.error(error.details); toast.error(error.details);
console.error(error); console.error(error);
} }
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-white dark:bg-zinc-800 dark:text-gray-300 p-4 rounded-lg"> <div className="max-w-full shadow flex flex-col justify-start items-start bg-white dark:bg-zinc-800 dark:text-gray-300 p-4 rounded-lg">
<div className="flex flex-row justify-between items-center w-full mb-4 gap-2"> <div className="flex flex-row justify-between items-center w-full mb-4 gap-2">
<p>{t("setting.access-token-section.create-dialog.create-access-token")}</p> <p>{t("setting.access-token-section.create-dialog.create-access-token")}</p>
<Button size="sm" variant="plain" onClick={() => destroy()}> <Button size="sm" variant="plain" onClick={() => destroy()}>
<XIcon className="w-5 h-auto" /> <XIcon className="w-5 h-auto" />
</Button> </Button>
</div> </div>
<div className="flex flex-col justify-start items-start !w-80"> <div className="flex flex-col justify-start items-start !w-80">
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2"> <span className="mb-2">
{t("setting.access-token-section.create-dialog.description")} <span className="text-red-600">*</span> {t("setting.access-token-section.create-dialog.description")} <span className="text-red-600">*</span>
</span> </span>
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder={t("setting.access-token-section.create-dialog.some-description")} placeholder={t("setting.access-token-section.create-dialog.some-description")}
value={state.description} value={state.description}
onChange={handleDescriptionInputChange} onChange={handleDescriptionInputChange}
/> />
</div> </div>
</div> </div>
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2"> <span className="mb-2">
{t("setting.access-token-section.create-dialog.expiration")} <span className="text-red-600">*</span> {t("setting.access-token-section.create-dialog.expiration")} <span className="text-red-600">*</span>
</span> </span>
<div className="w-full flex flex-row justify-start items-center text-base"> <div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}> <RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
{expirationOptions.map((option) => ( {expirationOptions.map((option) => (
<Radio key={option.value} value={option.value} checked={state.expiration === option.value} label={option.label} /> <Radio key={option.value} value={option.value} checked={state.expiration === option.value} label={option.label} />
))} ))}
</RadioGroup> </RadioGroup>
</div> </div>
</div> </div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2"> <div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button variant="plain" disabled={requestState.isLoading} onClick={destroy}> <Button variant="plain" disabled={requestState.isLoading} onClick={destroy}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} onClick={handleSaveBtnClick}> <Button color="primary" disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")} {t("common.create")}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
function showCreateAccessTokenDialog(onConfirm: () => void) { function showCreateAccessTokenDialog(onConfirm: () => void) {
generateDialog( generateDialog(
{ {
className: "create-access-token-dialog", className: "create-access-token-dialog",
dialogName: "create-access-token-dialog", dialogName: "create-access-token-dialog",
}, },
CreateAccessTokenDialog, CreateAccessTokenDialog,
{ {
onConfirm, onConfirm,
}, },
); );
} }
export default showCreateAccessTokenDialog; export default showCreateAccessTokenDialog;

View File

@@ -1,160 +1,160 @@
import { Button, Input } from "@usememos/mui"; import { Button, Input } from "@usememos/mui";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { webhookServiceClient } from "@/grpcweb"; import { webhookServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog"; import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface Props extends DialogProps {
webhookId?: number; webhookId?: number;
onConfirm: () => void; onConfirm: () => void;
} }
interface State { interface State {
name: string; name: string;
url: string; url: string;
} }
const CreateWebhookDialog: React.FC<Props> = (props: Props) => { const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
const { webhookId, destroy, onConfirm } = props; const { webhookId, destroy, onConfirm } = props;
const t = useTranslate(); const t = useTranslate();
const [state, setState] = useState({ const [state, setState] = useState({
name: "", name: "",
url: "", url: "",
}); });
const requestState = useLoading(false); const requestState = useLoading(false);
const isCreating = webhookId === undefined; const isCreating = webhookId === undefined;
useEffect(() => { useEffect(() => {
if (webhookId) { if (webhookId) {
webhookServiceClient webhookServiceClient
.getWebhook({ .getWebhook({
id: webhookId, id: webhookId,
}) })
.then((webhook) => { .then((webhook) => {
setState({ setState({
name: webhook.name, name: webhook.name,
url: webhook.url, url: webhook.url,
}); });
}); });
} }
}, []); }, []);
const setPartialState = (partialState: Partial<State>) => { const setPartialState = (partialState: Partial<State>) => {
setState({ setState({
...state, ...state,
...partialState, ...partialState,
}); });
}; };
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
name: e.target.value, name: e.target.value,
}); });
}; };
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
url: e.target.value, url: e.target.value,
}); });
}; };
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (!state.name || !state.url) { if (!state.name || !state.url) {
toast.error(t("message.fill-all-required-fields")); toast.error(t("message.fill-all-required-fields"));
return; return;
} }
try { try {
if (isCreating) { if (isCreating) {
await webhookServiceClient.createWebhook({ await webhookServiceClient.createWebhook({
name: state.name, name: state.name,
url: state.url, url: state.url,
}); });
} else { } else {
await webhookServiceClient.updateWebhook({ await webhookServiceClient.updateWebhook({
webhook: { webhook: {
id: webhookId, id: webhookId,
name: state.name, name: state.name,
url: state.url, url: state.url,
}, },
updateMask: ["name", "url"], updateMask: ["name", "url"],
}); });
} }
onConfirm(); onConfirm();
destroy(); destroy();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
} }
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-white dark:bg-zinc-800 dark:text-gray-300 p-4 rounded-lg"> <div className="max-w-full shadow flex flex-col justify-start items-start bg-white dark:bg-zinc-800 dark:text-gray-300 p-4 rounded-lg">
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
<p className="title-text"> <p className="title-text">
{isCreating ? t("setting.webhook-section.create-dialog.create-webhook") : t("setting.webhook-section.create-dialog.edit-webhook")} {isCreating ? t("setting.webhook-section.create-dialog.create-webhook") : t("setting.webhook-section.create-dialog.edit-webhook")}
</p> </p>
<Button size="sm" variant="plain" onClick={() => destroy()}> <Button size="sm" variant="plain" onClick={() => destroy()}>
<XIcon className="w-5 h-auto" /> <XIcon className="w-5 h-auto" />
</Button> </Button>
</div> </div>
<div className="flex flex-col justify-start items-start !w-80"> <div className="flex flex-col justify-start items-start !w-80">
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2"> <span className="mb-2">
{t("setting.webhook-section.create-dialog.title")} <span className="text-red-600">*</span> {t("setting.webhook-section.create-dialog.title")} <span className="text-red-600">*</span>
</span> </span>
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")} placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")}
value={state.name} value={state.name}
onChange={handleTitleInputChange} onChange={handleTitleInputChange}
/> />
</div> </div>
</div> </div>
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2"> <span className="mb-2">
{t("setting.webhook-section.create-dialog.payload-url")} <span className="text-red-600">*</span> {t("setting.webhook-section.create-dialog.payload-url")} <span className="text-red-600">*</span>
</span> </span>
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")} placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")}
value={state.url} value={state.url}
onChange={handleUrlInputChange} onChange={handleUrlInputChange}
/> />
</div> </div>
</div> </div>
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2"> <div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
<Button variant="plain" disabled={requestState.isLoading} onClick={destroy}> <Button variant="plain" disabled={requestState.isLoading} onClick={destroy}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} onClick={handleSaveBtnClick}> <Button color="primary" disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")} {t("common.create")}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
function showCreateWebhookDialog(onConfirm: () => void) { function showCreateWebhookDialog(onConfirm: () => void) {
generateDialog( generateDialog(
{ {
className: "create-webhook-dialog", className: "create-webhook-dialog",
dialogName: "create-webhook-dialog", dialogName: "create-webhook-dialog",
}, },
CreateWebhookDialog, CreateWebhookDialog,
{ {
onConfirm, onConfirm,
}, },
); );
} }
export default showCreateWebhookDialog; export default showCreateWebhookDialog;

View File

@@ -33,7 +33,7 @@ const LocaleSelect: FC<Props> = (props: Props) => {
</Option> </Option>
); );
} }
} catch (error) { } catch {
// do nth // do nth
} }

View File

@@ -77,7 +77,7 @@ const MemoActionMenu = (props: Props) => {
["pinned"], ["pinned"],
); );
} }
} catch (error) { } catch {
// do nth // do nth
} }
}; };
@@ -108,7 +108,7 @@ const MemoActionMenu = (props: Props) => {
} }
if (isInMemoDetailPage) { if (isInMemoDetailPage) {
memo.state === State.ARCHIVED ? navigateTo("/") : navigateTo("/archived"); navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
} }
memoUpdatedCallback(); memoUpdatedCallback();
}; };

View File

@@ -45,7 +45,7 @@ const CodeBlock: React.FC<Props> = ({ language, content }: Props) => {
language: formatedLanguage, language: formatedLanguage,
}).value; }).value;
} }
} catch (error) { } catch {
// Skip error and use default highlighted code. // Skip error and use default highlighted code.
} }

View File

@@ -1,8 +1,4 @@
import { BaseProps } from "./types"; const LineBreak = () => {
interface Props extends BaseProps {}
const LineBreak: React.FC<Props> = () => {
return <br />; return <br />;
}; };

View File

@@ -14,7 +14,7 @@ const getFaviconWithGoogleS2 = (url: string) => {
try { try {
const urlObject = new URL(url); const urlObject = new URL(url);
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`; return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
} catch (error) { } catch {
return undefined; return undefined;
} }
}; };

View File

@@ -1,187 +1,187 @@
import { Autocomplete, AutocompleteOption, Chip } from "@mui/joy"; import { Autocomplete, AutocompleteOption, Chip } from "@mui/joy";
import { Button, Checkbox } from "@usememos/mui"; import { Button, Checkbox } from "@usememos/mui";
import { uniqBy } from "lodash-es"; import { uniqBy } from "lodash-es";
import { LinkIcon } from "lucide-react"; import { LinkIcon } from "lucide-react";
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useDebounce from "react-use/lib/useDebounce"; import useDebounce from "react-use/lib/useDebounce";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover";
import { memoServiceClient } from "@/grpcweb"; import { memoServiceClient } from "@/grpcweb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { extractMemoIdFromName } from "@/store/v1"; import { extractMemoIdFromName } from "@/store/v1";
import { Memo, MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service"; import { Memo, MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { EditorRefActions } from "../Editor"; import { EditorRefActions } from "../Editor";
import { MemoEditorContext } from "../types"; import { MemoEditorContext } from "../types";
interface Props { interface Props {
editorRef: React.RefObject<EditorRefActions>; editorRef: React.RefObject<EditorRefActions>;
} }
const AddMemoRelationPopover = (props: Props) => { const AddMemoRelationPopover = (props: Props) => {
const { editorRef } = props; const { editorRef } = props;
const t = useTranslate(); const t = useTranslate();
const context = useContext(MemoEditorContext); const context = useContext(MemoEditorContext);
const user = useCurrentUser(); const user = useCurrentUser();
const [searchText, setSearchText] = useState<string>(""); const [searchText, setSearchText] = useState<string>("");
const [isFetching, setIsFetching] = useState<boolean>(true); const [isFetching, setIsFetching] = useState<boolean>(true);
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]); const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
const [selectedMemos, setSelectedMemos] = useState<Memo[]>([]); const [selectedMemos, setSelectedMemos] = useState<Memo[]>([]);
const [embedded, setEmbedded] = useState<boolean>(false); const [embedded, setEmbedded] = useState<boolean>(false);
const [popoverOpen, setPopoverOpen] = useState<boolean>(false); const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
const filteredMemos = fetchedMemos.filter( const filteredMemos = fetchedMemos.filter(
(memo) => (memo) =>
!selectedMemos.includes(memo) && !selectedMemos.includes(memo) &&
memo.name !== context.memoName && memo.name !== context.memoName &&
!context.relationList.some((relation) => relation.relatedMemo?.name === memo.name), !context.relationList.some((relation) => relation.relatedMemo?.name === memo.name),
); );
useDebounce( useDebounce(
async () => { async () => {
if (!popoverOpen) return; if (!popoverOpen) return;
setIsFetching(true); setIsFetching(true);
try { try {
const conditions = []; const conditions = [];
if (searchText) { if (searchText) {
conditions.push(`content_search == [${JSON.stringify(searchText)}]`); conditions.push(`content_search == [${JSON.stringify(searchText)}]`);
} }
const { memos } = await memoServiceClient.listMemos({ const { memos } = await memoServiceClient.listMemos({
parent: user.name, parent: user.name,
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE, pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
oldFilter: conditions.length > 0 ? conditions.join(" && ") : undefined, oldFilter: conditions.length > 0 ? conditions.join(" && ") : undefined,
}); });
setFetchedMemos(memos); setFetchedMemos(memos);
} catch (error: any) { } catch (error: any) {
toast.error(error.details); toast.error(error.details);
console.error(error); console.error(error);
} }
setIsFetching(false); setIsFetching(false);
}, },
300, 300,
[popoverOpen, searchText], [popoverOpen, searchText],
); );
const getHighlightedContent = (content: string) => { const getHighlightedContent = (content: string) => {
const index = content.toLowerCase().indexOf(searchText.toLowerCase()); const index = content.toLowerCase().indexOf(searchText.toLowerCase());
if (index === -1) { if (index === -1) {
return content; return content;
} }
let before = content.slice(0, index); let before = content.slice(0, index);
if (before.length > 20) { if (before.length > 20) {
before = "..." + before.slice(before.length - 20); before = "..." + before.slice(before.length - 20);
} }
const highlighted = content.slice(index, index + searchText.length); const highlighted = content.slice(index, index + searchText.length);
let after = content.slice(index + searchText.length); let after = content.slice(index + searchText.length);
if (after.length > 20) { if (after.length > 20) {
after = after.slice(0, 20) + "..."; after = after.slice(0, 20) + "...";
} }
return ( return (
<> <>
{before} {before}
<mark className="font-medium">{highlighted}</mark> <mark className="font-medium">{highlighted}</mark>
{after} {after}
</> </>
); );
}; };
const addMemoRelations = async () => { const addMemoRelations = async () => {
// If embedded mode is enabled, embed the memo instead of creating a relation. // If embedded mode is enabled, embed the memo instead of creating a relation.
if (embedded) { if (embedded) {
if (!editorRef.current) { if (!editorRef.current) {
toast.error(t("message.failed-to-embed-memo")); toast.error(t("message.failed-to-embed-memo"));
return; return;
} }
const cursorPosition = editorRef.current.getCursorPosition(); const cursorPosition = editorRef.current.getCursorPosition();
const prevValue = editorRef.current.getContent().slice(0, cursorPosition); const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
if (prevValue !== "" && !prevValue.endsWith("\n")) { if (prevValue !== "" && !prevValue.endsWith("\n")) {
editorRef.current.insertText("\n"); editorRef.current.insertText("\n");
} }
for (const memo of selectedMemos) { for (const memo of selectedMemos) {
editorRef.current.insertText(`![[memos/${extractMemoIdFromName(memo.name)}]]\n`); editorRef.current.insertText(`![[memos/${extractMemoIdFromName(memo.name)}]]\n`);
} }
setTimeout(() => { setTimeout(() => {
editorRef.current?.scrollToCursor(); editorRef.current?.scrollToCursor();
editorRef.current?.focus(); editorRef.current?.focus();
}); });
} else { } else {
context.setRelationList( context.setRelationList(
uniqBy( uniqBy(
[ [
...selectedMemos.map((memo) => ({ ...selectedMemos.map((memo) => ({
memo: MemoRelation_Memo.fromPartial({ name: memo.name }), memo: MemoRelation_Memo.fromPartial({ name: memo.name }),
relatedMemo: MemoRelation_Memo.fromPartial({ name: memo.name }), relatedMemo: MemoRelation_Memo.fromPartial({ name: memo.name }),
type: MemoRelation_Type.REFERENCE, type: MemoRelation_Type.REFERENCE,
})), })),
...context.relationList, ...context.relationList,
].filter((relation) => relation.relatedMemo !== context.memoName), ].filter((relation) => relation.relatedMemo !== context.memoName),
"relatedMemo", "relatedMemo",
), ),
); );
} }
setSelectedMemos([]); setSelectedMemos([]);
setPopoverOpen(false); setPopoverOpen(false);
}; };
return ( return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger className="w-9 relative"> <PopoverTrigger className="w-9 relative">
<Button className="flex items-center justify-center" size="sm" variant="plain" asChild> <Button className="flex items-center justify-center" size="sm" variant="plain" asChild>
<LinkIcon className="w-5 h-5 mx-auto p-0" /> <LinkIcon className="w-5 h-5 mx-auto p-0" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="center"> <PopoverContent align="center">
<div className="w-[16rem] flex flex-col justify-start items-start"> <div className="w-[16rem] flex flex-col justify-start items-start">
<Autocomplete <Autocomplete
className="w-full" className="w-full"
size="md" size="md"
clearOnBlur clearOnBlur
disableClearable disableClearable
placeholder={t("reference.search-placeholder")} placeholder={t("reference.search-placeholder")}
noOptionsText={t("reference.no-memos-found")} noOptionsText={t("reference.no-memos-found")}
options={filteredMemos} options={filteredMemos}
loading={isFetching} loading={isFetching}
inputValue={searchText} inputValue={searchText}
value={selectedMemos} value={selectedMemos}
multiple multiple
onInputChange={(_, value) => setSearchText(value.trim())} onInputChange={(_, value) => setSearchText(value.trim())}
getOptionKey={(memo) => memo.name} getOptionKey={(memo) => memo.name}
getOptionLabel={(memo) => memo.content} getOptionLabel={(memo) => memo.content}
isOptionEqualToValue={(memo, value) => memo.name === value.name} isOptionEqualToValue={(memo, value) => memo.name === value.name}
renderOption={(props, memo) => ( renderOption={(props, memo) => (
<AutocompleteOption {...props} key={memo.name}> <AutocompleteOption {...props} key={memo.name}>
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
<p className="text-xs text-gray-400 select-none">{memo.displayTime?.toLocaleString()}</p> <p className="text-xs text-gray-400 select-none">{memo.displayTime?.toLocaleString()}</p>
<p className="mt-0.5 text-sm leading-5 line-clamp-2">{searchText ? getHighlightedContent(memo.content) : memo.snippet}</p> <p className="mt-0.5 text-sm leading-5 line-clamp-2">{searchText ? getHighlightedContent(memo.content) : memo.snippet}</p>
</div> </div>
</AutocompleteOption> </AutocompleteOption>
)} )}
renderTags={(memos) => renderTags={(memos) =>
memos.map((memo) => ( memos.map((memo) => (
<Chip key={memo.name} className="!max-w-full !rounded" variant="outlined" color="neutral"> <Chip key={memo.name} className="!max-w-full !rounded" variant="outlined" color="neutral">
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
<p className="text-xs text-gray-400 select-none">{memo.displayTime?.toLocaleString()}</p> <p className="text-xs text-gray-400 select-none">{memo.displayTime?.toLocaleString()}</p>
<span className="w-full text-sm leading-5 truncate">{memo.content}</span> <span className="w-full text-sm leading-5 truncate">{memo.content}</span>
</div> </div>
</Chip> </Chip>
)) ))
} }
onChange={(_, value) => setSelectedMemos(value)} onChange={(_, value) => setSelectedMemos(value)}
/> />
<div className="mt-2 w-full flex flex-row justify-end items-center gap-2"> <div className="mt-2 w-full flex flex-row justify-end items-center gap-2">
<Checkbox size="sm" label={"Embed"} checked={embedded} onChange={(e) => setEmbedded(e.target.checked)} /> <Checkbox size="sm" label={"Embed"} checked={embedded} onChange={(e) => setEmbedded(e.target.checked)} />
<Button size="sm" color="primary" onClick={addMemoRelations} disabled={selectedMemos.length === 0}> <Button size="sm" color="primary" onClick={addMemoRelations} disabled={selectedMemos.length === 0}>
{t("common.add")} {t("common.add")}
</Button> </Button>
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}; };
export default AddMemoRelationPopover; export default AddMemoRelationPopover;

View File

@@ -86,7 +86,11 @@ const TagSuggestions = observer(({ editorRef, editorActions }: Props) => {
const caretCordinates = getCaretCoordinates(editor, index); const caretCordinates = getCaretCoordinates(editor, index);
caretCordinates.top -= editor.scrollTop; caretCordinates.top -= editor.scrollTop;
isActive ? setPosition(caretCordinates) : hide(); if (isActive) {
setPosition(caretCordinates);
} else {
hide();
}
}; };
const listenersAreRegisteredRef = useRef(false); const listenersAreRegisteredRef = useRef(false);

View File

@@ -4,6 +4,7 @@ export interface NodeType {
memo: MemoRelation_Memo; memo: MemoRelation_Memo;
} }
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LinkType { export interface LinkType {
// ...add more additional properties relevant to the link here. // ...add more additional properties relevant to the link here.
} }

View File

@@ -49,7 +49,7 @@ const ReactionSelector = (props: Props) => {
}); });
} }
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true }); await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
} catch (error) { } catch {
// skip error. // skip error.
} }
setOpen(false); setOpen(false);

View File

@@ -58,7 +58,7 @@ const ReactionView = (props: Props) => {
await memoServiceClient.deleteMemoReaction({ id: reaction.id }); await memoServiceClient.deleteMemoReaction({ id: reaction.id });
} }
} }
} catch (error) { } catch {
// Skip error. // Skip error.
} }
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true }); await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });

View File

@@ -9,7 +9,6 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
// eslint-disable-next-line react/prop-types
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content

View File

@@ -25,7 +25,7 @@ export const isValidUrl = (url: string): boolean => {
try { try {
new URL(url); new URL(url);
return true; return true;
} catch (err) { } catch {
return false; return false;
} }
}; };