mirror of https://github.com/usememos/memos.git
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:
parent
ab650ac86d
commit
85f4fc7a75
|
|
@ -84,7 +84,7 @@
|
|||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noExplicitAny": "error",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[]));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue