| | import React, { ReactNode } from 'react'; |
| | import * as Ariakit from '@ariakit/react'; |
| | import { ChevronDown, Paperclip } from 'lucide-react'; |
| | import { VisuallyHidden } from '@ariakit/react'; |
| | import { useLocalize } from '~/hooks'; |
| | import { cn } from '~/utils'; |
| |
|
| | export interface SourceData { |
| | link: string; |
| | title?: string; |
| | attribution?: string; |
| | snippet?: string; |
| | } |
| |
|
| | interface SourceHovercardProps { |
| | source: SourceData; |
| | label: string; |
| | onMouseEnter?: () => void; |
| | onMouseLeave?: () => void; |
| | onClick?: (e: React.MouseEvent) => void; |
| | isFile?: boolean; |
| | isLocalFile?: boolean; |
| | children?: ReactNode; |
| | } |
| |
|
| | |
| | function getFaviconUrl(domain: string) { |
| | return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; |
| | } |
| |
|
| | |
| | export function getCleanDomain(url: string) { |
| | const domain = url.replace(/(^\w+:|^)\/\//, '').split('/')[0]; |
| | return domain.startsWith('www.') ? domain.substring(4) : domain; |
| | } |
| |
|
| | export function FaviconImage({ domain, className = '' }: { domain: string; className?: string }) { |
| | return ( |
| | <div className={cn('relative size-4 flex-shrink-0 overflow-hidden rounded-full', className)}> |
| | <div className="absolute inset-0 rounded-full bg-white" /> |
| | <img src={getFaviconUrl(domain)} alt={domain} className="relative size-full" /> |
| | <div className="border-border-light/10 absolute inset-0 rounded-full border dark:border-transparent"></div> |
| | </div> |
| | ); |
| | } |
| |
|
| | export function SourceHovercard({ |
| | source, |
| | label, |
| | onMouseEnter, |
| | onMouseLeave, |
| | onClick, |
| | isFile = false, |
| | isLocalFile = false, |
| | children, |
| | }: SourceHovercardProps) { |
| | const localize = useLocalize(); |
| | const domain = getCleanDomain(source.link || ''); |
| |
|
| | return ( |
| | <span className="relative ml-0.5 inline-block"> |
| | <Ariakit.HovercardProvider showTimeout={150} hideTimeout={150}> |
| | <span className="flex items-center"> |
| | <Ariakit.HovercardAnchor |
| | render={ |
| | isFile ? ( |
| | <button |
| | onClick={onClick} |
| | className="ml-1 inline-block h-5 max-w-36 cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium text-blue-600 no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:text-blue-400 dark:hover:bg-surface-tertiary" |
| | onMouseEnter={onMouseEnter} |
| | onMouseLeave={onMouseLeave} |
| | title={ |
| | isLocalFile ? localize('com_sources_download_local_unavailable') : undefined |
| | } |
| | > |
| | {label} |
| | </button> |
| | ) : ( |
| | <a |
| | href={source.link} |
| | target="_blank" |
| | rel="noopener noreferrer" |
| | className="ml-1 inline-block h-5 max-w-36 cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:hover:bg-surface-tertiary" |
| | onMouseEnter={onMouseEnter} |
| | onMouseLeave={onMouseLeave} |
| | > |
| | {label} |
| | </a> |
| | ) |
| | } |
| | /> |
| | <Ariakit.HovercardDisclosure className="ml-0.5 rounded-full text-text-primary focus:outline-none focus:ring-2 focus:ring-ring"> |
| | <VisuallyHidden>{localize('com_citation_more_details', { label })}</VisuallyHidden> |
| | <ChevronDown className="icon-sm" /> |
| | </Ariakit.HovercardDisclosure> |
| | |
| | <Ariakit.Hovercard |
| | gutter={16} |
| | className="dark:shadow-lg-dark z-[999] w-[300px] max-w-[calc(100vw-2rem)] rounded-xl border border-border-medium bg-surface-secondary p-3 text-text-primary shadow-lg" |
| | portal={true} |
| | unmountOnHide={true} |
| | > |
| | {children} |
| | {!children && ( |
| | <> |
| | <span className="mb-2 flex items-center"> |
| | {isFile ? ( |
| | <div className="mr-2 flex h-4 w-4 items-center justify-center"> |
| | <Paperclip className="h-3 w-3 text-text-secondary" /> |
| | </div> |
| | ) : ( |
| | <FaviconImage domain={domain} className="mr-2" /> |
| | )} |
| | {isFile ? ( |
| | <button |
| | onClick={onClick} |
| | className="line-clamp-2 cursor-pointer overflow-hidden text-left text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3" |
| | > |
| | {source.attribution || source.title || localize('com_file_source')} |
| | </button> |
| | ) : ( |
| | <a |
| | href={source.link} |
| | target="_blank" |
| | rel="noopener noreferrer" |
| | className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3" |
| | > |
| | {source.attribution || domain} |
| | </a> |
| | )} |
| | </span> |
| | |
| | {isFile ? ( |
| | <> |
| | {source.snippet && ( |
| | <span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm"> |
| | {source.snippet} |
| | </span> |
| | )} |
| | </> |
| | ) : ( |
| | <> |
| | <h4 className="mb-1.5 mt-0 text-xs text-text-primary md:text-sm"> |
| | {source.title || source.link} |
| | </h4> |
| | {source.snippet && ( |
| | <span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm"> |
| | {source.snippet} |
| | </span> |
| | )} |
| | </> |
| | )} |
| | </> |
| | )} |
| | </Ariakit.Hovercard> |
| | </span> |
| | </Ariakit.HovercardProvider> |
| | </span> |
| | ); |
| | } |
| |
|