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:
parent
c27aced55b
commit
f1f9bc8c65
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { EntryPreviewCard } from "./EntryPreviewCard"
|
||||
export { FeedPreviewCard } from "./FeedPreviewCard"
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
Loading…
Reference in New Issue