feat: add hover preview cards for entries and feeds

- Introduced EntryPreviewCard and FeedPreviewCard components for enhanced hover previews.
- Implemented prefetching of entry details and feed information on hover.
- Updated AnimatedMarkdown to utilize the new hover preview components for better user interaction.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-09-29 20:47:20 +08:00
parent c27aced55b
commit f1f9bc8c65
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
5 changed files with 209 additions and 95 deletions

View File

@ -0,0 +1,92 @@
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@follow/components/ui/hover-card/index.js"
import { useEntry, usePrefetchEntryDetail } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { feedIconSelector } from "@follow/store/feed/selectors"
import { cn } from "@follow/utils"
import { m } from "motion/react"
import * as React from "react"
import { RelativeTime } from "~/components/ui/datetime"
import { FeedIcon } from "~/modules/feed/feed-icon"
interface EntryPreviewCardProps {
entryId: string
children: React.ReactNode
className?: string
}
export const EntryPreviewCard: React.FC<EntryPreviewCardProps> = ({
entryId,
children,
className,
}) => {
// Prefetch entry details on hover
usePrefetchEntryDetail(entryId)
const entry = useEntry(entryId, (state) => {
if (!state) return null
return {
title: state.title,
description: state.description,
author: state.author,
publishedAt: state.publishedAt,
feedId: state.feedId,
url: state.url,
}
})
const feed = useFeedById(entry?.feedId, feedIconSelector)
if (!entry || !feed) {
return <>{children}</>
}
return (
<HoverCard openDelay={300} closeDelay={100}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent className="w-80 p-0" side="top">
<m.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.15 }}
className={cn("overflow-hidden", className)}
>
{/* Header */}
<div className="bg-fill-tertiary border-border border-b p-3">
<div className="flex items-center gap-2">
<FeedIcon target={feed} size={16} />
<div className="min-w-0 flex-1">
<div className="text-text line-clamp-1 text-sm font-medium">{feed.title}</div>
<div className="text-text-tertiary line-clamp-1 text-xs">{feed.url}</div>
</div>
</div>
</div>
{/* Content */}
<div className="p-3">
<div className="space-y-2">
<h3 className="text-text line-clamp-2 text-sm font-semibold">{entry.title}</h3>
{entry.description && (
<p className="text-text-secondary line-clamp-3 text-xs">{entry.description}</p>
)}
<div className="flex items-center justify-between pt-2">
<div className="text-text-tertiary text-xs">
{entry.author && <span>by {entry.author}</span>}
</div>
<div className="text-text-tertiary text-xs">
<RelativeTime date={entry.publishedAt} />
</div>
</div>
</div>
</div>
</m.div>
</HoverCardContent>
</HoverCard>
)
}

View File

@ -0,0 +1,56 @@
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@follow/components/ui/hover-card/index.js"
import { useFeedById } from "@follow/store/feed/hooks"
import { feedIconSelector } from "@follow/store/feed/selectors"
import { cn } from "@follow/utils"
import { m } from "motion/react"
import * as React from "react"
import { FeedIcon } from "~/modules/feed/feed-icon"
interface FeedPreviewCardProps {
feedId: string
children: React.ReactNode
className?: string
}
export const FeedPreviewCard: React.FC<FeedPreviewCardProps> = ({
feedId,
children,
className,
}) => {
const feed = useFeedById(feedId, feedIconSelector)
if (!feed) {
return <>{children}</>
}
return (
<HoverCard openDelay={300} closeDelay={100}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent className="w-80 p-0" side="top">
<m.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.15 }}
className={cn("overflow-hidden", className)}
>
{/* Header */}
<div className="bg-fill-tertiary border-border border-b p-4">
<div className="flex items-start gap-3">
<FeedIcon target={feed} size={40} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-text line-clamp-1 text-sm font-semibold">{feed.title}</h3>
</div>
</div>
</div>
</div>
</m.div>
</HoverCardContent>
</HoverCard>
)
}

View File

@ -0,0 +1,2 @@
export { EntryPreviewCard } from "./EntryPreviewCard"
export { FeedPreviewCard } from "./FeedPreviewCard"

View File

