Commit 85f4fc7a authored by Johnny's avatar Johnny

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.
parent ab650ac8
...@@ -84,7 +84,7 @@ ...@@ -84,7 +84,7 @@
"noDuplicateObjectKeys": "error", "noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error", "noDuplicateParameters": "error",
"noEmptyBlockStatements": "off", "noEmptyBlockStatements": "off",
"noExplicitAny": "off", "noExplicitAny": "error",
"noExtraNonNullAssertion": "error", "noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error", "noFallthroughSwitchClause": "error",
"noFunctionAssign": "error", "noFunctionAssign": "error",
......
...@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " ...@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useUpdateUser } from "@/hooks/useUserQueries"; import { useUpdateUser } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { User } from "@/types/proto/api/v1/user_service_pb"; import { User } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -60,9 +61,10 @@ function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Pro ...@@ -60,9 +61,10 @@ function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Pro
toast(t("message.password-changed")); toast(t("message.password-changed"));
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: unknown) {
console.error(error); await handleError(error, toast.error, {
toast.error(error.message); context: "Change member password",
});
} }
}; };
......
...@@ -8,6 +8,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; ...@@ -8,6 +8,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { CreatePersonalAccessTokenResponse } from "@/types/proto/api/v1/user_service_pb"; import { CreatePersonalAccessTokenResponse } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -83,10 +84,11 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) { ...@@ -83,10 +84,11 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
requestState.setFinish(); requestState.setFinish();
onSuccess(response); onSuccess(response);
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: unknown) {
toast.error(error.message); handleError(error, toast.error, {
console.error(error); context: "Create access token",
requestState.setError(); onError: () => requestState.setError(),
});
} }
}; };
......
...@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ ...@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import { handleError } from "@/lib/error";
import { import {
FieldMapping, FieldMapping,
FieldMappingSchema, FieldMappingSchema,
...@@ -288,9 +289,10 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on ...@@ -288,9 +289,10 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on
}); });
toast.success(t("setting.sso-section.sso-updated", { name: basicInfo.title })); toast.success(t("setting.sso-section.sso-updated", { name: basicInfo.title }));
} }
} catch (error: any) { } catch (error: unknown) {
toast.error(error.message); await handleError(error, toast.error, {
console.error(error); context: isCreating ? "Create identity provider" : "Update identity provider",
});
} }
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
......
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
...@@ -11,6 +11,7 @@ import { shortcutServiceClient } from "@/connect"; ...@@ -11,6 +11,7 @@ import { shortcutServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb"; import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -33,31 +34,34 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -33,31 +34,34 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
}), }),
); );
const requestState = useLoading(false); const requestState = useLoading(false);
const isCreating = !initialShortcut; const isCreating = shortcut.name === "";
useEffect(() => { useEffect(() => {
if (initialShortcut) { if (shortcut.name) {
setShortcut( setShortcut(shortcut);
create(ShortcutSchema, {
name: initialShortcut.name,
title: initialShortcut.title,
filter: initialShortcut.filter,
}),
);
} else {
setShortcut(create(ShortcutSchema, { name: "", title: "", filter: "" }));
} }
}, [initialShortcut]); }, [shortcut.name, shortcut.title, shortcut.filter]);
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setShortcut({ ...shortcut, title: e.target.value }); setPartialState({
title: e.target.value,
});
}; };
const onShortcutFilterChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { 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) { if (!shortcut.title || !shortcut.filter) {
toast.error("Title and filter cannot be empty"); toast.error("Title and filter cannot be empty");
return; return;
...@@ -69,7 +73,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -69,7 +73,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
await shortcutServiceClient.createShortcut({ await shortcutServiceClient.createShortcut({
parent: user?.name, parent: user?.name,
shortcut: { shortcut: {
name: "", // Will be set by server name: "",
title: shortcut.title, title: shortcut.title,
filter: shortcut.filter, filter: shortcut.filter,
}, },
...@@ -79,21 +83,21 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -79,21 +83,21 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
await shortcutServiceClient.updateShortcut({ await shortcutServiceClient.updateShortcut({
shortcut: { shortcut: {
...shortcut, ...shortcut,
name: initialShortcut!.name, // Keep the original resource name name: initialShortcut!.name,
}, },
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }), updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
}); });
toast.success("Update shortcut successfully"); toast.success("Update shortcut successfully");
} }
// Refresh shortcuts.
await refetchSettings(); await refetchSettings();
requestState.setFinish(); requestState.setFinish();
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: unknown) {
console.error(error); await handleError(error, toast.error, {
toast.error(error.message); context: isCreating ? "Create shortcut" : "Update shortcut",
requestState.setError(); onError: () => requestState.setError(),
});
} }
}; };
...@@ -118,28 +122,13 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -118,28 +122,13 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
onChange={onShortcutFilterChange} onChange={onShortcutFilterChange}
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}> <Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button disabled={requestState.isLoading} onClick={handleConfirm}> <Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.confirm")} {t("common.save")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
......
...@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label"; ...@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -68,10 +69,11 @@ function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: ...@@ -68,10 +69,11 @@ function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }:
requestState.setFinish(); requestState.setFinish();
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: unknown) {
console.error(error); handleError(error, toast.error, {
toast.error(error.message); context: user ? "Update user" : "Create user",
requestState.setError(); onError: () => requestState.setError(),
});
} }
}; };
......
...@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label"; ...@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
interface Props { interface Props {
...@@ -107,10 +108,11 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro ...@@ -107,10 +108,11 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
requestState.setFinish(); requestState.setFinish();
} catch (error: any) { } catch (error: unknown) {
console.error(error); handleError(error, toast.error, {
toast.error(error.message); context: webhookName ? "Update webhook" : "Create webhook",
requestState.setError(); onError: () => requestState.setError(),
});
} }
}; };
......
...@@ -8,6 +8,7 @@ import { activityServiceClient, memoServiceClient, userServiceClient } from "@/c ...@@ -8,6 +8,7 @@ import { activityServiceClient, memoServiceClient, userServiceClient } from "@/c
import { activityNamePrefix } from "@/helpers/resource-names"; import { activityNamePrefix } from "@/helpers/resource-names";
import useAsyncEffect from "@/hooks/useAsyncEffect"; import useAsyncEffect from "@/hooks/useAsyncEffect";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
...@@ -56,8 +57,10 @@ function MemoCommentMessage({ notification }: Props) { ...@@ -56,8 +57,10 @@ function MemoCommentMessage({ notification }: Props) {
setInitialized(true); setInitialized(true);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch activity:", error); handleError(error, () => {}, {
setHasError(true); context: "Failed to fetch activity",
onError: () => setHasError(true),
});
return; return;
} }
}, [notification.activityId]); }, [notification.activityId]);
......
...@@ -7,6 +7,7 @@ import { useInstance } from "@/contexts/InstanceContext"; ...@@ -7,6 +7,7 @@ import { useInstance } from "@/contexts/InstanceContext";
import { useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import { useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -53,6 +54,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe ...@@ -53,6 +54,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
}, [onEdit]); }, [onEdit]);
const handleToggleMemoStatusClick = useCallback(async () => { const handleToggleMemoStatusClick = useCallback(async () => {
const isArchiving = memo.state !== State.ARCHIVED;
const state = memo.state === State.ARCHIVED ? State.NORMAL : 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"); 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 ...@@ -66,9 +68,10 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
}); });
toast.success(message); toast.success(message);
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { details?: string }; handleError(error, toast.error, {
toast.error(err.details || "An error occurred"); context: `${isArchiving ? "Archive" : "Restore"} memo`,
console.error(error); fallbackMessage: "An error occurred",
});
return; return;
} }
......
import type { Element } from "hast";
import React from "react"; 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>, CustomComponent: React.ComponentType<P>,
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements, 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; const { node, ...restProps } = props;
// Check AST node to determine which component to use // Check AST node to determine which component to use
...@@ -21,17 +32,5 @@ export const createConditionalComponent = <P extends Record<string, any>>( ...@@ -21,17 +32,5 @@ export const createConditionalComponent = <P extends Record<string, any>>(
}; };
}; };
// Condition checkers for AST node types // Re-export type guards for convenience
export const isTagNode = (node: any): boolean => { export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };
// 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;
};
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,
});
import { useContext } from "react"; import type { Element } from "hast";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { MemoContentContext } from "./MemoContentContext"; import { useMemoViewContext } from "../MemoView/MemoViewContext";
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> { interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
node?: any; // AST node from react-markdown node?: Element; // AST node from react-markdown
"data-tag"?: string; "data-tag"?: string;
children?: React.ReactNode; children?: React.ReactNode;
} }
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => { export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => {
const context = useContext(MemoContentContext); const { parentPage } = useMemoViewContext();
const location = useLocation(); const location = useLocation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext(); const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
...@@ -23,13 +23,9 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa ...@@ -23,13 +23,9 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
const handleTagClick = (e: React.MouseEvent) => { const handleTagClick = (e: React.MouseEvent) => {
e.stopPropagation(); 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 the tag is clicked in a memo detail page, we should navigate to the memo list page.
if (location.pathname.startsWith("/m")) { if (location.pathname.startsWith("/m")) {
const pathname = context.parentPage || Routes.ROOT; const pathname = parentPage || Routes.ROOT;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }])); searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
...@@ -52,13 +48,9 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa ...@@ -52,13 +48,9 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
return ( return (
<span <span
{...props} className={cn("inline-block w-auto text-primary cursor-pointer hover:opacity-80 transition-colors", className)}
className={cn(
"inline-block w-auto text-primary",
context.disableFilter ? "" : "cursor-pointer hover:opacity-80 transition-colors",
className,
)}
data-tag={tag} data-tag={tag}
{...props}
onClick={handleTagClick} onClick={handleTagClick}
> >
{children} {children}
......
import { useQueryClient } from "@tanstack/react-query"; import type { Element } from "hast";
import { useContext, useRef } from "react"; import { useRef } from "react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { memoKeys, useUpdateMemo } from "@/hooks/useMemoQueries"; import { useUpdateMemo } from "@/hooks/useMemoQueries";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation"; import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
import { MemoContentContext } from "./MemoContentContext"; import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement> { interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
node?: any; // AST node from react-markdown node?: Element; // AST node from react-markdown
checked?: boolean; checked?: boolean;
} }
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => { export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
const context = useContext(MemoContentContext); const { memo } = useMemoViewContext();
const { readonly } = useMemoViewDerived();
const checkboxRef = useRef<HTMLButtonElement>(null); const checkboxRef = useRef<HTMLButtonElement>(null);
const queryClient = useQueryClient();
const { mutate: updateMemo } = useUpdateMemo(); const { mutate: updateMemo } = useUpdateMemo();
const handleChange = async (newChecked: boolean) => { const handleChange = async (newChecked: boolean) => {
// Don't update if readonly or no memo context // Don't update if readonly or no memo
if (context.readonly || !context.memoName) { if (readonly || !memo) {
return; return;
} }
...@@ -37,8 +36,8 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) ...@@ -37,8 +36,8 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props })
taskIndex = parseInt(taskIndexStr); taskIndex = parseInt(taskIndexStr);
} else { } else {
// Fallback: Calculate index by counting ALL task list items in the memo // Fallback: Calculate index by counting ALL task list items in the memo
// Use the container ref from context for proper scoping // Find the markdown-content container by traversing up from the list item
const container = context.containerRef?.current; const container = listItem.closest(".markdown-content");
if (!container) { if (!container) {
return; return;
} }
...@@ -53,11 +52,6 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) ...@@ -53,11 +52,6 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props })
} }
// Update memo content using the string manipulation utility // 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); const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
updateMemo({ updateMemo({
update: { update: {
...@@ -69,8 +63,5 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) ...@@ -69,8 +63,5 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props })
}; };
// Override the disabled prop from remark-gfm (which defaults to true) // 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={readonly} onCheckedChange={handleChange} className={props.className} />;
return (
<Checkbox ref={checkboxRef} checked={checked} disabled={context.readonly} onCheckedChange={handleChange} className={props.className} />
);
}; };
import { useQueryClient } from "@tanstack/react-query"; import type { Element } from "hast";
import { memo } from "react"; import { memo } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
...@@ -7,49 +7,31 @@ import rehypeSanitize from "rehype-sanitize"; ...@@ -7,49 +7,31 @@ import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext"; import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkTag } from "@/utils/remark-plugins/remark-tag"; import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { isSuperUser } from "@/utils/user";
import { CodeBlock } from "./CodeBlock"; import { CodeBlock } from "./CodeBlock";
import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent"; import { isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { SANITIZE_SCHEMA } from "./constants"; import { SANITIZE_SCHEMA } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks"; import { useCompactLabel, useCompactMode } from "./hooks";
import { MemoContentContext } from "./MemoContentContext";
import { Tag } from "./Tag"; import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem"; import { TaskListItem } from "./TaskListItem";
import type { MemoContentProps } from "./types"; import type { MemoContentProps } from "./types";
const MemoContent = (props: MemoContentProps) => { const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props; const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser();
const queryClient = useQueryClient();
const { const {
containerRef: memoContentContainerRef, containerRef: memoContentContainerRef,
mode: showCompactMode, mode: showCompactMode,
toggle: toggleCompactMode, toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact)); } = 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); const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
return ( return (
<MemoContentContext.Provider value={contextValue}>
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}> <div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div <div
ref={memoContentContainerRef} ref={memoContentContainerRef}
...@@ -65,12 +47,22 @@ const MemoContent = (props: MemoContentProps) => { ...@@ -65,12 +47,22 @@ const MemoContent = (props: MemoContentProps) => {
remarkPlugins={[remarkDisableSetext, remarkGfm, remarkBreaks, remarkMath, remarkTag, remarkPreserveType]} remarkPlugins={[remarkDisableSetext, remarkGfm, remarkBreaks, remarkMath, remarkTag, remarkPreserveType]}
rehypePlugins={[rehypeRaw, rehypeKatex, [rehypeSanitize, SANITIZE_SCHEMA]]} rehypePlugins={[rehypeRaw, rehypeKatex, [rehypeSanitize, SANITIZE_SCHEMA]]}
components={{ components={{
// Conditionally render custom components based on AST node type // Child components consume from MemoViewContext directly
input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode), input: ((inputProps: React.ComponentProps<"input"> & { node?: Element }) => {
span: createConditionalComponent(Tag, "span", isTagNode), 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, pre: CodeBlock,
a: ({ href, children, ...props }) => ( a: ({ href, children, ...aProps }) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}> <a href={href} target="_blank" rel="noopener noreferrer" {...aProps}>
{children} {children}
</a> </a>
), ),
...@@ -94,7 +86,6 @@ const MemoContent = (props: MemoContentProps) => { ...@@ -94,7 +86,6 @@ const MemoContent = (props: MemoContentProps) => {
</div> </div>
)} )}
</div> </div>
</MemoContentContext.Provider>
); );
}; };
......
...@@ -2,15 +2,11 @@ import type React from "react"; ...@@ -2,15 +2,11 @@ import type React from "react";
export interface MemoContentProps { export interface MemoContentProps {
content: string; content: string;
memoName?: string;
compact?: boolean; compact?: boolean;
readonly?: boolean;
disableFilter?: boolean;
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void;
parentPage?: string;
} }
export type ContentCompactView = "ALL" | "SNIPPET"; export type ContentCompactView = "ALL" | "SNIPPET";
...@@ -4,6 +4,7 @@ import { toast } from "react-hot-toast"; ...@@ -4,6 +4,7 @@ import { toast } from "react-hot-toast";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries"; import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components"; import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
...@@ -138,9 +139,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -138,9 +139,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
toast.success("Saved successfully"); toast.success("Saved successfully");
} catch (error) { } catch (error) {
const errorMessage = errorService.getErrorMessage(error); handleError(error, toast.error, {
toast.error(errorMessage); context: "Failed to save memo",
console.error("Failed to save memo:", error); fallbackMessage: errorService.getErrorMessage(error),
});
} finally { } finally {
dispatch(actions.setLoading("saving", false)); dispatch(actions.setLoading("saving", false));
} }
......
...@@ -55,11 +55,8 @@ const MemoView: React.FC<Props> = (props: Props) => { ...@@ -55,11 +55,8 @@ const MemoView: React.FC<Props> = (props: Props) => {
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
const creator = useMemoCreator(memoData.creator); const creator = useMemoCreator(memoData.creator);
const { commentAmount, relativeTimeFormat, isArchived, readonly, isInMemoDetailPage, parentPage } = useMemoViewDerivedState( const { isArchived, readonly, parentPage } = useMemoViewDerivedState(memoData, props.parentPage);
memoData, const { showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
props.parentPage,
);
const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
const { previewState, openPreview, setPreviewOpen } = useImagePreview(); const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor(); const { showEditor, openEditor, handleEditorConfirm, handleEditorCancel } = useMemoEditor();
const { archiveMemo, unpinMemo } = useMemoActions(memoData); const { archiveMemo, unpinMemo } = useMemoActions(memoData);
...@@ -79,37 +76,15 @@ const MemoView: React.FC<Props> = (props: Props) => { ...@@ -79,37 +76,15 @@ const MemoView: React.FC<Props> = (props: Props) => {
onArchive: archiveMemo, onArchive: archiveMemo,
}); });
// Memoize static values that rarely change // Minimal essential context - only non-derivable data
const staticContextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
memo: memoData, memo: memoData,
creator, creator,
isArchived,
readonly,
isInMemoDetailPage,
parentPage, parentPage,
}),
[memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage],
);
// Memoize dynamic values separately
const dynamicContextValue = useMemo(
() => ({
commentAmount,
relativeTimeFormat,
nsfw,
showNSFWContent, showNSFWContent,
}), }),
[commentAmount, relativeTimeFormat, nsfw, showNSFWContent], [memoData, creator, parentPage, showNSFWContent],
);
// Combine context values
const contextValue = useMemo(
() => ({
...staticContextValue,
...dynamicContextValue,
}),
[staticContextValue, dynamicContextValue],
); );
if (showEditor) { if (showEditor) {
......
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { createContext, useContext } from "react"; 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 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 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 // Minimal essential context - only data that cannot be easily derived
export interface MemoViewStaticContextValue { export interface MemoViewContextValue {
memo: Memo; memo: Memo;
creator: User | undefined; creator: User | undefined;
isArchived: boolean;
readonly: boolean;
isInMemoDetailPage: boolean;
parentPage: string; parentPage: string;
}
// Dynamic values that change frequently
export interface MemoViewDynamicContextValue {
commentAmount: number;
relativeTimeFormat: "datetime" | "auto";
nsfw: boolean;
showNSFWContent: boolean; showNSFWContent: boolean;
} }
export interface MemoViewContextValue extends MemoViewStaticContextValue, MemoViewDynamicContextValue {}
export const MemoViewContext = createContext<MemoViewContextValue | null>(null); export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
export const useMemoViewContext = (): MemoViewContextValue => { export const useMemoViewContext = (): MemoViewContextValue => {
...@@ -31,3 +26,33 @@ export const useMemoViewContext = (): MemoViewContextValue => { ...@@ -31,3 +26,33 @@ export const useMemoViewContext = (): MemoViewContextValue => {
} }
return context; 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"; ...@@ -4,7 +4,7 @@ import { useTranslate } from "@/utils/i18n";
import MemoContent from "../../MemoContent"; import MemoContent from "../../MemoContent";
import { MemoReactionListView } from "../../MemoReactionListView"; import { MemoReactionListView } from "../../MemoReactionListView";
import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata"; import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata";
import { useMemoViewContext } from "../MemoViewContext"; import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
interface Props { interface Props {
compact?: boolean; compact?: boolean;
...@@ -16,8 +16,9 @@ interface Props { ...@@ -16,8 +16,9 @@ interface Props {
const MemoBody: React.FC<Props> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => { const MemoBody: React.FC<Props> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
const t = useTranslate(); const t = useTranslate();
// Get shared state from context // Get essential context and derive other values
const { memo, readonly, parentPage, nsfw, showNSFWContent } = useMemoViewContext(); const { memo, parentPage, showNSFWContent } = useMemoViewContext();
const { nsfw } = useMemoViewDerived();
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
...@@ -31,13 +32,10 @@ const MemoBody: React.FC<Props> = ({ compact, onContentClick, onContentDoubleCli ...@@ -31,13 +32,10 @@ const MemoBody: React.FC<Props> = ({ compact, onContentClick, onContentDoubleCli
> >
<MemoContent <MemoContent
key={`${memo.name}-${memo.updateTime}`} key={`${memo.name}-${memo.updateTime}`}
memoName={memo.name}
content={memo.content} content={memo.content}
readonly={readonly}
onClick={onContentClick} onClick={onContentClick}
onDoubleClick={onContentDoubleClick} onDoubleClick={onContentDoubleClick}
compact={memo.pinned ? false : compact} // Always show full content when pinned compact={memo.pinned ? false : compact} // Always show full content when pinned
parentPage={parentPage}
/> />
{memo.location && <LocationDisplay mode="view" location={memo.location} />} {memo.location && <LocationDisplay mode="view" location={memo.location} />}
<AttachmentList mode="view" attachments={memo.attachments} /> <AttachmentList mode="view" attachments={memo.attachments} />
......
...@@ -12,7 +12,7 @@ import MemoActionMenu from "../../MemoActionMenu"; ...@@ -12,7 +12,7 @@ import MemoActionMenu from "../../MemoActionMenu";
import { ReactionSelector } from "../../MemoReactionListView"; import { ReactionSelector } from "../../MemoReactionListView";
import UserAvatar from "../../UserAvatar"; import UserAvatar from "../../UserAvatar";
import VisibilityIcon from "../../VisibilityIcon"; import VisibilityIcon from "../../VisibilityIcon";
import { useMemoViewContext } from "../MemoViewContext"; import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
interface Props { interface Props {
showCreator?: boolean; showCreator?: boolean;
...@@ -39,9 +39,9 @@ const MemoHeader: React.FC<Props> = ({ ...@@ -39,9 +39,9 @@ const MemoHeader: React.FC<Props> = ({
}) => { }) => {
const t = useTranslate(); const t = useTranslate();
// Get shared state from context // Get essential context and derive other values
const { memo, creator, isArchived, commentAmount, isInMemoDetailPage, parentPage, readonly, relativeTimeFormat, nsfw, showNSFWContent } = const { memo, creator, parentPage, showNSFWContent } = useMemoViewContext();
useMemoViewContext(); const { isArchived, readonly, isInMemoDetailPage, commentAmount, relativeTimeFormat, nsfw } = useMemoViewDerived();
const displayTime = isArchived ? ( const displayTime = isArchived ? (
(memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language) (memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language)
......
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useUpdateMemo } from "@/hooks/useMemoQueries"; import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { handleError } from "@/lib/error";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -15,9 +16,10 @@ export const useMemoActions = (memo: Memo) => { ...@@ -15,9 +16,10 @@ export const useMemoActions = (memo: Memo) => {
await updateMemo({ update: { name: memo.name, state: State.ARCHIVED }, updateMask: ["state"] }); await updateMemo({ update: { name: memo.name, state: State.ARCHIVED }, updateMask: ["state"] });
toast.success(t("message.archived-successfully")); toast.success(t("message.archived-successfully"));
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); handleError(error, toast.error, {
const err = error as { details?: string }; context: "Archive memo",
toast.error(err?.details || "Failed to archive memo"); fallbackMessage: "Failed to archive memo",
});
} }
}; };
......
...@@ -10,6 +10,7 @@ import { useAuth } from "@/contexts/AuthContext"; ...@@ -10,6 +10,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
function PasswordSignInForm() { function PasswordSignInForm() {
...@@ -60,9 +61,9 @@ function PasswordSignInForm() { ...@@ -60,9 +61,9 @@ function PasswordSignInForm() {
await initialize(); await initialize();
navigateTo("/"); navigateTo("/");
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); handleError(error, toast.error, {
const message = error instanceof Error ? error.message : "Failed to sign in."; fallbackMessage: "Failed to sign in.",
toast.error(message); });
} }
actionBtnLoadingState.setFinish(); actionBtnLoadingState.setFinish();
}; };
......
...@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea"; ...@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import useDialog from "@/hooks/useDialog"; import useDialog from "@/hooks/useDialog";
import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import { import {
InstanceSetting_GeneralSetting, InstanceSetting_GeneralSetting,
...@@ -58,9 +59,10 @@ const InstanceSection = () => { ...@@ -58,9 +59,10 @@ const InstanceSection = () => {
}), }),
); );
await fetchSetting(InstanceSetting_Key.GENERAL); await fetchSetting(InstanceSetting_Key.GENERAL);
} catch (error: any) { } catch (error: unknown) {
toast.error(error.message); await handleError(error, toast.error, {
console.error(error); context: "Update general settings",
});
return; return;
} }
toast.success(t("message.update-succeed")); toast.success(t("message.update-succeed"));
...@@ -107,7 +109,7 @@ const InstanceSection = () => { ...@@ -107,7 +109,7 @@ const InstanceSection = () => {
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
<SettingGroup title={t("setting.instance-section.disallow-user-registration")} showSeparator> <SettingGroup>
<SettingRow label={t("setting.instance-section.disallow-user-registration")}> <SettingRow label={t("setting.instance-section.disallow-user-registration")}>
<Switch <Switch
disabled={profile.mode === "demo"} disabled={profile.mode === "demo"}
......
...@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; ...@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import { import {
InstanceSetting_Key, InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting, InstanceSetting_MemoRelatedSetting,
...@@ -70,9 +71,10 @@ const MemoRelatedSettings = () => { ...@@ -70,9 +71,10 @@ const MemoRelatedSettings = () => {
); );
await fetchSetting(InstanceSetting_Key.MEMO_RELATED); await fetchSetting(InstanceSetting_Key.MEMO_RELATED);
toast.success(t("message.update-succeed")); toast.success(t("message.update-succeed"));
} catch (error: any) { } catch (error: unknown) {
toast.error(error.message); await handleError(error, toast.error, {
console.error(error); context: "Update memo-related settings",
});
} }
}; };
......
...@@ -5,6 +5,7 @@ import ConfirmDialog from "@/components/ConfirmDialog"; ...@@ -5,6 +5,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog"; import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
...@@ -36,9 +37,10 @@ const SSOSection = () => { ...@@ -36,9 +37,10 @@ const SSOSection = () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name }); await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name });
} catch (error: any) { } catch (error: unknown) {
console.error(error); handleError(error, toast.error, {
toast.error(error.message); context: "Delete identity provider",
});
} }
await fetchIdentityProviderList(); await fetchIdentityProviderList();
setDeleteTarget(undefined); setDeleteTarget(undefined);
......
import React from "react"; import React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface SettingTableColumn { interface SettingTableColumn<T = Record<string, unknown>> {
key: string; key: string;
header: string; header: string;
className?: string; className?: string;
render?: (value: any, row: any) => React.ReactNode; render?: (value: T[keyof T], row: T) => React.ReactNode;
} }
interface SettingTableProps { interface SettingTableProps<T = Record<string, unknown>> {
columns: SettingTableColumn[]; columns: SettingTableColumn<T>[];
data: any[]; data: T[];
emptyMessage?: string; emptyMessage?: string;
className?: 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 ( return (
<div className={cn("w-full overflow-x-auto", className)}> <div className={cn("w-full overflow-x-auto", className)}>
<div className="inline-block min-w-full align-middle border border-border rounded-lg"> <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 ...@@ -43,8 +49,8 @@ const SettingTable: React.FC<SettingTableProps> = ({ columns, data, emptyMessage
return ( return (
<tr key={rowKey}> <tr key={rowKey}>
{columns.map((column) => { {columns.map((column) => {
const value = row[column.key]; const value = row[column.key as keyof T] as T[keyof T];
const content = column.render ? column.render(value, row) : value; const content = column.render ? column.render(value, row) : (value as React.ReactNode);
return ( return (
<td key={column.key} className={cn("whitespace-nowrap px-3 py-2 text-sm text-muted-foreground", column.className)}> <td key={column.key} className={cn("whitespace-nowrap px-3 py-2 text-sm text-muted-foreground", column.className)}>
{content} {content}
......
...@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; ...@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import { import {
InstanceSetting_Key, InstanceSetting_Key,
InstanceSetting_StorageSetting, InstanceSetting_StorageSetting,
...@@ -141,9 +142,10 @@ const StorageSection = () => { ...@@ -141,9 +142,10 @@ const StorageSection = () => {
); );
await fetchSetting(InstanceSetting_Key.STORAGE); await fetchSetting(InstanceSetting_Key.STORAGE);
toast.success("Updated"); toast.success("Updated");
} catch (error: any) { } catch (error: unknown) {
toast.error(error.message); handleError(error, toast.error, {
console.error(error); context: "Update storage settings",
});
} }
}; };
...@@ -223,7 +225,11 @@ const StorageSection = () => { ...@@ -223,7 +225,11 @@ const StorageSection = () => {
<SettingRow label="Use Path Style"> <SettingRow label="Use Path Style">
<Switch <Switch
checked={instanceStorageSetting.s3Config?.usePathStyle} checked={instanceStorageSetting.s3Config?.usePathStyle}
onCheckedChange={(checked) => handleS3ConfigUsePathStyleChanged({ target: { checked } } as any)} onCheckedChange={(checked) =>
handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent<HTMLInputElement> & {
target: { checked: boolean };
})
}
/> />
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
......
...@@ -11,6 +11,7 @@ import { useInstance } from "@/contexts/InstanceContext"; ...@@ -11,6 +11,7 @@ import { useInstance } from "@/contexts/InstanceContext";
import { convertFileToBase64 } from "@/helpers/utils"; import { convertFileToBase64 } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useUpdateUser } from "@/hooks/useUserQueries"; import { useUpdateUser } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
...@@ -141,9 +142,10 @@ function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) { ...@@ -141,9 +142,10 @@ function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
toast.success(t("message.update-succeed")); toast.success(t("message.update-succeed"));
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: unknown) {
console.error(error); await handleError(error, toast.error, {
toast.error(error.message); context: "Update account",
});
} }
}; };
......
...@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; ...@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { buildInstanceSettingName } from "@/helpers/resource-names"; import { buildInstanceSettingName } from "@/helpers/resource-names";
import { handleError } from "@/lib/error";
import { import {
InstanceSetting_GeneralSetting_CustomProfile, InstanceSetting_GeneralSetting_CustomProfile,
InstanceSetting_GeneralSetting_CustomProfileSchema, InstanceSetting_GeneralSetting_CustomProfileSchema,
...@@ -92,8 +93,10 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props) ...@@ -92,8 +93,10 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props)
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
console.error(error); handleError(error, toast.error, {
toast.error("Failed to update profile"); context: "Update customized profile",
fallbackMessage: "Failed to update profile",
});
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
......
...@@ -127,7 +127,7 @@ const authInterceptor: Interceptor = (next) => async (req) => { ...@@ -127,7 +127,7 @@ const authInterceptor: Interceptor = (next) => async (req) => {
const transport = createConnectTransport({ const transport = createConnectTransport({
baseUrl: window.location.origin, baseUrl: window.location.origin,
useBinaryFormat: true, useBinaryFormat: false,
fetch: fetchWithCredentials, fetch: fetchWithCredentials,
interceptors: [authInterceptor], interceptors: [authInterceptor],
}); });
......
...@@ -4,8 +4,8 @@ const useNavigateTo = () => { ...@@ -4,8 +4,8 @@ const useNavigateTo = () => {
const navigateTo = useNavigate(); const navigateTo = useNavigate();
const navigateToWithViewTransition = (to: string, options?: NavigateOptions) => { const navigateToWithViewTransition = (to: string, options?: NavigateOptions) => {
const document = window.document as any; const doc = window.document as unknown as Document & { startViewTransition?: (callback: () => void) => void };
if (!document.startViewTransition) { if (!doc.startViewTransition) {
navigateTo(to, options); navigateTo(to, options);
} else { } else {
document.startViewTransition(() => { document.startViewTransition(() => {
......
...@@ -51,7 +51,7 @@ const LazyImportPlugin: BackendModule = { ...@@ -51,7 +51,7 @@ const LazyImportPlugin: BackendModule = {
read: function (language, _, callback) { read: function (language, _, callback) {
const matchedLanguage = findNearestMatchedLanguage(language); const matchedLanguage = findNearestMatchedLanguage(language);
import(`./locales/${matchedLanguage}.json`) import(`./locales/${matchedLanguage}.json`)
.then((translation: any) => { .then((translation: Record<string, unknown>) => {
callback(null, translation); callback(null, translation);
}) })
.catch(() => { .catch(() => {
......
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"; ...@@ -17,6 +17,7 @@ import useDialog from "@/hooks/useDialog";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useMediaQuery from "@/hooks/useMediaQuery"; import useMediaQuery from "@/hooks/useMediaQuery";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { handleError } from "@/lib/error";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -98,8 +99,10 @@ const Attachments = () => { ...@@ -98,8 +99,10 @@ const Attachments = () => {
setAttachments(fetchedAttachments); setAttachments(fetchedAttachments);
setNextPageToken(nextPageToken ?? ""); setNextPageToken(nextPageToken ?? "");
} catch (error) { } catch (error) {
console.error("Failed to fetch attachments:", error); handleError(error, toast.error, {
toast.error("Failed to load attachments. Please try again."); context: "Failed to fetch attachments",
fallbackMessage: "Failed to load attachments. Please try again.",
});
} finally { } finally {
loadingState.setFinish(); loadingState.setFinish();
} }
...@@ -122,8 +125,10 @@ const Attachments = () => { ...@@ -122,8 +125,10 @@ const Attachments = () => {
setAttachments((prev) => [...prev, ...fetchedAttachments]); setAttachments((prev) => [...prev, ...fetchedAttachments]);
setNextPageToken(newPageToken ?? ""); setNextPageToken(newPageToken ?? "");
} catch (error) { } catch (error) {
console.error("Failed to load more attachments:", error); handleError(error, toast.error, {
toast.error("Failed to load more attachments. Please try again."); context: "Failed to load more attachments",
fallbackMessage: "Failed to load more attachments. Please try again.",
});
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
} }
...@@ -140,9 +145,11 @@ const Attachments = () => { ...@@ -140,9 +145,11 @@ const Attachments = () => {
setNextPageToken(nextPageToken ?? ""); setNextPageToken(nextPageToken ?? "");
loadingState.setFinish(); loadingState.setFinish();
} catch (error) { } catch (error) {
console.error("Failed to refetch attachments:", error); handleError(error, toast.error, {
loadingState.setError(); context: "Failed to refetch attachments",
toast.error("Failed to refresh attachments. Please try again."); fallbackMessage: "Failed to refresh attachments. Please try again.",
onError: () => loadingState.setError(),
});
} }
}, [loadingState]); }, [loadingState]);
...@@ -152,8 +159,10 @@ const Attachments = () => { ...@@ -152,8 +159,10 @@ const Attachments = () => {
await Promise.all(unusedAttachments.map((attachment) => deleteAttachment(attachment.name))); await Promise.all(unusedAttachments.map((attachment) => deleteAttachment(attachment.name)));
toast.success(t("resource.delete-all-unused-success")); toast.success(t("resource.delete-all-unused-success"));
} catch (error) { } catch (error) {
console.error("Failed to delete unused attachments:", error); handleError(error, toast.error, {
toast.error(t("resource.delete-all-unused-error")); context: "Failed to delete unused attachments",
fallbackMessage: t("resource.delete-all-unused-error"),
});
} finally { } finally {
await handleRefetch(); await handleRefetch();
} }
......
...@@ -7,6 +7,7 @@ import { authServiceClient } from "@/connect"; ...@@ -7,6 +7,7 @@ import { authServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { validateOAuthState } from "@/utils/oauth"; import { validateOAuthState } from "@/utils/oauth";
interface State { interface State {
...@@ -95,12 +96,16 @@ const AuthCallback = () => { ...@@ -95,12 +96,16 @@ const AuthCallback = () => {
// Redirect to return URL if specified, otherwise home // Redirect to return URL if specified, otherwise home
navigateTo(returnUrl || "/"); navigateTo(returnUrl || "/");
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); handleError(error, () => {}, {
const message = error instanceof Error ? error.message : "Failed to authenticate."; fallbackMessage: "Failed to authenticate.",
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to authenticate.";
setState({ setState({
loading: false, loading: false,
errorMessage: message, errorMessage: message,
}); });
},
});
} }
})(); })();
}, [searchParams, navigateTo]); }, [searchParams, navigateTo]);
......
...@@ -10,6 +10,7 @@ import { useInstance } from "@/contexts/InstanceContext"; ...@@ -10,6 +10,7 @@ import { useInstance } from "@/contexts/InstanceContext";
import { extractIdentityProviderIdFromName } from "@/helpers/resource-names"; import { extractIdentityProviderIdFromName } from "@/helpers/resource-names";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { handleError } from "@/lib/error";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -62,8 +63,10 @@ const SignIn = () => { ...@@ -62,8 +63,10 @@ const SignIn = () => {
window.location.href = authUrl; window.location.href = authUrl;
} catch (error) { } catch (error) {
console.error("Failed to initiate OAuth flow:", error); handleError(error, toast.error, {
toast.error("Failed to initiate sign-in. Please try again."); context: "Failed to initiate OAuth flow",
fallbackMessage: "Failed to initiate sign-in. Please try again.",
});
} }
} }
}; };
......
...@@ -13,6 +13,7 @@ import { useAuth } from "@/contexts/AuthContext"; ...@@ -13,6 +13,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -70,9 +71,9 @@ const SignUp = () => { ...@@ -70,9 +71,9 @@ const SignUp = () => {
await initialize(); await initialize();
navigateTo("/"); navigateTo("/");
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); handleError(error, toast.error, {
const message = error instanceof Error ? error.message : "Sign up failed"; fallbackMessage: "Sign up failed",
toast.error(message); });
} }
actionBtnLoadingState.setFinish(); actionBtnLoadingState.setFinish();
}; };
......
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>;
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 { ...@@ -15,7 +15,7 @@ function isPublicRoute(path: string): boolean {
} }
function isPrivateRoute(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 { export function redirectOnAuthFailure(): void {
......
...@@ -59,7 +59,7 @@ type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number) ...@@ -59,7 +59,7 @@ type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number)
export type Translations = NestedKeyOf<typeof enTranslation>; export type Translations = NestedKeyOf<typeof enTranslation>;
// Represents a typed translation function. // 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 => { export const useTranslate = (): TypedT => {
const { t } = useTranslation<Translations>(); const { t } = useTranslation<Translations>();
......
/** export function remarkDisableSetext(this: unknown) {
* Remark plugin to disable setext header syntax. const data = (this as { data: () => Record<string, unknown> }).data();
*
* 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();
add("micromarkExtensions", { add("micromarkExtensions", {
disable: { disable: {
...@@ -20,11 +7,8 @@ export function remarkDisableSetext(this: any) { ...@@ -20,11 +7,8 @@ export function remarkDisableSetext(this: any) {
}, },
}); });
/** function add(field: string, value: unknown) {
* Add a micromark extension to the parser configuration. const list = data[field] ? (data[field] as unknown[]) : (data[field] = []);
*/
function add(field: string, value: any) {
const list = data[field] ? data[field] : (data[field] = []);
list.push(value); list.push(value);
} }
} }
import type { Root } from "mdast"; import type { Root } from "mdast";
import { visit } from "unist-util-visit"; 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 = () => { export const remarkPreserveType = () => {
return (tree: Root) => { return (tree: Root) => {
visit(tree, (node: any) => { visit(tree, (node) => {
// Skip text nodes and standard element types if (STANDARD_NODE_TYPES.has(node.type)) {
if (node.type === "text" || node.type === "root") {
return; return;
} }
// Preserve the original mdast type in data
if (!node.data) { if (!node.data) {
node.data = {}; node.data = {};
} }
// Store original type for custom node types const data = node.data as ExtendedData;
if (node.type !== "paragraph" && node.type !== "heading" && node.type !== "list" && node.type !== "listItem") { data.mdastType = node.type;
node.data.mdastType = node.type;
}
}); });
}; };
}; };
import type { Root, Text } from "mdast"; import type { Root, Text } from "mdast";
import type { Node as UnistNode } from "unist";
import { visit } from "unist-util-visit"; import { visit } from "unist-util-visit";
import type { TagNode, TagNodeData } from "@/types/markdown";
const MAX_TAG_LENGTH = 100; const MAX_TAG_LENGTH = 100;
// Check if character is valid for tag content (Unicode letters, digits, symbols, _, -, /)
function isTagChar(char: string): boolean { function isTagChar(char: string): boolean {
// Allow Unicode letters (any script)
if (/\p{L}/u.test(char)) { if (/\p{L}/u.test(char)) {
return true; return true;
} }
// Allow Unicode digits
if (/\p{N}/u.test(char)) { if (/\p{N}/u.test(char)) {
return true; return true;
} }
// Allow Unicode symbols (includes emoji)
// This makes tags compatible with social media platforms
if (/\p{S}/u.test(char)) { if (/\p{S}/u.test(char)) {
return true; return true;
} }
// Allow specific symbols for tag structure return char === "_" || char === "-" || char === "/";
// 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;
} }
// Parse tags from text and return segments function parseTagsFromText(text: string): Array<{ type: "text"; value: string } | { type: "tag"; value: string }> {
function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: string }> { const segments: Array<{ type: "text"; value: string } | { type: "tag"; value: string }> = [];
const segments: Array<{ type: "text" | "tag"; value: string }> = [];
// Convert to array of code points for proper Unicode handling (emojis, etc.)
const chars = [...text]; const chars = [...text];
let i = 0; let i = 0;
while (i < chars.length) { while (i < chars.length) {
// Check for tag pattern
if (chars[i] === "#" && i + 1 < chars.length && isTagChar(chars[i + 1])) { 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 prevChar = i > 0 ? chars[i - 1] : "";
const nextChar = i + 1 < chars.length ? chars[i + 1] : ""; const nextChar = i + 1 < chars.length ? chars[i + 1] : "";
if (prevChar === "#" || nextChar === "#" || nextChar === " ") { if (prevChar === "#" || nextChar === "#" || nextChar === " ") {
// This is a heading, not a tag
segments.push({ type: "text", value: chars[i] }); segments.push({ type: "text", value: chars[i] });
i++; i++;
continue; continue;
} }
// Extract tag content
let j = i + 1; let j = i + 1;
while (j < chars.length && isTagChar(chars[j])) { while (j < chars.length && isTagChar(chars[j])) {
j++; j++;
...@@ -63,7 +45,6 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s ...@@ -63,7 +45,6 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s
const tagContent = chars.slice(i + 1, j).join(""); const tagContent = chars.slice(i + 1, j).join("");
// Validate tag length by rune count (must match backend MAX_TAG_LENGTH)
const runeCount = [...tagContent].length; const runeCount = [...tagContent].length;
if (runeCount > 0 && runeCount <= MAX_TAG_LENGTH) { if (runeCount > 0 && runeCount <= MAX_TAG_LENGTH) {
segments.push({ type: "tag", value: tagContent }); segments.push({ type: "tag", value: tagContent });
...@@ -72,7 +53,6 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s ...@@ -72,7 +53,6 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s
} }
} }
// Regular text
let j = i + 1; let j = i + 1;
while (j < chars.length && chars[j] !== "#") { while (j < chars.length && chars[j] !== "#") {
j++; j++;
...@@ -84,51 +64,49 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s ...@@ -84,51 +64,49 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s
return segments; 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 = () => { export const remarkTag = () => {
return (tree: Root) => { return (tree: Root) => {
// Process text nodes in all node types (paragraphs, headings, etc.) visit(tree, (node, index, parent) => {
visit(tree, (node: any, index, parent) => {
// Only process text nodes that have a parent and index
if (node.type !== "text" || !parent || index === null) return; if (node.type !== "text" || !parent || index === null) return;
const textNode = node as Text; const textNode = node as Text;
const text = textNode.value; const text = textNode.value;
const segments = parseTagsFromText(text); const segments = parseTagsFromText(text);
// If no tags found, leave node as-is
if (segments.every((seg) => seg.type === "text")) { if (segments.every((seg) => seg.type === "text")) {
return; return;
} }
// Replace text node with multiple nodes (text + tag nodes)
const newNodes = segments.map((segment) => { const newNodes = segments.map((segment) => {
if (segment.type === "tag") { if (segment.type === "tag") {
// Create a custom mdast node that remark-rehype will convert to <span> return createTagNode(segment.value);
// 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 { return {
type: "text" as const, type: "text",
value: segment.value, value: segment.value,
}; } as Text;
}
}); });
// Replace the current node with the new nodes if (typeof index === "number" && parent) {
parent.children.splice(index, 1, ...newNodes); (parent.children as UnistNode[]).splice(index, 1, ...(newNodes as UnistNode[]));
}
}); });
}; };
}; };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment