From 85f4fc7a75115bab25d5606b00315c9c7ea9e4ab Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 28 Dec 2025 12:46:12 +0800 Subject: [PATCH] refactor: remove MemoContentContext and integrate MemoViewContext - Deleted MemoContentContext and its associated types. - Updated Tag and TaskListItem components to use MemoViewContext instead. - Refactored MemoContent component to eliminate context provider and directly use derived values. - Simplified MemoViewContext to only include essential data. - Enhanced error handling in various components by introducing a centralized error handling utility. - Improved type safety across components and hooks by refining TypeScript definitions. - Updated remark plugins to enhance tag parsing and preserve node types. --- web/biome.json | 2 +- .../components/ChangeMemberPasswordDialog.tsx | 8 +- .../components/CreateAccessTokenDialog.tsx | 10 +- .../CreateIdentityProviderDialog.tsx | 8 +- web/src/components/CreateShortcutDialog.tsx | 69 +++++----- web/src/components/CreateUserDialog.tsx | 10 +- web/src/components/CreateWebhookDialog.tsx | 10 +- .../components/Inbox/MemoCommentMessage.tsx | 7 +- web/src/components/MemoActionMenu/hooks.ts | 9 +- .../MemoContent/ConditionalComponent.tsx | 33 +++-- .../MemoContent/MemoContentContext.tsx | 14 --- web/src/components/MemoContent/Tag.tsx | 22 ++-- .../components/MemoContent/TaskListItem.tsx | 33 ++--- web/src/components/MemoContent/index.tsx | 119 ++++++++---------- web/src/components/MemoContent/types.ts | 4 - web/src/components/MemoEditor/index.tsx | 8 +- web/src/components/MemoView/MemoView.tsx | 35 +----- .../components/MemoView/MemoViewContext.tsx | 53 +++++--- .../MemoView/components/MemoBody.tsx | 10 +- .../MemoView/components/MemoHeader.tsx | 8 +- .../MemoView/hooks/useMemoActions.ts | 8 +- web/src/components/PasswordSignInForm.tsx | 7 +- .../components/Settings/InstanceSection.tsx | 10 +- .../Settings/MemoRelatedSettings.tsx | 8 +- web/src/components/Settings/SSOSection.tsx | 8 +- web/src/components/Settings/SettingTable.tsx | 24 ++-- .../components/Settings/StorageSection.tsx | 14 ++- web/src/components/UpdateAccountDialog.tsx | 8 +- .../UpdateCustomizedProfileDialog.tsx | 7 +- web/src/connect.ts | 2 +- web/src/hooks/useNavigateTo.ts | 4 +- web/src/i18n.ts | 2 +- web/src/lib/error.ts | 38 ++++++ web/src/pages/Attachments.tsx | 27 ++-- web/src/pages/AuthCallback.tsx | 15 ++- web/src/pages/SignIn.tsx | 7 +- web/src/pages/SignUp.tsx | 7 +- web/src/types/common.ts | 13 ++ web/src/types/markdown.ts | 44 +++++++ web/src/utils/auth-redirect.ts | 2 +- web/src/utils/i18n.ts | 2 +- .../remark-plugins/remark-disable-setext.ts | 24 +--- .../remark-plugins/remark-preserve-type.ts | 16 ++- web/src/utils/remark-plugins/remark-tag.ts | 84 +++++-------- 44 files changed, 456 insertions(+), 397 deletions(-) delete mode 100644 web/src/components/MemoContent/MemoContentContext.tsx create mode 100644 web/src/lib/error.ts create mode 100644 web/src/types/common.ts create mode 100644 web/src/types/markdown.ts diff --git a/web/biome.json b/web/biome.json index 6ab658f85..43bff5738 100644 --- a/web/biome.json +++ b/web/biome.json @@ -84,7 +84,7 @@ "noDuplicateObjectKeys": "error", "noDuplicateParameters": "error", "noEmptyBlockStatements": "off", - "noExplicitAny": "off", + "noExplicitAny": "error", "noExtraNonNullAssertion": "error", "noFallthroughSwitchClause": "error", "noFunctionAssign": "error", diff --git a/web/src/components/ChangeMemberPasswordDialog.tsx b/web/src/components/ChangeMemberPasswordDialog.tsx index 385696eee..cd45b844a 100644 --- a/web/src/components/ChangeMemberPasswordDialog.tsx +++ b/web/src/components/ChangeMemberPasswordDialog.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useUpdateUser } from "@/hooks/useUserQueries"; +import { handleError } from "@/lib/error"; import { User } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -60,9 +61,10 @@ function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Pro toast(t("message.password-changed")); onSuccess?.(); onOpenChange(false); - } catch (error: any) { - console.error(error); - toast.error(error.message); + } catch (error: unknown) { + await handleError(error, toast.error, { + context: "Change member password", + }); } }; diff --git a/web/src/components/CreateAccessTokenDialog.tsx b/web/src/components/CreateAccessTokenDialog.tsx index 6e0cad3b6..4b23dff3c 100644 --- a/web/src/components/CreateAccessTokenDialog.tsx +++ b/web/src/components/CreateAccessTokenDialog.tsx @@ -8,6 +8,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { userServiceClient } from "@/connect"; import useCurrentUser from "@/hooks/useCurrentUser"; import useLoading from "@/hooks/useLoading"; +import { handleError } from "@/lib/error"; import { CreatePersonalAccessTokenResponse } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -83,10 +84,11 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) { requestState.setFinish(); onSuccess(response); onOpenChange(false); - } catch (error: any) { - toast.error(error.message); - console.error(error); - requestState.setError(); + } catch (error: unknown) { + handleError(error, toast.error, { + context: "Create access token", + onError: () => requestState.setError(), + }); } }; diff --git a/web/src/components/CreateIdentityProviderDialog.tsx b/web/src/components/CreateIdentityProviderDialog.tsx index 3e2665837..da4ca5386 100644 --- a/web/src/components/CreateIdentityProviderDialog.tsx +++ b/web/src/components/CreateIdentityProviderDialog.tsx @@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Separator } from "@/components/ui/separator"; import { identityProviderServiceClient } from "@/connect"; import { absolutifyLink } from "@/helpers/utils"; +import { handleError } from "@/lib/error"; import { FieldMapping, FieldMappingSchema, @@ -288,9 +289,10 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on }); toast.success(t("setting.sso-section.sso-updated", { name: basicInfo.title })); } - } catch (error: any) { - toast.error(error.message); - console.error(error); + } catch (error: unknown) { + await handleError(error, toast.error, { + context: isCreating ? "Create identity provider" : "Update identity provider", + }); } onSuccess?.(); onOpenChange(false); diff --git a/web/src/components/CreateShortcutDialog.tsx b/web/src/components/CreateShortcutDialog.tsx index cc0623bed..5d4675c3d 100644 --- a/web/src/components/CreateShortcutDialog.tsx +++ b/web/src/components/CreateShortcutDialog.tsx @@ -1,6 +1,6 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -11,6 +11,7 @@ import { shortcutServiceClient } from "@/connect"; import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import useLoading from "@/hooks/useLoading"; +import { handleError } from "@/lib/error"; import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -33,31 +34,34 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o }), ); const requestState = useLoading(false); - const isCreating = !initialShortcut; + const isCreating = shortcut.name === ""; useEffect(() => { - if (initialShortcut) { - setShortcut( - create(ShortcutSchema, { - name: initialShortcut.name, - title: initialShortcut.title, - filter: initialShortcut.filter, - }), - ); - } else { - setShortcut(create(ShortcutSchema, { name: "", title: "", filter: "" })); + if (shortcut.name) { + setShortcut(shortcut); } - }, [initialShortcut]); + }, [shortcut.name, shortcut.title, shortcut.filter]); const onShortcutTitleChange = (e: React.ChangeEvent) => { - setShortcut({ ...shortcut, title: e.target.value }); + setPartialState({ + title: e.target.value, + }); }; const onShortcutFilterChange = (e: React.ChangeEvent) => { - setShortcut({ ...shortcut, filter: e.target.value }); + setPartialState({ + filter: e.target.value, + }); }; - const handleConfirm = async () => { + const setPartialState = (partialState: Partial) => { + setShortcut({ + ...shortcut, + ...partialState, + }); + }; + + const handleSaveBtnClick = async () => { if (!shortcut.title || !shortcut.filter) { toast.error("Title and filter cannot be empty"); return; @@ -69,7 +73,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o await shortcutServiceClient.createShortcut({ parent: user?.name, shortcut: { - name: "", // Will be set by server + name: "", title: shortcut.title, filter: shortcut.filter, }, @@ -79,21 +83,21 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o await shortcutServiceClient.updateShortcut({ shortcut: { ...shortcut, - name: initialShortcut!.name, // Keep the original resource name + name: initialShortcut!.name, }, updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }), }); toast.success("Update shortcut successfully"); } - // Refresh shortcuts. await refetchSettings(); requestState.setFinish(); onSuccess?.(); onOpenChange(false); - } catch (error: any) { - console.error(error); - toast.error(error.message); - requestState.setError(); + } catch (error: unknown) { + await handleError(error, toast.error, { + context: isCreating ? "Create shortcut" : "Update shortcut", + onError: () => requestState.setError(), + }); } }; @@ -118,28 +122,13 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o onChange={onShortcutFilterChange} /> -
-

{t("common.learn-more")}:

- -
- diff --git a/web/src/components/CreateUserDialog.tsx b/web/src/components/CreateUserDialog.tsx index ece2cb448..5044bb009 100644 --- a/web/src/components/CreateUserDialog.tsx +++ b/web/src/components/CreateUserDialog.tsx @@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { userServiceClient } from "@/connect"; import useLoading from "@/hooks/useLoading"; +import { handleError } from "@/lib/error"; import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -68,10 +69,11 @@ function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: requestState.setFinish(); onSuccess?.(); onOpenChange(false); - } catch (error: any) { - console.error(error); - toast.error(error.message); - requestState.setError(); + } catch (error: unknown) { + handleError(error, toast.error, { + context: user ? "Update user" : "Create user", + onError: () => requestState.setError(), + }); } }; diff --git a/web/src/components/CreateWebhookDialog.tsx b/web/src/components/CreateWebhookDialog.tsx index 47b831cdb..f1a9fe11f 100644 --- a/web/src/components/CreateWebhookDialog.tsx +++ b/web/src/components/CreateWebhookDialog.tsx @@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label"; import { userServiceClient } from "@/connect"; import useCurrentUser from "@/hooks/useCurrentUser"; import useLoading from "@/hooks/useLoading"; +import { handleError } from "@/lib/error"; import { useTranslate } from "@/utils/i18n"; interface Props { @@ -107,10 +108,11 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro onSuccess?.(); onOpenChange(false); requestState.setFinish(); - } catch (error: any) { - console.error(error); - toast.error(error.message); - requestState.setError(); + } catch (error: unknown) { + handleError(error, toast.error, { + context: webhookName ? "Update webhook" : "Create webhook", + onError: () => requestState.setError(), + }); } }; diff --git a/web/src/components/Inbox/MemoCommentMessage.tsx b/web/src/components/Inbox/MemoCommentMessage.tsx index 39c711fd9..2f62debcc 100644 --- a/web/src/components/Inbox/MemoCommentMessage.tsx +++ b/web/src/components/Inbox/MemoCommentMessage.tsx @@ -8,6 +8,7 @@ import { activityServiceClient, memoServiceClient, userServiceClient } from "@/c import { activityNamePrefix } from "@/helpers/resource-names"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import useNavigateTo from "@/hooks/useNavigateTo"; +import { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; @@ -56,8 +57,10 @@ function MemoCommentMessage({ notification }: Props) { setInitialized(true); } } catch (error) { - console.error("Failed to fetch activity:", error); - setHasError(true); + handleError(error, () => {}, { + context: "Failed to fetch activity", + onError: () => setHasError(true), + }); return; } }, [notification.activityId]); diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index b0fecaabb..18db5b28d 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -7,6 +7,7 @@ import { useInstance } from "@/contexts/InstanceContext"; import { useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; import { userKeys } from "@/hooks/useUserQueries"; +import { handleError } from "@/lib/error"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -53,6 +54,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe }, [onEdit]); const handleToggleMemoStatusClick = useCallback(async () => { + const isArchiving = memo.state !== State.ARCHIVED; const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED; const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully"); @@ -66,9 +68,10 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe }); toast.success(message); } catch (error: unknown) { - const err = error as { details?: string }; - toast.error(err.details || "An error occurred"); - console.error(error); + handleError(error, toast.error, { + context: `${isArchiving ? "Archive" : "Restore"} memo`, + fallbackMessage: "An error occurred", + }); return; } diff --git a/web/src/components/MemoContent/ConditionalComponent.tsx b/web/src/components/MemoContent/ConditionalComponent.tsx index 7fcce0647..4ae417909 100644 --- a/web/src/components/MemoContent/ConditionalComponent.tsx +++ b/web/src/components/MemoContent/ConditionalComponent.tsx @@ -1,11 +1,22 @@ +import type { Element } from "hast"; import React from "react"; +import { isTagElement, isTaskListItemElement } from "@/types/markdown"; -export const createConditionalComponent =

>( +/** + * Creates a conditional component that renders different components + * based on AST node type detection + * + * @param CustomComponent - Custom component to render when condition matches + * @param DefaultComponent - Default component/element to render otherwise + * @param condition - Function to test AST node + * @returns Conditional wrapper component + */ +export const createConditionalComponent =

>( CustomComponent: React.ComponentType

, DefaultComponent: React.ComponentType

| keyof JSX.IntrinsicElements, - condition: (node: any) => boolean, + condition: (node: Element) => boolean, ) => { - return (props: P & { node?: any }) => { + return (props: P & { node?: Element }) => { const { node, ...restProps } = props; // Check AST node to determine which component to use @@ -21,17 +32,5 @@ export const createConditionalComponent =

>( }; }; -// Condition checkers for AST node types -export const isTagNode = (node: any): boolean => { - // Check preserved mdast type first - if (node?.data?.mdastType === "tagNode") { - return true; - } - // Fallback: check hast properties - return node?.properties?.className?.includes?.("tag") || false; -}; - -export const isTaskListItemNode = (node: any): boolean => { - // Task list checkboxes are standard GFM - check element type - return node?.properties?.type === "checkbox" || false; -}; +// Re-export type guards for convenience +export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode }; diff --git a/web/src/components/MemoContent/MemoContentContext.tsx b/web/src/components/MemoContent/MemoContentContext.tsx deleted file mode 100644 index 320f9f87f..000000000 --- a/web/src/components/MemoContent/MemoContentContext.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext } from "react"; - -export interface MemoContentContextType { - memoName?: string; - readonly: boolean; - disableFilter?: boolean; - parentPage?: string; - containerRef?: React.RefObject; -} - -export const MemoContentContext = createContext({ - readonly: true, - disableFilter: false, -}); diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index 3a17c0f6f..5fa87d753 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -1,19 +1,19 @@ -import { useContext } from "react"; +import type { Element } from "hast"; import { useLocation } from "react-router-dom"; import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import useNavigateTo from "@/hooks/useNavigateTo"; import { cn } from "@/lib/utils"; import { Routes } from "@/router"; -import { MemoContentContext } from "./MemoContentContext"; +import { useMemoViewContext } from "../MemoView/MemoViewContext"; interface TagProps extends React.HTMLAttributes { - node?: any; // AST node from react-markdown + node?: Element; // AST node from react-markdown "data-tag"?: string; children?: React.ReactNode; } export const Tag: React.FC = ({ "data-tag": dataTag, children, className, ...props }) => { - const context = useContext(MemoContentContext); + const { parentPage } = useMemoViewContext(); const location = useLocation(); const navigateTo = useNavigateTo(); const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext(); @@ -23,13 +23,9 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa const handleTagClick = (e: React.MouseEvent) => { e.stopPropagation(); - if (context.disableFilter) { - return; - } - // If the tag is clicked in a memo detail page, we should navigate to the memo list page. if (location.pathname.startsWith("/m")) { - const pathname = context.parentPage || Routes.ROOT; + const pathname = parentPage || Routes.ROOT; const searchParams = new URLSearchParams(); searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }])); @@ -52,13 +48,9 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa return ( {children} diff --git a/web/src/components/MemoContent/TaskListItem.tsx b/web/src/components/MemoContent/TaskListItem.tsx index 3acdc7775..07f06764a 100644 --- a/web/src/components/MemoContent/TaskListItem.tsx +++ b/web/src/components/MemoContent/TaskListItem.tsx @@ -1,25 +1,24 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useContext, useRef } from "react"; +import type { Element } from "hast"; +import { useRef } from "react"; import { Checkbox } from "@/components/ui/checkbox"; -import { memoKeys, useUpdateMemo } from "@/hooks/useMemoQueries"; -import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import { useUpdateMemo } from "@/hooks/useMemoQueries"; import { toggleTaskAtIndex } from "@/utils/markdown-manipulation"; -import { MemoContentContext } from "./MemoContentContext"; +import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext"; interface TaskListItemProps extends React.InputHTMLAttributes { - node?: any; // AST node from react-markdown + node?: Element; // AST node from react-markdown checked?: boolean; } export const TaskListItem: React.FC = ({ checked, ...props }) => { - const context = useContext(MemoContentContext); + const { memo } = useMemoViewContext(); + const { readonly } = useMemoViewDerived(); const checkboxRef = useRef(null); - const queryClient = useQueryClient(); const { mutate: updateMemo } = useUpdateMemo(); const handleChange = async (newChecked: boolean) => { - // Don't update if readonly or no memo context - if (context.readonly || !context.memoName) { + // Don't update if readonly or no memo + if (readonly || !memo) { return; } @@ -37,8 +36,8 @@ export const TaskListItem: React.FC = ({ checked, ...props }) taskIndex = parseInt(taskIndexStr); } else { // Fallback: Calculate index by counting ALL task list items in the memo - // Use the container ref from context for proper scoping - const container = context.containerRef?.current; + // Find the markdown-content container by traversing up from the list item + const container = listItem.closest(".markdown-content"); if (!container) { return; } @@ -53,11 +52,6 @@ export const TaskListItem: React.FC = ({ checked, ...props }) } // Update memo content using the string manipulation utility - const memo = queryClient.getQueryData(memoKeys.detail(context.memoName)); - if (!memo) { - return; - } - const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked); updateMemo({ update: { @@ -69,8 +63,5 @@ export const TaskListItem: React.FC = ({ checked, ...props }) }; // Override the disabled prop from remark-gfm (which defaults to true) - // We want interactive checkboxes, only disabled when readonly - return ( - - ); + return ; }; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 382a7dd40..971c440bd 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from "@tanstack/react-query"; +import type { Element } from "hast"; import { memo } from "react"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; @@ -7,94 +7,85 @@ import rehypeSanitize from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; -import useCurrentUser from "@/hooks/useCurrentUser"; -import { memoKeys } from "@/hooks/useMemoQueries"; import { cn } from "@/lib/utils"; -import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; import { remarkTag } from "@/utils/remark-plugins/remark-tag"; -import { isSuperUser } from "@/utils/user"; import { CodeBlock } from "./CodeBlock"; -import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent"; +import { isTagNode, isTaskListItemNode } from "./ConditionalComponent"; import { SANITIZE_SCHEMA } from "./constants"; import { useCompactLabel, useCompactMode } from "./hooks"; -import { MemoContentContext } from "./MemoContentContext"; import { Tag } from "./Tag"; import { TaskListItem } from "./TaskListItem"; import type { MemoContentProps } from "./types"; const MemoContent = (props: MemoContentProps) => { - const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props; + const { className, contentClassName, content, onClick, onDoubleClick } = props; const t = useTranslate(); - const currentUser = useCurrentUser(); - const queryClient = useQueryClient(); const { containerRef: memoContentContainerRef, mode: showCompactMode, toggle: toggleCompactMode, } = useCompactMode(Boolean(props.compact)); - const memo = memoName ? queryClient.getQueryData(memoKeys.detail(memoName)) : null; - const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser)); - - const contextValue = { - memoName, - readonly: !allowEdit, - disableFilter: props.disableFilter, - parentPage: props.parentPage, - containerRef: memoContentContainerRef, - }; const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string); return ( - -

-
+
+ & { node?: Element }) => { + if (inputProps.node && isTaskListItemNode(inputProps.node)) { + return ; + } + return ; + }) as React.ComponentType>, + span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => { + if (spanProps.node && isTagNode(spanProps.node)) { + return ; + } + return ; + }) as React.ComponentType>, + pre: CodeBlock, + a: ({ href, children, ...aProps }) => ( + + {children} + + ), + }} > - ( - - {children} - - ), - }} - > - {content} - -
- {showCompactMode === "ALL" && ( -
- )} - {showCompactMode !== undefined && ( -
- -
- )} + {content} +
- + {showCompactMode === "ALL" && ( +
+ )} + {showCompactMode !== undefined && ( +
+ +
+ )} +
); }; diff --git a/web/src/components/MemoContent/types.ts b/web/src/components/MemoContent/types.ts index 00ddc5563..ed46b1cfe 100644 --- a/web/src/components/MemoContent/types.ts +++ b/web/src/components/MemoContent/types.ts @@ -2,15 +2,11 @@ import type React from "react"; export interface MemoContentProps { content: string; - memoName?: string; compact?: boolean; - readonly?: boolean; - disableFilter?: boolean; className?: string; contentClassName?: string; onClick?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void; - parentPage?: string; } export type ContentCompactView = "ALL" | "SNIPPET"; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index ba5b74ad8..99e8ac88c 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -4,6 +4,7 @@ import { toast } from "react-hot-toast"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; +import { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components"; @@ -138,9 +139,10 @@ const MemoEditorImpl: React.FC = ({ toast.success("Saved successfully"); } catch (error) { - const errorMessage = errorService.getErrorMessage(error); - toast.error(errorMessage); - console.error("Failed to save memo:", error); + handleError(error, toast.error, { + context: "Failed to save memo", + fallbackMessage: errorService.getErrorMessage(error), + }); } finally { dispatch(actions.setLoading("saving", false)); } diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index b8681625c..68ec54f94 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -55,11 +55,8 @@ const MemoView: React.FC = (props: Props) => { const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); const creator = useMemoCreator(memoData.creator); - const { commentAmount, relativeTimeFormat, isArchived, readonly, isInMemoDetailPage, parentPage } = useMemoViewDerivedState( - memoData, - props.parentPage, - ); - const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent); + const { isArchived, readonly, parentPage } = useMemoViewDerivedState(memoData, props.parentPage); + const { showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent); const { previewState, openPreview, setPreviewOpen } = useImagePreview(); const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor(); const { archiveMemo, unpinMemo } = useMemoActions(memoData); @@ -79,37 +76,15 @@ const MemoView: React.FC = (props: Props) => { onArchive: archiveMemo, }); - // Memoize static values that rarely change - const staticContextValue = useMemo( + // Minimal essential context - only non-derivable data + const contextValue = useMemo( () => ({ memo: memoData, creator, - isArchived, - readonly, - isInMemoDetailPage, parentPage, - }), - [memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage], - ); - - // Memoize dynamic values separately - const dynamicContextValue = useMemo( - () => ({ - commentAmount, - relativeTimeFormat, - nsfw, showNSFWContent, }), - [commentAmount, relativeTimeFormat, nsfw, showNSFWContent], - ); - - // Combine context values - const contextValue = useMemo( - () => ({ - ...staticContextValue, - ...dynamicContextValue, - }), - [staticContextValue, dynamicContextValue], + [memoData, creator, parentPage, showNSFWContent], ); if (showEditor) { diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index b16f7a37f..a9134109d 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -1,27 +1,22 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; import { createContext, useContext } from "react"; +import { useLocation } from "react-router-dom"; +import useCurrentUser from "@/hooks/useCurrentUser"; +import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb"; +import { isSuperUser } from "@/utils/user"; +import { RELATIVE_TIME_THRESHOLD_MS } from "./constants"; -// Stable values that rarely change -export interface MemoViewStaticContextValue { +// Minimal essential context - only data that cannot be easily derived +export interface MemoViewContextValue { memo: Memo; creator: User | undefined; - isArchived: boolean; - readonly: boolean; - isInMemoDetailPage: boolean; parentPage: string; -} - -// Dynamic values that change frequently -export interface MemoViewDynamicContextValue { - commentAmount: number; - relativeTimeFormat: "datetime" | "auto"; - nsfw: boolean; showNSFWContent: boolean; } -export interface MemoViewContextValue extends MemoViewStaticContextValue, MemoViewDynamicContextValue {} - export const MemoViewContext = createContext(null); export const useMemoViewContext = (): MemoViewContextValue => { @@ -31,3 +26,33 @@ export const useMemoViewContext = (): MemoViewContextValue => { } return context; }; + +// Utility hooks to derive common values from context +export const useMemoViewDerived = () => { + const { memo } = useMemoViewContext(); + const location = useLocation(); + const currentUser = useCurrentUser(); + + const isArchived = memo.state === State.ARCHIVED; + const readonly = memo.creator !== currentUser?.name && !isSuperUser(currentUser); + const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`); + + const commentAmount = memo.relations.filter( + (relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name, + ).length; + + const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined; + const relativeTimeFormat: "datetime" | "auto" = + displayTime && Date.now() - displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto"; + + const nsfw = memo.tags.some((tag) => tag.toLowerCase() === "nsfw"); + + return { + isArchived, + readonly, + isInMemoDetailPage, + commentAmount, + relativeTimeFormat, + nsfw, + }; +}; diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index d17d16080..dd636f45c 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -4,7 +4,7 @@ import { useTranslate } from "@/utils/i18n"; import MemoContent from "../../MemoContent"; import { MemoReactionListView } from "../../MemoReactionListView"; import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata"; -import { useMemoViewContext } from "../MemoViewContext"; +import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext"; interface Props { compact?: boolean; @@ -16,8 +16,9 @@ interface Props { const MemoBody: React.FC = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => { const t = useTranslate(); - // Get shared state from context - const { memo, readonly, parentPage, nsfw, showNSFWContent } = useMemoViewContext(); + // Get essential context and derive other values + const { memo, parentPage, showNSFWContent } = useMemoViewContext(); + const { nsfw } = useMemoViewDerived(); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); @@ -31,13 +32,10 @@ const MemoBody: React.FC = ({ compact, onContentClick, onContentDoubleCli > {memo.location && } diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index bb3a782bb..85a781c82 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -12,7 +12,7 @@ import MemoActionMenu from "../../MemoActionMenu"; import { ReactionSelector } from "../../MemoReactionListView"; import UserAvatar from "../../UserAvatar"; import VisibilityIcon from "../../VisibilityIcon"; -import { useMemoViewContext } from "../MemoViewContext"; +import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext"; interface Props { showCreator?: boolean; @@ -39,9 +39,9 @@ const MemoHeader: React.FC = ({ }) => { const t = useTranslate(); - // Get shared state from context - const { memo, creator, isArchived, commentAmount, isInMemoDetailPage, parentPage, readonly, relativeTimeFormat, nsfw, showNSFWContent } = - useMemoViewContext(); + // Get essential context and derive other values + const { memo, creator, parentPage, showNSFWContent } = useMemoViewContext(); + const { isArchived, readonly, isInMemoDetailPage, commentAmount, relativeTimeFormat, nsfw } = useMemoViewDerived(); const displayTime = isArchived ? ( (memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language) diff --git a/web/src/components/MemoView/hooks/useMemoActions.ts b/web/src/components/MemoView/hooks/useMemoActions.ts index 6e5be2cb9..223794de4 100644 --- a/web/src/components/MemoView/hooks/useMemoActions.ts +++ b/web/src/components/MemoView/hooks/useMemoActions.ts @@ -1,5 +1,6 @@ import toast from "react-hot-toast"; import { useUpdateMemo } from "@/hooks/useMemoQueries"; +import { handleError } from "@/lib/error"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -15,9 +16,10 @@ export const useMemoActions = (memo: Memo) => { await updateMemo({ update: { name: memo.name, state: State.ARCHIVED }, updateMask: ["state"] }); toast.success(t("message.archived-successfully")); } catch (error: unknown) { - console.error(error); - const err = error as { details?: string }; - toast.error(err?.details || "Failed to archive memo"); + handleError(error, toast.error, { + context: "Archive memo", + fallbackMessage: "Failed to archive memo", + }); } }; diff --git a/web/src/components/PasswordSignInForm.tsx b/web/src/components/PasswordSignInForm.tsx index f870775da..a148fb3af 100644 --- a/web/src/components/PasswordSignInForm.tsx +++ b/web/src/components/PasswordSignInForm.tsx @@ -10,6 +10,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { useInstance } from "@/contexts/InstanceContext"; import useLoading from "@/hooks/useLoading"; import useNavigateTo from "@/hooks/useNavigateTo"; +import { handleError } from "@/lib/error"; import { useTranslate } from "@/utils/i18n"; function PasswordSignInForm() { @@ -60,9 +61,9 @@ function PasswordSignInForm() { await initialize(); navigateTo("/"); } catch (error: unknown) { - console.error(error); - const message = error instanceof Error ? error.message : "Failed to sign in."; - toast.error(message); + handleError(error, toast.error, { + fallbackMessage: "Failed to sign in.", + }); } actionBtnLoadingState.setFinish(); }; diff --git a/web/src/components/Settings/InstanceSection.tsx b/web/src/components/Settings/InstanceSection.tsx index 366b95b35..f136ce537 100644 --- a/web/src/components/Settings/InstanceSection.tsx +++ b/web/src/components/Settings/InstanceSection.tsx @@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea"; import { identityProviderServiceClient } from "@/connect"; import { useInstance } from "@/contexts/InstanceContext"; import useDialog from "@/hooks/useDialog"; +import { handleError } from "@/lib/error"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb"; import { InstanceSetting_GeneralSetting, @@ -58,9 +59,10 @@ const InstanceSection = () => { }), ); await fetchSetting(InstanceSetting_Key.GENERAL); - } catch (error: any) { - toast.error(error.message); - console.error(error); + } catch (error: unknown) { + await handleError(error, toast.error, { + context: "Update general settings", + }); return; } toast.success(t("message.update-succeed")); @@ -107,7 +109,7 @@ const InstanceSection = () => { - + { ); await fetchSetting(InstanceSetting_Key.MEMO_RELATED); toast.success(t("message.update-succeed")); - } catch (error: any) { - toast.error(error.message); - console.error(error); + } catch (error: unknown) { + await handleError(error, toast.error, { + context: "Update memo-related settings", + }); } }; diff --git a/web/src/components/Settings/SSOSection.tsx b/web/src/components/Settings/SSOSection.tsx index 0e7f9634e..426e43820 100644 --- a/web/src/components/Settings/SSOSection.tsx +++ b/web/src/components/Settings/SSOSection.tsx @@ -5,6 +5,7 @@ import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { identityProviderServiceClient } from "@/connect"; +import { handleError } from "@/lib/error"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb"; import { useTranslate } from "@/utils/i18n"; import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog"; @@ -36,9 +37,10 @@ const SSOSection = () => { if (!deleteTarget) return; try { await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name }); - } catch (error: any) { - console.error(error); - toast.error(error.message); + } catch (error: unknown) { + handleError(error, toast.error, { + context: "Delete identity provider", + }); } await fetchIdentityProviderList(); setDeleteTarget(undefined); diff --git a/web/src/components/Settings/SettingTable.tsx b/web/src/components/Settings/SettingTable.tsx index 3fd5e5ecc..fac859ba2 100644 --- a/web/src/components/Settings/SettingTable.tsx +++ b/web/src/components/Settings/SettingTable.tsx @@ -1,22 +1,28 @@ import React from "react"; import { cn } from "@/lib/utils"; -interface SettingTableColumn { +interface SettingTableColumn> { key: string; header: string; className?: string; - render?: (value: any, row: any) => React.ReactNode; + render?: (value: T[keyof T], row: T) => React.ReactNode; } -interface SettingTableProps { - columns: SettingTableColumn[]; - data: any[]; +interface SettingTableProps> { + columns: SettingTableColumn[]; + data: T[]; emptyMessage?: string; className?: string; - getRowKey?: (row: any, index: number) => string; + getRowKey?: (row: T, index: number) => string; } -const SettingTable: React.FC = ({ columns, data, emptyMessage = "No data", className, getRowKey }) => { +const SettingTable = >({ + columns, + data, + emptyMessage = "No data", + className, + getRowKey, +}: SettingTableProps) => { return (
@@ -43,8 +49,8 @@ const SettingTable: React.FC = ({ columns, data, emptyMessage return ( {columns.map((column) => { - const value = row[column.key]; - const content = column.render ? column.render(value, row) : value; + const value = row[column.key as keyof T] as T[keyof T]; + const content = column.render ? column.render(value, row) : (value as React.ReactNode); return ( {content} diff --git a/web/src/components/Settings/StorageSection.tsx b/web/src/components/Settings/StorageSection.tsx index 6371fd608..56b325f7d 100644 --- a/web/src/components/Settings/StorageSection.tsx +++ b/web/src/components/Settings/StorageSection.tsx @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; import { useInstance } from "@/contexts/InstanceContext"; +import { handleError } from "@/lib/error"; import { InstanceSetting_Key, InstanceSetting_StorageSetting, @@ -141,9 +142,10 @@ const StorageSection = () => { ); await fetchSetting(InstanceSetting_Key.STORAGE); toast.success("Updated"); - } catch (error: any) { - toast.error(error.message); - console.error(error); + } catch (error: unknown) { + handleError(error, toast.error, { + context: "Update storage settings", + }); } }; @@ -223,7 +225,11 @@ const StorageSection = () => { handleS3ConfigUsePathStyleChanged({ target: { checked } } as any)} + onCheckedChange={(checked) => + handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent & { + target: { checked: boolean }; + }) + } /> diff --git a/web/src/components/UpdateAccountDialog.tsx b/web/src/components/UpdateAccountDialog.tsx index c75026528..074b2b5cd 100644 --- a/web/src/components/UpdateAccountDialog.tsx +++ b/web/src/components/UpdateAccountDialog.tsx @@ -11,6 +11,7 @@ import { useInstance } from "@/contexts/InstanceContext"; import { convertFileToBase64 } from "@/helpers/utils"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useUpdateUser } from "@/hooks/useUserQueries"; +import { handleError } from "@/lib/error"; import { useTranslate } from "@/utils/i18n"; import UserAvatar from "./UserAvatar"; @@ -141,9 +142,10 @@ function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) { toast.success(t("message.update-succeed")); onSuccess?.(); onOpenChange(false); - } catch (error: any) { - console.error(error); - toast.error(error.message); + } catch (error: unknown) { + await handleError(error, toast.error, { + context: "Update account", + }); } }; diff --git a/web/src/components/UpdateCustomizedProfileDialog.tsx b/web/src/components/UpdateCustomizedProfileDialog.tsx index 041ab7a06..46ea3edc3 100644 --- a/web/src/components/UpdateCustomizedProfileDialog.tsx +++ b/web/src/components/UpdateCustomizedProfileDialog.tsx @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useInstance } from "@/contexts/InstanceContext"; import { buildInstanceSettingName } from "@/helpers/resource-names"; +import { handleError } from "@/lib/error"; import { InstanceSetting_GeneralSetting_CustomProfile, InstanceSetting_GeneralSetting_CustomProfileSchema, @@ -92,8 +93,10 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props) onSuccess?.(); onOpenChange(false); } catch (error) { - console.error(error); - toast.error("Failed to update profile"); + handleError(error, toast.error, { + context: "Update customized profile", + fallbackMessage: "Failed to update profile", + }); } finally { setIsLoading(false); } diff --git a/web/src/connect.ts b/web/src/connect.ts index 1a8cb7177..e59a6964e 100644 --- a/web/src/connect.ts +++ b/web/src/connect.ts @@ -127,7 +127,7 @@ const authInterceptor: Interceptor = (next) => async (req) => { const transport = createConnectTransport({ baseUrl: window.location.origin, - useBinaryFormat: true, + useBinaryFormat: false, fetch: fetchWithCredentials, interceptors: [authInterceptor], }); diff --git a/web/src/hooks/useNavigateTo.ts b/web/src/hooks/useNavigateTo.ts index ad743cd6d..916f47105 100644 --- a/web/src/hooks/useNavigateTo.ts +++ b/web/src/hooks/useNavigateTo.ts @@ -4,8 +4,8 @@ const useNavigateTo = () => { const navigateTo = useNavigate(); const navigateToWithViewTransition = (to: string, options?: NavigateOptions) => { - const document = window.document as any; - if (!document.startViewTransition) { + const doc = window.document as unknown as Document & { startViewTransition?: (callback: () => void) => void }; + if (!doc.startViewTransition) { navigateTo(to, options); } else { document.startViewTransition(() => { diff --git a/web/src/i18n.ts b/web/src/i18n.ts index 0a540d7bb..1febe5953 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -51,7 +51,7 @@ const LazyImportPlugin: BackendModule = { read: function (language, _, callback) { const matchedLanguage = findNearestMatchedLanguage(language); import(`./locales/${matchedLanguage}.json`) - .then((translation: any) => { + .then((translation: Record) => { callback(null, translation); }) .catch(() => { diff --git a/web/src/lib/error.ts b/web/src/lib/error.ts new file mode 100644 index 000000000..ded8b4954 --- /dev/null +++ b/web/src/lib/error.ts @@ -0,0 +1,38 @@ +export function getErrorMessage(error: unknown, fallback = "Unknown error"): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + if (error && typeof error === "object" && "message" in error) { + return String(error.message); + } + + return fallback; +} + +export function handleError( + error: unknown, + toast: (message: string) => void, + options?: { + context?: string; + fallbackMessage?: string; + onError?: (error: unknown) => void; + }, +): void { + const contextPrefix = options?.context ? `${options.context}: ` : ""; + const fallback = options?.fallbackMessage; + + const errorMessage = options?.context ? `${contextPrefix}${getErrorMessage(error, fallback)}` : getErrorMessage(error, fallback); + + console.error(error); + toast(errorMessage); + options?.onError?.(error); +} + +export function isError(value: unknown): value is Error { + return value instanceof Error; +} diff --git a/web/src/pages/Attachments.tsx b/web/src/pages/Attachments.tsx index 8e26c8d5c..21be4edb4 100644 --- a/web/src/pages/Attachments.tsx +++ b/web/src/pages/Attachments.tsx @@ -17,6 +17,7 @@ import useDialog from "@/hooks/useDialog"; import useLoading from "@/hooks/useLoading"; import useMediaQuery from "@/hooks/useMediaQuery"; import i18n from "@/i18n"; +import { handleError } from "@/lib/error"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -98,8 +99,10 @@ const Attachments = () => { setAttachments(fetchedAttachments); setNextPageToken(nextPageToken ?? ""); } catch (error) { - console.error("Failed to fetch attachments:", error); - toast.error("Failed to load attachments. Please try again."); + handleError(error, toast.error, { + context: "Failed to fetch attachments", + fallbackMessage: "Failed to load attachments. Please try again.", + }); } finally { loadingState.setFinish(); } @@ -122,8 +125,10 @@ const Attachments = () => { setAttachments((prev) => [...prev, ...fetchedAttachments]); setNextPageToken(newPageToken ?? ""); } catch (error) { - console.error("Failed to load more attachments:", error); - toast.error("Failed to load more attachments. Please try again."); + handleError(error, toast.error, { + context: "Failed to load more attachments", + fallbackMessage: "Failed to load more attachments. Please try again.", + }); } finally { setIsLoadingMore(false); } @@ -140,9 +145,11 @@ const Attachments = () => { setNextPageToken(nextPageToken ?? ""); loadingState.setFinish(); } catch (error) { - console.error("Failed to refetch attachments:", error); - loadingState.setError(); - toast.error("Failed to refresh attachments. Please try again."); + handleError(error, toast.error, { + context: "Failed to refetch attachments", + fallbackMessage: "Failed to refresh attachments. Please try again.", + onError: () => loadingState.setError(), + }); } }, [loadingState]); @@ -152,8 +159,10 @@ const Attachments = () => { await Promise.all(unusedAttachments.map((attachment) => deleteAttachment(attachment.name))); toast.success(t("resource.delete-all-unused-success")); } catch (error) { - console.error("Failed to delete unused attachments:", error); - toast.error(t("resource.delete-all-unused-error")); + handleError(error, toast.error, { + context: "Failed to delete unused attachments", + fallbackMessage: t("resource.delete-all-unused-error"), + }); } finally { await handleRefetch(); } diff --git a/web/src/pages/AuthCallback.tsx b/web/src/pages/AuthCallback.tsx index ea24a4109..3e9200937 100644 --- a/web/src/pages/AuthCallback.tsx +++ b/web/src/pages/AuthCallback.tsx @@ -7,6 +7,7 @@ import { authServiceClient } from "@/connect"; import { useAuth } from "@/contexts/AuthContext"; import { absolutifyLink } from "@/helpers/utils"; import useNavigateTo from "@/hooks/useNavigateTo"; +import { handleError } from "@/lib/error"; import { validateOAuthState } from "@/utils/oauth"; interface State { @@ -95,11 +96,15 @@ const AuthCallback = () => { // Redirect to return URL if specified, otherwise home navigateTo(returnUrl || "/"); } catch (error: unknown) { - console.error(error); - const message = error instanceof Error ? error.message : "Failed to authenticate."; - setState({ - loading: false, - errorMessage: message, + handleError(error, () => {}, { + fallbackMessage: "Failed to authenticate.", + onError: (err) => { + const message = err instanceof Error ? err.message : "Failed to authenticate."; + setState({ + loading: false, + errorMessage: message, + }); + }, }); } })(); diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index aba3b8f17..6502c3853 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -10,6 +10,7 @@ import { useInstance } from "@/contexts/InstanceContext"; import { extractIdentityProviderIdFromName } from "@/helpers/resource-names"; import { absolutifyLink } from "@/helpers/utils"; import useCurrentUser from "@/hooks/useCurrentUser"; +import { handleError } from "@/lib/error"; import { Routes } from "@/router"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -62,8 +63,10 @@ const SignIn = () => { window.location.href = authUrl; } catch (error) { - console.error("Failed to initiate OAuth flow:", error); - toast.error("Failed to initiate sign-in. Please try again."); + handleError(error, toast.error, { + context: "Failed to initiate OAuth flow", + fallbackMessage: "Failed to initiate sign-in. Please try again.", + }); } } }; diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index 6574874a4..c83bd320c 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -13,6 +13,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { useInstance } from "@/contexts/InstanceContext"; import useLoading from "@/hooks/useLoading"; import useNavigateTo from "@/hooks/useNavigateTo"; +import { handleError } from "@/lib/error"; import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -70,9 +71,9 @@ const SignUp = () => { await initialize(); navigateTo("/"); } catch (error: unknown) { - console.error(error); - const message = error instanceof Error ? error.message : "Sign up failed"; - toast.error(message); + handleError(error, toast.error, { + fallbackMessage: "Sign up failed", + }); } actionBtnLoadingState.setFinish(); }; diff --git a/web/src/types/common.ts b/web/src/types/common.ts new file mode 100644 index 000000000..7d1269705 --- /dev/null +++ b/web/src/types/common.ts @@ -0,0 +1,13 @@ +export type TableData = Record; + +export interface ApiError { + message: string; + code?: string; + details?: unknown; +} + +export function isApiError(error: unknown): error is ApiError { + return typeof error === "object" && error !== null && "message" in error && typeof (error as ApiError).message === "string"; +} + +export type ToastFunction = (message: string) => void | Promise; diff --git a/web/src/types/markdown.ts b/web/src/types/markdown.ts new file mode 100644 index 000000000..7179bbb12 --- /dev/null +++ b/web/src/types/markdown.ts @@ -0,0 +1,44 @@ +import type { Data, Element as HastElement } from "hast"; + +export interface TagNode { + type: "tagNode"; + value: string; + data: TagNodeData; +} + +export interface TagNodeData { + hName: "span"; + hProperties: TagNodeProperties; + hChildren: Array<{ type: "text"; value: string }>; +} + +export interface TagNodeProperties { + className: string; + "data-tag": string; +} + +export interface ExtendedData extends Data { + mdastType?: string; +} + +export function hasExtendedData(node: unknown): node is { data: ExtendedData } { + return typeof node === "object" && node !== null && "data" in node && typeof (node as { data: unknown }).data === "object"; +} + +export function isTagElement(node: HastElement): boolean { + if (hasExtendedData(node) && node.data.mdastType === "tagNode") { + return true; + } + + const className = node.properties?.className; + if (Array.isArray(className) && className.includes("tag")) { + return true; + } + + return false; +} + +export function isTaskListItemElement(node: HastElement): boolean { + const type = node.properties?.type; + return typeof type === "string" && type === "checkbox"; +} diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index a352576f2..a670a1b3f 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -15,7 +15,7 @@ function isPublicRoute(path: string): boolean { } function isPrivateRoute(path: string): boolean { - return PRIVATE_ROUTES.includes(path as any); + return PRIVATE_ROUTES.includes(path as (typeof PRIVATE_ROUTES)[number]); } export function redirectOnAuthFailure(): void { diff --git a/web/src/utils/i18n.ts b/web/src/utils/i18n.ts index d6f641d0d..1edc11bf9 100644 --- a/web/src/utils/i18n.ts +++ b/web/src/utils/i18n.ts @@ -59,7 +59,7 @@ type NestedKeyOf = K extends keyof T & (string | number) export type Translations = NestedKeyOf; // Represents a typed translation function. -type TypedT = (key: Translations, params?: Record) => string; +type TypedT = (key: Translations, params?: Record) => string; export const useTranslate = (): TypedT => { const { t } = useTranslation(); diff --git a/web/src/utils/remark-plugins/remark-disable-setext.ts b/web/src/utils/remark-plugins/remark-disable-setext.ts index 5dda7e2d2..4a2d1a7b0 100644 --- a/web/src/utils/remark-plugins/remark-disable-setext.ts +++ b/web/src/utils/remark-plugins/remark-disable-setext.ts @@ -1,18 +1,5 @@ -/** - * Remark plugin to disable setext header syntax. - * - * Setext headers use underlines (=== or ---) to create headings: - * Heading 1 - * ========= - * - * Heading 2 - * --------- - * - * This plugin disables the setext heading construct at the micromark parser level, - * preventing these patterns from being recognized as headers. - */ -export function remarkDisableSetext(this: any) { - const data = this.data(); +export function remarkDisableSetext(this: unknown) { + const data = (this as { data: () => Record }).data(); add("micromarkExtensions", { disable: { @@ -20,11 +7,8 @@ export function remarkDisableSetext(this: any) { }, }); - /** - * Add a micromark extension to the parser configuration. - */ - function add(field: string, value: any) { - const list = data[field] ? data[field] : (data[field] = []); + function add(field: string, value: unknown) { + const list = data[field] ? (data[field] as unknown[]) : (data[field] = []); list.push(value); } } diff --git a/web/src/utils/remark-plugins/remark-preserve-type.ts b/web/src/utils/remark-plugins/remark-preserve-type.ts index cdf15151d..98791bca9 100644 --- a/web/src/utils/remark-plugins/remark-preserve-type.ts +++ b/web/src/utils/remark-plugins/remark-preserve-type.ts @@ -1,24 +1,22 @@ import type { Root } from "mdast"; import { visit } from "unist-util-visit"; +import type { ExtendedData } from "@/types/markdown"; + +const STANDARD_NODE_TYPES = new Set(["text", "root", "paragraph", "heading", "list", "listItem"]); -// Remark plugin to preserve original mdast node types in the data field export const remarkPreserveType = () => { return (tree: Root) => { - visit(tree, (node: any) => { - // Skip text nodes and standard element types - if (node.type === "text" || node.type === "root") { + visit(tree, (node) => { + if (STANDARD_NODE_TYPES.has(node.type)) { return; } - // Preserve the original mdast type in data if (!node.data) { node.data = {}; } - // Store original type for custom node types - if (node.type !== "paragraph" && node.type !== "heading" && node.type !== "list" && node.type !== "listItem") { - node.data.mdastType = node.type; - } + const data = node.data as ExtendedData; + data.mdastType = node.type; }); }; }; diff --git a/web/src/utils/remark-plugins/remark-tag.ts b/web/src/utils/remark-plugins/remark-tag.ts index 3afdf8cc4..3a6ad0c00 100644 --- a/web/src/utils/remark-plugins/remark-tag.ts +++ b/web/src/utils/remark-plugins/remark-tag.ts @@ -1,61 +1,43 @@ import type { Root, Text } from "mdast"; +import type { Node as UnistNode } from "unist"; import { visit } from "unist-util-visit"; +import type { TagNode, TagNodeData } from "@/types/markdown"; const MAX_TAG_LENGTH = 100; -// Check if character is valid for tag content (Unicode letters, digits, symbols, _, -, /) function isTagChar(char: string): boolean { - // Allow Unicode letters (any script) if (/\p{L}/u.test(char)) { return true; } - // Allow Unicode digits if (/\p{N}/u.test(char)) { return true; } - // Allow Unicode symbols (includes emoji) - // This makes tags compatible with social media platforms if (/\p{S}/u.test(char)) { return true; } - // Allow specific symbols for tag structure - // Underscore: word separation (snake_case) - // Hyphen: word separation (kebab-case) - // Forward slash: hierarchical tags (category/subcategory) - if (char === "_" || char === "-" || char === "/") { - return true; - } - - // Everything else is invalid (whitespace, punctuation, control chars) - return false; + return char === "_" || char === "-" || char === "/"; } -// Parse tags from text and return segments -function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: string }> { - const segments: Array<{ type: "text" | "tag"; value: string }> = []; +function parseTagsFromText(text: string): Array<{ type: "text"; value: string } | { type: "tag"; value: string }> { + const segments: Array<{ type: "text"; value: string } | { type: "tag"; value: string }> = []; - // Convert to array of code points for proper Unicode handling (emojis, etc.) const chars = [...text]; let i = 0; while (i < chars.length) { - // Check for tag pattern if (chars[i] === "#" && i + 1 < chars.length && isTagChar(chars[i + 1])) { - // Check if this might be a heading (## at start or after whitespace) const prevChar = i > 0 ? chars[i - 1] : ""; const nextChar = i + 1 < chars.length ? chars[i + 1] : ""; if (prevChar === "#" || nextChar === "#" || nextChar === " ") { - // This is a heading, not a tag segments.push({ type: "text", value: chars[i] }); i++; continue; } - // Extract tag content let j = i + 1; while (j < chars.length && isTagChar(chars[j])) { j++; @@ -63,7 +45,6 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s const tagContent = chars.slice(i + 1, j).join(""); - // Validate tag length by rune count (must match backend MAX_TAG_LENGTH) const runeCount = [...tagContent].length; if (runeCount > 0 && runeCount <= MAX_TAG_LENGTH) { segments.push({ type: "tag", value: tagContent }); @@ -72,7 +53,6 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s } } - // Regular text let j = i + 1; while (j < chars.length && chars[j] !== "#") { j++; @@ -84,51 +64,49 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s return segments; } -// Remark plugin to parse #tag syntax +function createTagNode(tagValue: string): TagNode { + const data: TagNodeData = { + hName: "span", + hProperties: { + className: "tag", + "data-tag": tagValue, + }, + hChildren: [{ type: "text", value: `#${tagValue}` }], + }; + + return { + type: "tagNode", + value: tagValue, + data, + } as TagNode; +} + export const remarkTag = () => { return (tree: Root) => { - // Process text nodes in all node types (paragraphs, headings, etc.) - visit(tree, (node: any, index, parent) => { - // Only process text nodes that have a parent and index + visit(tree, (node, index, parent) => { if (node.type !== "text" || !parent || index === null) return; const textNode = node as Text; const text = textNode.value; const segments = parseTagsFromText(text); - // If no tags found, leave node as-is if (segments.every((seg) => seg.type === "text")) { return; } - // Replace text node with multiple nodes (text + tag nodes) const newNodes = segments.map((segment) => { if (segment.type === "tag") { - // Create a custom mdast node that remark-rehype will convert to - // This allows ReactMarkdown's component mapping (span: Tag) to work - return { - type: "tagNode" as any, - value: segment.value, - data: { - hName: "span", - hProperties: { - className: "tag", - "data-tag": segment.value, - }, - hChildren: [{ type: "text", value: `#${segment.value}` }], - }, - }; - } else { - // Keep as text node - return { - type: "text" as const, - value: segment.value, - }; + return createTagNode(segment.value); } + return { + type: "text", + value: segment.value, + } as Text; }); - // Replace the current node with the new nodes - parent.children.splice(index, 1, ...newNodes); + if (typeof index === "number" && parent) { + (parent.children as UnistNode[]).splice(index, 1, ...(newNodes as UnistNode[])); + } }); }; };