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.
This commit is contained in:
Johnny 2025-12-28 12:46:12 +08:00
parent ab650ac86d
commit 85f4fc7a75
44 changed files with 456 additions and 397 deletions

View File

@ -84,7 +84,7 @@
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "off",
"noExplicitAny": "off",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",

View File

@ -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",
});
}
};

View File

@ -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(),
});
}
};

View File

@ -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);

View File

@ -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<HTMLInputElement>) => {
setShortcut({ ...shortcut, title: e.target.value });
setPartialState({
title: e.target.value,
});
};
const onShortcutFilterChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setShortcut({ ...shortcut, filter: e.target.value });
setPartialState({
filter: e.target.value,
});
};
const handleConfirm = async () => {
const setPartialState = (partialState: Partial<Shortcut>) => {
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}
/>
</div>
<div className="text-sm text-muted-foreground">
<p className="mb-2">{t("common.learn-more")}:</p>
<ul className="list-disc list-inside space-y-1">
<li>
<a
className="text-primary hover:underline"
href="https://www.usememos.com/docs/guides/shortcuts"
target="_blank"
rel="noopener noreferrer"
>
Docs - Shortcuts
</a>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")}
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -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(),
});
}
};

View File

@ -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(),
});
}
};

View File

@ -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]);

View File

@ -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;
}

View File

