inline-citation.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. 'use client';
  2. import { Badge } from '@/components/ui/badge';
  3. import {
  4. Carousel,
  5. type CarouselApi,
  6. CarouselContent,
  7. CarouselItem,
  8. } from '@/components/ui/carousel';
  9. import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
  10. import { cn } from '@/lib/utils';
  11. import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
  12. import {
  13. type ComponentProps,
  14. createContext,
  15. useCallback,
  16. useContext,
  17. useEffect,
  18. useState,
  19. } from 'react';
  20. export type InlineCitationProps = ComponentProps<'span'>;
  21. export const InlineCitation = ({ className, ...props }: InlineCitationProps) => (
  22. <span className={cn('group inline items-center gap-1', className)} {...props} />
  23. );
  24. export type InlineCitationTextProps = ComponentProps<'span'>;
  25. export const InlineCitationText = ({ className, ...props }: InlineCitationTextProps) => (
  26. <span className={cn('transition-colors group-hover:bg-accent', className)} {...props} />
  27. );
  28. export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
  29. export const InlineCitationCard = (props: InlineCitationCardProps) => (
  30. <HoverCard closeDelay={0} openDelay={0} {...props} />
  31. );
  32. export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
  33. sources: string[];
  34. };
  35. export const InlineCitationCardTrigger = ({
  36. sources,
  37. className,
  38. ...props
  39. }: InlineCitationCardTriggerProps) => (
  40. <HoverCardTrigger asChild>
  41. <Badge className={cn('ml-1 rounded-full', className)} variant="secondary" {...props}>
  42. {sources[0] ? (
  43. <>
  44. {new URL(sources[0]).hostname} {sources.length > 1 && `+${sources.length - 1}`}
  45. </>
  46. ) : (
  47. 'unknown'
  48. )}
  49. </Badge>
  50. </HoverCardTrigger>
  51. );
  52. export type InlineCitationCardBodyProps = ComponentProps<'div'>;
  53. export const InlineCitationCardBody = ({ className, ...props }: InlineCitationCardBodyProps) => (
  54. <HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
  55. );
  56. const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
  57. const useCarouselApi = () => {
  58. const context = useContext(CarouselApiContext);
  59. return context;
  60. };
  61. export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
  62. export const InlineCitationCarousel = ({
  63. className,
  64. children,
  65. ...props
  66. }: InlineCitationCarouselProps) => {
  67. const [api, setApi] = useState<CarouselApi>();
  68. return (
  69. <CarouselApiContext.Provider value={api}>
  70. <Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
  71. {children}
  72. </Carousel>
  73. </CarouselApiContext.Provider>
  74. );
  75. };
  76. export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
  77. export const InlineCitationCarouselContent = (props: InlineCitationCarouselContentProps) => (
  78. <CarouselContent {...props} />
  79. );
  80. export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
  81. export const InlineCitationCarouselItem = ({
  82. className,
  83. ...props
  84. }: InlineCitationCarouselItemProps) => (
  85. <CarouselItem className={cn('w-full space-y-2 p-4 pl-8', className)} {...props} />
  86. );
  87. export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
  88. export const InlineCitationCarouselHeader = ({
  89. className,
  90. ...props
  91. }: InlineCitationCarouselHeaderProps) => (
  92. <div
  93. className={cn(
  94. 'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
  95. className,
  96. )}
  97. {...props}
  98. />
  99. );
  100. export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
  101. export const InlineCitationCarouselIndex = ({
  102. children,
  103. className,
  104. ...props
  105. }: InlineCitationCarouselIndexProps) => {
  106. const api = useCarouselApi();
  107. const count = api?.scrollSnapList().length ?? 0;
  108. const [current, setCurrent] = useState(0);
  109. useEffect(() => {
  110. if (!api) {
  111. return;
  112. }
  113. // eslint-disable-next-line react-hooks/set-state-in-effect -- Initial sync from external embla carousel API
  114. setCurrent(api.selectedScrollSnap() + 1);
  115. const onSelect = () => {
  116. setCurrent(api.selectedScrollSnap() + 1);
  117. };
  118. api.on('select', onSelect);
  119. return () => {
  120. api.off('select', onSelect);
  121. };
  122. }, [api]);
  123. return (
  124. <div
  125. className={cn(
  126. 'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
  127. className,
  128. )}
  129. {...props}
  130. >
  131. {children ?? `${current}/${count}`}
  132. </div>
  133. );
  134. };
  135. export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
  136. export const InlineCitationCarouselPrev = ({
  137. className,
  138. ...props
  139. }: InlineCitationCarouselPrevProps) => {
  140. const api = useCarouselApi();
  141. const handleClick = useCallback(() => {
  142. if (api) {
  143. api.scrollPrev();
  144. }
  145. }, [api]);
  146. return (
  147. <button
  148. aria-label="Previous"
  149. className={cn('shrink-0', className)}
  150. onClick={handleClick}
  151. type="button"
  152. {...props}
  153. >
  154. <ArrowLeftIcon className="size-4 text-muted-foreground" />
  155. </button>
  156. );
  157. };
  158. export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
  159. export const InlineCitationCarouselNext = ({
  160. className,
  161. ...props
  162. }: InlineCitationCarouselNextProps) => {
  163. const api = useCarouselApi();
  164. const handleClick = useCallback(() => {
  165. if (api) {
  166. api.scrollNext();
  167. }
  168. }, [api]);
  169. return (
  170. <button
  171. aria-label="Next"
  172. className={cn('shrink-0', className)}
  173. onClick={handleClick}
  174. type="button"
  175. {...props}
  176. >
  177. <ArrowRightIcon className="size-4 text-muted-foreground" />
  178. </button>
  179. );
  180. };
  181. export type InlineCitationSourceProps = ComponentProps<'div'> & {
  182. title?: string;
  183. url?: string;
  184. description?: string;
  185. };
  186. export const InlineCitationSource = ({
  187. title,
  188. url,
  189. description,
  190. className,
  191. children,
  192. ...props
  193. }: InlineCitationSourceProps) => (
  194. <div className={cn('space-y-1', className)} {...props}>
  195. {title && <h4 className="truncate font-medium text-sm leading-tight">{title}</h4>}
  196. {url && <p className="truncate break-all text-muted-foreground text-xs">{url}</p>}
  197. {description && (
  198. <p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">{description}</p>
  199. )}
  200. {children}
  201. </div>
  202. );
  203. export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
  204. export const InlineCitationQuote = ({
  205. children,
  206. className,
  207. ...props
  208. }: InlineCitationQuoteProps) => (
  209. <blockquote
  210. className={cn('border-muted border-l-2 pl-3 text-muted-foreground text-sm italic', className)}
  211. {...props}
  212. >
  213. {children}
  214. </blockquote>
  215. );