feat: update date mention and mention information (#4578)
This commit is contained in:
parent
e9de114ebe
commit
86980e71f6
|
|
@ -1,4 +1,3 @@
|
|||
import { getCategoryFeedIds } from "@follow/store/subscription/getter"
|
||||
import i18next from "i18next"
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
|
|
@ -14,13 +13,11 @@ import type {
|
|||
import { $applyNodeReplacement, DecoratorNode } from "lexical"
|
||||
import * as React from "react"
|
||||
|
||||
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
import { MentionComponent } from "./components/MentionComponent"
|
||||
import { RANGE_WITH_LABEL_KEY } from "./hooks/dateMentionConfig"
|
||||
import { getDateMentionDisplayName } from "./hooks/dateMentionUtils"
|
||||
import type { MentionData } from "./types"
|
||||
import { getMentionTextValue } from "./utils/mentionTextValue"
|
||||
|
||||
export type SerializedMentionNode = Spread<
|
||||
{
|
||||
|
|
@ -45,6 +42,15 @@ export class MentionNode extends DecoratorNode<React.JSX.Element> {
|
|||
this.__mentionData = mentionData
|
||||
}
|
||||
|
||||
getMentionData(): MentionData {
|
||||
return this.__mentionData
|
||||
}
|
||||
|
||||
setMentionData(mentionData: MentionData): void {
|
||||
const writable = this.getWritable()
|
||||
writable.__mentionData = mentionData
|
||||
}
|
||||
|
||||
override createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement("span")
|
||||
dom.className = config.theme.mention || "mention-node"
|
||||
|
|
@ -100,28 +106,24 @@ export class MentionNode extends DecoratorNode<React.JSX.Element> {
|
|||
* For export markdown conversion
|
||||
*/
|
||||
override getTextContent(): string {
|
||||
const { type, value } = this.__mentionData
|
||||
if (type === "date" && value) {
|
||||
return value as string
|
||||
}
|
||||
|
||||
if (
|
||||
type === "category" &&
|
||||
typeof value === "string" &&
|
||||
value.startsWith(ROUTE_FEED_IN_FOLDER)
|
||||
) {
|
||||
const { view } = getRouteParams()
|
||||
const ids = getCategoryFeedIds(value.slice(ROUTE_FEED_IN_FOLDER.length), view)
|
||||
return `<mention-feed ids=${JSON.stringify(ids)}></mention-feed>`
|
||||
}
|
||||
|
||||
return `<mention-${type} id="${value}"></mention-${type}>`
|
||||
return getMentionTextValue(this.__mentionData)
|
||||
}
|
||||
|
||||
override decorate(_editor: LexicalEditor): React.JSX.Element {
|
||||
override decorate(editor: LexicalEditor): React.JSX.Element {
|
||||
// Use a combination of key and value to ensure re-render when mention data changes
|
||||
const dataKey =
|
||||
typeof this.__mentionData.value === "string"
|
||||
? this.__mentionData.value
|
||||
: String(this.__mentionData.value)
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<MentionComponent mentionData={this.__mentionData} />
|
||||
<MentionComponent
|
||||
mentionData={this.__mentionData}
|
||||
nodeKey={this.__key}
|
||||
editor={editor}
|
||||
key={`${this.__key}-${dataKey}`}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { DateTimePicker } from "@follow/components/ui/input/index.js"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -5,44 +6,76 @@ import {
|
|||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from "@follow/components/ui/tooltip/index.js"
|
||||
import { getView } from "@follow/constants"
|
||||
import { cn } from "@follow/utils"
|
||||
import dayjs from "dayjs"
|
||||
import type { LexicalEditor } from "lexical"
|
||||
import { $getNodeByKey } from "lexical"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { RANGE_WITH_LABEL_KEY } from "../hooks/dateMentionConfig"
|
||||
import { getDateMentionDisplayName } from "../hooks/dateMentionUtils"
|
||||
import {
|
||||
createDateMentionData,
|
||||
getDateMentionDisplayName,
|
||||
parseRangeValue,
|
||||
} from "../hooks/dateMentionUtils"
|
||||
import { $isMentionNode } from "../MentionNode"
|
||||
import type { MentionData } from "../types"
|
||||
import { getMentionTextValue } from "../utils/mentionTextValue"
|
||||
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
|
||||
|
||||
interface MentionComponentProps {
|
||||
mentionData: MentionData
|
||||
className?: string
|
||||
nodeKey?: string
|
||||
editor?: LexicalEditor
|
||||
}
|
||||
|
||||
const MentionTooltipContent = ({
|
||||
mentionData,
|
||||
displayName,
|
||||
}: {
|
||||
mentionData: MentionData
|
||||
displayName: string
|
||||
}) => (
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center rounded text-white",
|
||||
mentionData.type === "entry" && "bg-blue",
|
||||
mentionData.type === "feed" && "bg-orange",
|
||||
mentionData.type === "date" && "bg-purple",
|
||||
mentionData.type === "shortcut" && "bg-amber-700",
|
||||
)}
|
||||
>
|
||||
<MentionTypeIcon type={mentionData.type} value={mentionData.value} className="size-3" />
|
||||
</div>
|
||||
<span className="text-text text-sm">{displayName}</span>
|
||||
</div>
|
||||
)
|
||||
const MentionTooltipContent = ({ mentionData }: { mentionData: MentionData }) => {
|
||||
const displayValue = getMentionTextValue(mentionData)
|
||||
|
||||
const getMentionStyles = (type: MentionData["type"]) => {
|
||||
const getIconBgColor = () => {
|
||||
if (mentionData.type === "view" && typeof mentionData.value === "number") {
|
||||
const viewDef = getView(mentionData.value)
|
||||
if (viewDef?.backgroundClassName) {
|
||||
return viewDef.backgroundClassName
|
||||
}
|
||||
}
|
||||
|
||||
switch (mentionData.type) {
|
||||
case "entry": {
|
||||
return "bg-blue"
|
||||
}
|
||||
case "feed": {
|
||||
return "bg-orange"
|
||||
}
|
||||
case "date": {
|
||||
return "bg-purple"
|
||||
}
|
||||
case "shortcut": {
|
||||
return "bg-amber-700"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded text-white",
|
||||
getIconBgColor(),
|
||||
)}
|
||||
>
|
||||
<MentionTypeIcon type={mentionData.type} value={mentionData.value} className="size-3" />
|
||||
</div>
|
||||
<span className="text-text text-sm">{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getMentionStyles = (mentionData: MentionData) => {
|
||||
const { type, value } = mentionData
|
||||
const baseStyles = tw`
|
||||
inline items-center gap-1 px-2 py-0.5 rounded-md
|
||||
font-medium text-sm cursor-pointer select-none
|
||||
|
|
@ -78,14 +111,21 @@ const getMentionStyles = (type: MentionData["type"]) => {
|
|||
)
|
||||
}
|
||||
case "view": {
|
||||
return cn(baseStyles)
|
||||
const viewDef = getView(value as number)
|
||||
return cn(baseStyles, viewDef!.mentionClassName)
|
||||
}
|
||||
case "shortcut": {
|
||||
return cn(baseStyles, "text-amber-700 border-amber-700/20", "hover:border-amber-700/30")
|
||||
}
|
||||
}
|
||||
}
|
||||
export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData, className }) => {
|
||||
|
||||
export const MentionComponent: React.FC<MentionComponentProps> = ({
|
||||
mentionData,
|
||||
className,
|
||||
nodeKey,
|
||||
editor,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation("ai")
|
||||
const language = i18n.language || i18n.resolvedLanguage || "en"
|
||||
|
||||
|
|
@ -100,28 +140,77 @@ export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData,
|
|||
}
|
||||
}, [mentionData, t, language])
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
// Handle mention click - could navigate to user profile, topic page, etc.
|
||||
// TODO: Implement navigation logic for mentions
|
||||
}
|
||||
const handleDateRangeChange = React.useCallback(
|
||||
(value: { start?: string; end?: string }) => {
|
||||
if (!nodeKey || !value.start || !value.end || !editor) return
|
||||
|
||||
const startDate = dayjs(value.start).startOf("day")
|
||||
const endDate = dayjs(value.end).startOf("day")
|
||||
const range = { start: startDate, end: endDate }
|
||||
|
||||
const newMentionData = createDateMentionData({
|
||||
range,
|
||||
translate: t,
|
||||
locale: language,
|
||||
withRangeKey: RANGE_WITH_LABEL_KEY,
|
||||
})
|
||||
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isMentionNode(node)) {
|
||||
node.setMentionData(newMentionData)
|
||||
}
|
||||
})
|
||||
},
|
||||
[nodeKey, editor, t, language],
|
||||
)
|
||||
|
||||
const currentDateRange = React.useMemo(() => {
|
||||
if (mentionData.type !== "date" || typeof mentionData.value !== "string") {
|
||||
return
|
||||
}
|
||||
const range = parseRangeValue(mentionData.value)
|
||||
if (!range) return
|
||||
|
||||
return {
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
}
|
||||
}, [mentionData])
|
||||
|
||||
const mentionSpan = (
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(getMentionStyles(mentionData), className)}>
|
||||
<MentionTypeIcon
|
||||
type={mentionData.type}
|
||||
value={mentionData.value}
|
||||
className="mr-1 translate-y-[2px]"
|
||||
/>
|
||||
<span>{displayName}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
)
|
||||
|
||||
const isEditableDateMention = mentionData.type === "date" && nodeKey && editor
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(getMentionStyles(mentionData.type), className)} onClick={handleClick}>
|
||||
<MentionTypeIcon
|
||||
type={mentionData.type}
|
||||
value={mentionData.value}
|
||||
className="mr-1 translate-y-[2px]"
|
||||
/>
|
||||
<span>{displayName}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isEditableDateMention ? (
|
||||
<DateTimePicker
|
||||
mode="range"
|
||||
rangeValue={currentDateRange}
|
||||
onRangeChange={handleDateRangeChange}
|
||||
minDate={dayjs().subtract(1, "month").toISOString()}
|
||||
>
|
||||
{mentionSpan}
|
||||
</DateTimePicker>
|
||||
) : (
|
||||
mentionSpan
|
||||
)}
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top" className="max-w-[300px]">
|
||||
<MentionTooltipContent mentionData={mentionData} displayName={displayName} />
|
||||
<MentionTooltipContent mentionData={mentionData} />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
|
|
|||
|
|
@ -31,9 +31,7 @@ export const MentionTypeIcon: React.FC<MentionTypeIconProps> = ({
|
|||
if (typeof value === "number") {
|
||||
const viewDef = getView(value)
|
||||
if (viewDef?.icon?.props?.className) {
|
||||
return (
|
||||
<i className={`${viewDef.icon.props.className} ${viewDef.className} ${className}`} />
|
||||
)
|
||||
return <i className={`${viewDef.icon.props.className} ${className}`} />
|
||||
}
|
||||
}
|
||||
return <i className={`i-mgc-grid-cute-re ${className}`} />
|
||||
|
|
|
|||
|
|
@ -151,7 +151,11 @@ export const createDateMentionData = ({
|
|||
}
|
||||
|
||||
export const parseRangeValue = (value: string): DateRange | null => {
|
||||
const [startIso, endIsoExclusive] = value.split("..", 2)
|
||||
// Parse XML format: <mention-date start="YYYY-MM-DD" end="YYYY-MM-DD"></mention-date>
|
||||
const match = value.match(/start="([^"]+)"\s+end="([^"]+)"/)
|
||||
if (!match) return null
|
||||
|
||||
const [, startIso, endIsoExclusive] = match
|
||||
if (!startIso || !endIsoExclusive) return null
|
||||
|
||||
const start = dayjs(startIso, MENTION_DATE_VALUE_FORMAT, true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { getCategoryFeedIds } from "@follow/store/subscription/getter"
|
||||
|
||||
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
import type { MentionData } from "../types"
|
||||
|
||||
export function getMentionTextValue(mentionData: MentionData): string {
|
||||
const { type, value } = mentionData
|
||||
|
||||
if (type === "date" && value) {
|
||||
return value as string
|
||||
}
|
||||
|
||||
if (type === "category" && typeof value === "string" && value.startsWith(ROUTE_FEED_IN_FOLDER)) {
|
||||
const { view } = getRouteParams()
|
||||
const ids = getCategoryFeedIds(value.slice(ROUTE_FEED_IN_FOLDER.length), view)
|
||||
return `<mention-feed ids=${JSON.stringify(ids)}></mention-feed>`
|
||||
}
|
||||
|
||||
return `<mention-${type} id="${value}"></mention-${type}>`
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ export interface DateTimePickerProps {
|
|||
rangePlaceholder?: string
|
||||
/** Class name for the content */
|
||||
contentClassName?: string
|
||||
/** Custom trigger element. If provided, replaces the default button */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +50,7 @@ export const DateTimePicker = memo<DateTimePickerProps>(
|
|||
onRangeChange,
|
||||
rangePlaceholder = "Select date range",
|
||||
contentClassName,
|
||||
children,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<"days" | "months" | "years">("days")
|
||||
|
|
@ -161,24 +164,26 @@ export const DateTimePicker = memo<DateTimePickerProps>(
|
|||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
buttonClassName={cn(
|
||||
"w-full justify-start text-left font-normal px-2.5",
|
||||
isRangeMode
|
||||
? !rangeStart && !rangeEnd && "text-text-tertiary"
|
||||
: !value && "text-text-tertiary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<i className="i-mgc-calendar-time-add-cute-re mr-2 size-4" />
|
||||
{isRangeMode
|
||||
? formatRangeButtonLabel()
|
||||
: value
|
||||
? currentDateTime.format("MMM DD, YYYY HH:mm")
|
||||
: placeholder}
|
||||
</Button>
|
||||
{children || (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
buttonClassName={cn(
|
||||
"w-full justify-start text-left font-normal px-2.5",
|
||||
isRangeMode
|
||||
? !rangeStart && !rangeEnd && "text-text-tertiary"
|
||||
: !value && "text-text-tertiary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<i className="i-mgc-calendar-time-add-cute-re mr-2 size-4" />
|
||||
{isRangeMode
|
||||
? formatRangeButtonLabel()
|
||||
: value
|
||||
? currentDateTime.format("MMM DD, YYYY HH:mm")
|
||||
: placeholder}
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export interface ViewDefinition {
|
|||
icon: React.JSX.Element
|
||||
className: string
|
||||
peerClassName: string
|
||||
mentionClassName: string
|
||||
backgroundClassName: string
|
||||
translation: string
|
||||
view: FeedViewType
|
||||
wideMode?: boolean
|
||||
|
|
@ -28,6 +30,8 @@ const viewAll: ViewDefinition = {
|
|||
icon: <i className="i-mgc-bubble-cute-fi" />,
|
||||
className: "text-folo",
|
||||
peerClassName: "peer-checked:text-folo dark:peer-checked:text-folo",
|
||||
mentionClassName: "bg-folo/10 text-folo border-folo/20 hover:bg-folo/20 hover:border-folo/30",
|
||||
backgroundClassName: "bg-folo",
|
||||
translation: "title,description,content",
|
||||
view: FeedViewType.All,
|
||||
activeColor: "#FF5C00",
|
||||
|
|
@ -43,6 +47,9 @@ const views: ViewDefinition[] = [
|
|||
icon: <i className="i-mgc-paper-cute-fi" />,
|
||||
className: "text-lime-600 dark:text-lime-500",
|
||||
peerClassName: "peer-checked:text-lime-600 dark:peer-checked:text-lime-500",
|
||||
mentionClassName:
|
||||
"bg-lime-600/10 text-lime-600 border-lime-600/20 hover:bg-lime-600/20 hover:border-lime-600/30",
|
||||
backgroundClassName: "bg-lime-600",
|
||||
translation: "title,description",
|
||||
view: FeedViewType.Articles,
|
||||
activeColor: "#FF5C00",
|
||||
|
|
@ -53,6 +60,9 @@ const views: ViewDefinition[] = [
|
|||
icon: <i className="i-mgc-thought-cute-fi" />,
|
||||
className: "text-sky-600 dark:text-sky-500",
|
||||
peerClassName: "peer-checked:text-sky-600 peer-checked:dark:text-sky-500",
|
||||
mentionClassName:
|
||||
"bg-sky-600/10 text-sky-600 border-sky-600/20 hover:bg-sky-600/20 hover:border-sky-600/30",
|
||||
backgroundClassName: "bg-sky-600",
|
||||
wideMode: true,
|
||||
translation: "content",
|
||||
view: FeedViewType.SocialMedia,
|
||||
|
|
@ -65,6 +75,9 @@ const views: ViewDefinition[] = [
|
|||
icon: <i className="i-mgc-pic-cute-fi" />,
|
||||
className: "text-green-600 dark:text-green-500",
|
||||
peerClassName: "peer-checked:text-green-600 peer-checked:dark:text-green-500",
|
||||
mentionClassName:
|
||||
"bg-green-600/10 text-green-600 border-green-600/20 hover:bg-green-600/20 hover:border-green-600/30",
|
||||
backgroundClassName: "bg-green-600",
|
||||
gridMode: true,
|
||||
wideMode: true,
|
||||
translation: "title",
|
||||
|
|
@ -78,6 +91,9 @@ const views: ViewDefinition[] = [
|
|||
icon: <i className="i-mgc-video-cute-fi" />,
|
||||
className: "text-red-600 dark:text-red-500",
|
||||
peerClassName: "peer-checked:text-red-600 peer-checked:dark:text-red-500",
|
||||
mentionClassName:
|
||||
"bg-red-600/10 text-red-600 border-red-600/20 hover:bg-red-600/20 hover:border-red-600/30",
|
||||
backgroundClassName: "bg-red-600",
|
||||
gridMode: true,
|
||||
wideMode: true,
|
||||
translation: "title",
|
||||
|
|
@ -91,6 +107,9 @@ const views: ViewDefinition[] = [
|
|||
icon: <i className="i-mgc-mic-cute-fi" />,
|
||||
className: "text-purple-600 dark:text-purple-500",
|
||||
peerClassName: "peer-checked:text-purple-600 peer-checked:dark:text-purple-500",
|
||||
mentionClassName:
|
||||
"bg-purple-600/10 text-purple-600 border-purple-600/20 hover:bg-purple-600/20 hover:border-purple-600/30",
|
||||
backgroundClassName: "bg-purple-600",
|
||||
translation: "title",
|
||||
view: FeedViewType.Audios,
|
||||
// purple-500
|
||||
|
|
@ -102,6 +121,9 @@ const views: ViewDefinition[] = [
|
|||
icon: <i className="i-mgc-announcement-cute-fi" />,
|
||||
className: "text-yellow-600 dark:text-yellow-500",
|
||||
peerClassName: "peer-checked:text-yellow-600 peer-checked:dark:text-yellow-500",
|
||||
mentionClassName:
|
||||
"bg-yellow-600/10 text-yellow-600 border-yellow-600/20 hover:bg-yellow-600/20 hover:border-yellow-600/30",
|
||||
backgroundClassName: "bg-yellow-600",
|
||||
translation: "title",
|
||||
view: FeedViewType.Notifications,
|
||||
// yellow-500
|
||||
|
|
|
|||
Loading…
Reference in New Issue