@ -1,11 +1,22 @@
import type { Element } from "hast";
import React from "react";
import { isTagElement, isTaskListItemElement } from "@/types/markdown";
export const createConditionalComponent = <P extends Record<string, any>>(
/**
* 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 = <P extends Record<string, unknown>>(
CustomComponent: React.ComponentType<P>,
DefaultComponent: React.ComponentType<P> | 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 = <P extends Record<string, any>>(
};
};
// 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 };

View File

@ -1,14 +0,0 @@
import { createContext } from "react";
export interface MemoContentContextType {
memoName?: string;
readonly: boolean;
disableFilter?: boolean;
parentPage?: string;
containerRef?: React.RefObject<HTMLDivElement>;
}
export const MemoContentContext = createContext<MemoContentContextType>({
readonly: true,
disableFilter: false,
});

View File

@ -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<HTMLSpanElement> {
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<TagProps> = ({ "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<TagProps> = ({ "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<TagProps> = ({ "data-tag": dataTag, children, classNa
return (
<span
{...props}
className={cn(
"inline-block w-auto text-primary",
context.disableFilter ? "" : "cursor-pointer hover:opacity-80 transition-colors",
className,
)}
className={cn("inline-block w-auto text-primary cursor-pointer hover:opacity-80 transition-colors", className)}
data-tag={tag}
{...props}
onClick={handleTagClick}
>
{children}

View File

@ -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<HTMLInputElement> {
node?: any; // AST node from react-markdown
node?: Element; // AST node from react-markdown
checked?: boolean;
}
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
const context = useContext(MemoContentContext);
const { memo } = useMemoViewContext();
const { readonly } = useMemoViewDerived();
const checkboxRef = useRef<HTMLButtonElement>(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<TaskListItemProps> = ({ 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<TaskListItemProps> = ({ checked, ...props })
}
// Update memo content using the string manipulation utility
const memo = queryClient.getQueryData<Memo>(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<TaskListItemProps> = ({ checked, ...props })
};
// Override the disabled prop from remark-gfm (which defaults to true)
// We want interactive checkboxes, only disabled when readonly
return (
<Checkbox ref={checkboxRef} checked={checked} disabled={context.readonly} onCheckedChange={handleChange} className={props.className} />
);
return <Checkbox ref={checkboxRef} checked={checked} disabled={readonly} onCheckedChange={handleChange} className={props.className} />;
};

View File

@ -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<Memo>(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 (
<MemoContentContext.Provider value={contextValue}>
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div
ref={memoContentContainerRef}
className={cn(
"markdown-content relative w-full max-w-full wrap-break-word text-base leading-6",
showCompactMode === "ALL" && "line-clamp-6 max-h-60",
contentClassName,
)}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div
ref={memoContentContainerRef}
className={cn(
"markdown-content relative w-full max-w-full wrap-break-word text-base leading-6",
showCompactMode === "ALL" && "line-clamp-6 max-h-60",
contentClassName,
)}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
<ReactMarkdown
remarkPlugins={[remarkDisableSetext, remarkGfm, remarkBreaks, remarkMath, remarkTag, remarkPreserveType]}
rehypePlugins={[rehypeRaw, rehypeKatex, [rehypeSanitize, SANITIZE_SCHEMA]]}
components={{
// Child components consume from MemoViewContext directly
input: ((inputProps: React.ComponentProps<"input"> & { node?: Element }) => {
if (inputProps.node && isTaskListItemNode(inputProps.node)) {
return <TaskListItem {...inputProps} />;
}
return <input {...inputProps} />;
}) as React.ComponentType<React.ComponentProps<"input">>,
span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
if (spanProps.node && isTagNode(spanProps.node)) {
return <Tag {...spanProps} />;
}
return <span {...spanProps} />;
}) as React.ComponentType<React.ComponentProps<"span">>,
pre: CodeBlock,
a: ({ href, children, ...aProps }) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...aProps}>
{children}
</a>
),
}}
>
<ReactMarkdown
remarkPlugins={[remarkDisableSetext, remarkGfm, remarkBreaks, remarkMath, remarkTag, remarkPreserveType]}
rehypePlugins={[rehypeRaw, rehypeKatex, [rehypeSanitize, SANITIZE_SCHEMA]]}
components={{
// Conditionally render custom components based on AST node type
input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode),
span: createConditionalComponent(Tag, "span", isTagNode),
pre: CodeBlock,
a: ({ href, children, ...props }) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
{showCompactMode === "ALL" && (
<div className="absolute bottom-0 left-0 w-full h-12 bg-linear-to-b from-transparent to-background pointer-events-none"></div>
)}
{showCompactMode !== undefined && (
<div className="w-full mt-1">
<button
type="button"
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80 text-left"
onClick={toggleCompactMode}
>
{compactLabel}
</button>
</div>
)}
{content}
</ReactMarkdown>
</div>
</MemoContentContext.Provider>
{showCompactMode === "ALL" && (
<div className="absolute bottom-0 left-0 w-full h-12 bg-linear-to-b from-transparent to-background pointer-events-none"></div>
)}
{showCompactMode !== undefined && (
<div className="w-full mt-1">
<button
type="button"
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80 text-left"
onClick={toggleCompactMode}
>
{compactLabel}
</button>
</div>
)}
</div>
);
};

View File

@ -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";

View File

@ -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<MemoEditorProps> = ({
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));
}

View File

@ -55,11 +55,8 @@ const MemoView: React.FC<Props> = (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: 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) {

View File

@ -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<MemoViewContextValue | null>(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,
};
};

View File

@ -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<Props> = ({ 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<Props> = ({ compact, onContentClick, onContentDoubleCli
>
<MemoContent
key={`${memo.name}-${memo.updateTime}`}
memoName={memo.name}
content={memo.content}
readonly={readonly}
onClick={onContentClick}
onDoubleClick={onContentDoubleClick}
compact={memo.pinned ? false : compact} // Always show full content when pinned
parentPage={parentPage}
/>
{memo.location && <LocationDisplay mode="view" location={memo.location} />}
<AttachmentList mode="view" attachments={memo.attachments} />

View File

@ -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<Props> = ({
}) => {
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)

View File

@ -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",
});
}
};

View File

@ -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();
};

View File

@ -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 = () => {
</SettingRow>
</SettingGroup>
<SettingGroup title={t("setting.instance-section.disallow-user-registration")} showSeparator>
<SettingGroup>
<SettingRow label={t("setting.instance-section.disallow-user-registration")}>
<Switch
disabled={profile.mode === "demo"}

View File

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import {
InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting,
@ -70,9 +71,10 @@ const MemoRelatedSettings = () => {
);
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",
});
}
};

View File

@ -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);

View File

@ -1,22 +1,28 @@
import React from "react";
import { cn } from "@/lib/utils";
interface SettingTableColumn {
interface SettingTableColumn<T = Record<string, unknown>> {
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<T = Record<string, unknown>> {
columns: SettingTableColumn<T>[];
data: T[];
emptyMessage?: string;
className?: string;
getRowKey?: (row: any, index: number) => string;
getRowKey?: (row: T, index: number) => string;
}
const SettingTable: React.FC<SettingTableProps> = ({ columns, data, emptyMessage = "No data", className, getRowKey }) => {
const SettingTable = <T extends Record<string, unknown>>({
columns,
data,
emptyMessage = "No data",
className,
getRowKey,
}: SettingTableProps<T>) => {
return (
<div className={cn("w-full overflow-x-auto", className)}>
<div className="inline-block min-w-full align-middle border border-border rounded-lg">
@ -43,8 +49,8 @@ const SettingTable: React.FC<SettingTableProps> = ({ columns, data, emptyMessage
return (
<tr key={rowKey}>
{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 (
<td key={column.key} className={cn("whitespace-nowrap px-3 py-2 text-sm text-muted-foreground", column.className)}>
{content}

View File

@ -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 = () => {
<SettingRow label="Use Path Style">
<Switch
checked={instanceStorageSetting.s3Config?.usePathStyle}
onCheckedChange={(checked) => handleS3ConfigUsePathStyleChanged({ target: { checked } } as any)}
onCheckedChange={(checked) =>
handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent<HTMLInputElement> & {
target: { checked: boolean };
})
}
/>
</SettingRow>
</SettingGroup>

View File

@ -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",
});
}
};

View File

@ -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);
}

View File

@ -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],
});

View File

@ -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(() => {

View File

@ -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<string, unknown>) => {
callback(null, translation);
})
.catch(() => {

38
web/src/lib/error.ts Normal file
View File

@ -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;
}

View File

@ -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();
}

View File

@ -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,
});
},
});
}
})();

View File

@ -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.",
});
}
}
};

View File

@ -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();
};

13
web/src/types/common.ts Normal file
View File

@ -0,0 +1,13 @@
export type TableData = Record<string, unknown>;
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<void>;

44
web/src/types/markdown.ts Normal file
View File

@ -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";
}

View File

@ -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 {

View File

@ -59,7 +59,7 @@ type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number)
export type Translations = NestedKeyOf<typeof enTranslation>;
// Represents a typed translation function.
type TypedT = (key: Translations, params?: Record<string, any>) => string;
type TypedT = (key: Translations, params?: Record<string, unknown>) => string;
export const useTranslate = (): TypedT => {
const { t } = useTranslation<Translations>();

View File

@ -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<string, unknown> }).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);
}
}

View File

@ -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;
});
};
};

View File

@ -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 <span>
// 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[]));
}
});
};
};