@ -3,15 +3,6 @@
*/
import type { LinkProps } from "@follow/components/ui/link/LinkWithTooltip.js"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { feedIconSelector } from "@follow/store/feed/selectors"
import { cn, isBizId } from "@follow/utils"
import * as React from "react"
import type { Components } from "react-markdown"
@ -20,10 +11,10 @@ import rehypeRaw from "rehype-raw"
import remarkGfm from "remark-gfm"
import { MemoizedShikiCode } from "~/components/ui/code-highlighter"
import { EntryPreviewCard, FeedPreviewCard } from "~/components/ui/hover-preview"
import { MarkdownLink } from "~/components/ui/markdown/renderers"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { usePeekModal } from "~/hooks/biz/usePeekModal"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { ANIMATION_STYLE as ANIMATION_STYLE_DEFAULT } from "./constants"
import { TokenizedText } from "./TokenizedText"
@ -193,9 +184,6 @@ export const MarkdownAnimateText: React.FC<MarkdownAnimateTextProps> = ({
type BaseInlineFoloReferenceProps = {
type: "entry" | "feed"
id?: string
reason?: string
description?: string
title?: string
}
const InlineFoloReference: React.FC<
BaseInlineFoloReferenceProps & {
@ -203,7 +191,7 @@ const InlineFoloReference: React.FC<
className?: string
style?: React.CSSProperties
}
> = ({ type, children, className, style, id, title, reason, description }) => {
> = ({ type, children, className, style, id }) => {
const peekModal = usePeekModal()
const navigateEntry = useNavigateEntry()
@ -240,95 +228,32 @@ const InlineFoloReference: React.FC<
if (!targetId) return null
if (!title)
return (
<button
type="button"
aria-label={type === "entry" ? `Open entry ${targetId}` : `Open feed ${targetId}`}
title={type === "entry" ? `Open entry ${targetId}` : `Open feed ${targetId}`}
className={cn(
"text-text-secondary hover:text-text mx-[0.15em] inline-flex cursor-pointer items-center align-middle opacity-80 transition-opacity hover:opacity-100",
className,
)}
style={style}
onClick={handleClick}
>
<i className="i-mgc-docment-cute-re size-[1em]" />
</button>
)
return (
<Tooltip>
<TooltipTrigger>
{type === "entry" && <InnerFoloEntryReference id={id!} />}
{type === "feed" && <BaseFoloReferenceBadge>{title}</BaseFoloReferenceBadge>}
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
<InlineTooltipContent
title={title}
reason={reason}
description={description}
type={type}
id={id}
/>
</TooltipContent>
</TooltipPortal>
</Tooltip>
)
}
const InnerFoloEntryReference: React.FC<{ id: string }> = ({ id }) => {
const feedId = useEntry(id, (s) => s.feedId)
const feedTitle = useFeedById(feedId, (s) => s.title)
return <BaseFoloReferenceBadge>{feedTitle}</BaseFoloReferenceBadge>
}
const BaseFoloReferenceBadge = (props: React.PropsWithChildren) => {
return (
const isEntry = type === "entry"
const button = (
<button
type="button"
aria-label={type === "entry" ? `Open entry ${targetId}` : `Open feed ${targetId}`}
title={type === "entry" ? `Open entry ${targetId}` : `Open feed ${targetId}`}
className={cn(
`text-text-secondary hover:text-text bg-material-medium mx-[0.15em] inline-flex cursor-pointer items-center rounded-full px-2 opacity-80 transition-opacity hover:opacity-100`,
`-translate-y-0.5 text-[0.65rem]`,
"text-text-secondary hover:text-text mx-[0.15em] inline-flex cursor-pointer items-center align-middle opacity-80 transition-opacity hover:opacity-100",
className,
)}
style={style}
onClick={handleClick}
>
{props.children}
{isEntry ? (
<i className="i-mgc-docment-cute-re size-[1em]" />
) : (
<i className="i-mgc-rss-2-cute-fi size-[1em]" />
)}
</button>
)
}
export const InlineTooltipContent: React.FC<BaseInlineFoloReferenceProps> = ({
title,
reason,
description,
type,
id,
}) => {
if (!id) return null
return (
<div className="flex flex-col gap-2 p-1">
<h4 className="text-text flex items-center text-sm">
{type === "entry" ? <TooltipEntryIcon entryId={id} /> : <TooltipFeedIcon feedId={id} />}
{title}
</h4>
{reason && <h2 className="text-text font-medium">{reason}</h2>}
{description && <p className="text-sm">{description}</p>}
</div>
)
}
const TooltipEntryIcon = ({ entryId }: { entryId: string }) => {
const feedId = useEntry(entryId, (e) => e.feedId)
if (!feedId) return null
return <TooltipFeedIcon feedId={feedId} />
}
const TooltipFeedIcon = ({ feedId }: { feedId: string }) => {
const target = useFeedById(feedId, feedIconSelector)
if (!target) return null
return <FeedIcon size={16} target={target} />
if (isEntry) {
return <EntryPreviewCard entryId={targetId}>{button}</EntryPreviewCard>
} else {
return <FeedPreviewCard feedId={targetId}>{button}</FeedPreviewCard>
}
}
const RelatedEntryLink = (props: LinkProps) => {

View File

@ -0,0 +1,39 @@
import { cn } from "@follow/utils/utils"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import * as React from "react"
import { RootPortal } from "../portal"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = ({
ref,
className,
align = "center",
sideOffset = 8,
...props
}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
ref?: React.Ref<React.ElementRef<typeof HoverCardPrimitive.Content> | null>
}) => (
<RootPortal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"bg-material-medium backdrop-blur-background border-border text-text z-[60] w-fit overflow-hidden rounded-md border shadow-lg",
"motion-scale-in-95 motion-duration-200 text-body",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</RootPortal>
)
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
const HoverCardArrow = HoverCardPrimitive.Arrow
export { HoverCard, HoverCardArrow, HoverCardContent, HoverCardTrigger }