atmosphere explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add backlinks preview

Juliet c99beac7 fb02cd89

+94 -25
+33 -12
src/components/backlinks.tsx
··· 4 4 import { localDateFromTimestamp } from "../utils/date.js"; 5 5 import { Button } from "./button.jsx"; 6 6 import { Favicon } from "./favicon.jsx"; 7 + import DidHoverCard from "./hover-card/did.jsx"; 8 + import RecordHoverCard from "./hover-card/record.jsx"; 7 9 8 10 type BacklinksProps = { 9 11 target: string; ··· 49 51 {({ did, collection, rkey }) => { 50 52 const timestamp = 51 53 TID.validate(rkey) ? localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) : null; 54 + const uri = `at://${did}/${collection}/${rkey}`; 52 55 return ( 53 - <a 54 - href={`/at://${did}/${collection}/${rkey}`} 55 - class="grid grid-cols-[auto_1fr_auto] items-center gap-x-1 px-2 py-1.5 font-mono text-xs select-none hover:bg-neutral-200/50 active:bg-neutral-200/50 sm:gap-x-3 sm:px-3 dark:hover:bg-neutral-700/50 dark:active:bg-neutral-700/50" 56 - > 57 - <span class="text-blue-500 dark:text-blue-400">{rkey}</span> 58 - <span class="truncate text-neutral-700 dark:text-neutral-300" title={did}> 59 - {did} 60 - </span> 61 - <span class="text-neutral-500 tabular-nums dark:text-neutral-400"> 62 - {timestamp ?? ""} 63 - </span> 64 - </a> 56 + <RecordHoverCard 57 + uri={uri} 58 + hoverDelay={300} 59 + class="block" 60 + trigger={ 61 + <a 62 + href={`/${uri}`} 63 + class="grid grid-cols-[auto_1fr_auto] items-center gap-x-1 px-2 py-1.5 font-mono text-xs select-none hover:bg-neutral-200/50 sm:gap-x-3 sm:px-3 dark:hover:bg-neutral-700/50" 64 + > 65 + <span class="text-blue-500 dark:text-blue-400">{rkey}</span> 66 + <DidHoverCard 67 + did={did} 68 + hoverDelay={300} 69 + class="min-w-0" 70 + trigger={ 71 + <a 72 + href={`/at://${did}`} 73 + class="block truncate text-neutral-700 hover:underline dark:text-neutral-300" 74 + onClick={(e) => e.stopPropagation()} 75 + > 76 + {did} 77 + </a> 78 + } 79 + /> 80 + <span class="text-neutral-500 tabular-nums dark:text-neutral-400"> 81 + {timestamp ?? ""} 82 + </span> 83 + </a> 84 + } 85 + /> 65 86 ); 66 87 }} 67 88 </For>
+53 -10
src/components/hover-card/base.tsx
··· 1 1 import { A } from "@solidjs/router"; 2 2 import { createSignal, JSX, onCleanup, Show } from "solid-js"; 3 + import { Portal } from "solid-js/web"; 3 4 import { isTouchDevice } from "../../layout"; 4 5 5 6 interface HoverCardProps { ··· 11 12 newTab?: boolean; 12 13 /** Called when hover starts (for prefetching) */ 13 14 onHover?: () => void; 15 + /** Delay in ms before showing card and calling onHover (default: 0) */ 16 + hoverDelay?: number; 14 17 /** Custom trigger element - if provided, overrides href/label */ 15 18 trigger?: JSX.Element; 16 19 /** Additional classes for the wrapper span */ ··· 27 30 const [show, setShow] = createSignal(false); 28 31 29 32 const [previewHeight, setPreviewHeight] = createSignal(0); 33 + const [anchorRect, setAnchorRect] = createSignal<DOMRect | null>(null); 30 34 let anchorRef!: HTMLSpanElement; 31 35 let previewRef!: HTMLDivElement; 32 36 let resizeObserver: ResizeObserver | null = null; 37 + let hoverTimeout: number | null = null; 33 38 34 39 const setupResizeObserver = (el: HTMLDivElement) => { 35 40 resizeObserver?.disconnect(); ··· 42 47 43 48 onCleanup(() => { 44 49 resizeObserver?.disconnect(); 50 + if (hoverTimeout !== null) { 51 + clearTimeout(hoverTimeout); 52 + } 45 53 }); 46 54 47 - const isOverflowing = (previewHeight: number) => 48 - anchorRef && anchorRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; 55 + const isOverflowing = (previewHeight: number) => { 56 + const rect = anchorRect(); 57 + return rect && rect.top + previewHeight + 32 > window.innerHeight; 58 + }; 59 + 60 + const getPreviewStyle = () => { 61 + const rect = anchorRect(); 62 + if (!rect) return {}; 63 + 64 + const left = rect.left + rect.width / 2; 65 + const overflowing = isOverflowing(previewHeight()); 66 + const gap = 4; 67 + 68 + return { 69 + left: `${left}px`, 70 + top: overflowing ? `${rect.top - gap}px` : `${rect.bottom + gap}px`, 71 + transform: overflowing ? "translate(-50%, -100%)" : "translate(-50%, 0)", 72 + }; 73 + }; 49 74 50 75 const handleMouseEnter = () => { 51 - props.onHover?.(); 52 - setShow(true); 76 + const delay = props.hoverDelay ?? 0; 77 + setAnchorRect(anchorRef.getBoundingClientRect()); 78 + 79 + if (delay > 0) { 80 + hoverTimeout = window.setTimeout(() => { 81 + props.onHover?.(); 82 + setShow(true); 83 + hoverTimeout = null; 84 + }, delay); 85 + } else { 86 + props.onHover?.(); 87 + setShow(true); 88 + } 53 89 }; 54 90 55 91 const handleMouseLeave = () => { 92 + if (hoverTimeout !== null) { 93 + clearTimeout(hoverTimeout); 94 + hoverTimeout = null; 95 + } 56 96 setShow(false); 57 97 }; 58 98 ··· 73 113 </A> 74 114 )} 75 115 <Show when={show() && !isTouchDevice}> 76 - <div 77 - ref={setupResizeObserver} 78 - class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none absolute left-[50%] z-50 block -translate-x-1/2 overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700 ${props.previewClass || ""} ${isOverflowing(previewHeight()) ? "bottom-7" : "top-7"}`} 79 - > 80 - {props.children} 81 - </div> 116 + <Portal> 117 + <div 118 + ref={setupResizeObserver} 119 + style={getPreviewStyle()} 120 + class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none fixed z-50 block overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700 ${props.previewClass ?? "max-h-80 w-max max-w-sm font-mono text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg"}`} 121 + > 122 + {props.children} 123 + </div> 124 + </Portal> 82 125 </Show> 83 126 </span> 84 127 );
+4
src/components/hover-card/did.tsx
··· 8 8 newTab?: boolean; 9 9 class?: string; 10 10 labelClass?: string; 11 + trigger?: any; 12 + hoverDelay?: number; 11 13 } 12 14 13 15 interface DidInfo { ··· 65 67 label={props.did} 66 68 newTab={props.newTab} 67 69 onHover={handlePrefetch} 70 + hoverDelay={props.hoverDelay} 71 + trigger={props.trigger} 68 72 class={props.class} 69 73 labelClass={props.labelClass} 70 74 previewClass="w-max max-w-xs font-sans text-sm"
+4 -1
src/components/hover-card/record.tsx
··· 10 10 newTab?: boolean; 11 11 class?: string; 12 12 labelClass?: string; 13 + trigger?: any; 14 + hoverDelay?: number; 13 15 } 14 16 15 17 const recordCache = new Map<string, { value: unknown; loading: boolean; error?: string }>(); ··· 85 87 label={props.uri} 86 88 newTab={props.newTab} 87 89 onHover={handlePrefetch} 90 + hoverDelay={props.hoverDelay} 91 + trigger={props.trigger} 88 92 class={props.class} 89 93 labelClass={props.labelClass} 90 - previewClass="max-h-80 w-max max-w-sm text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg" 91 94 > 92 95 <Show when={record()?.loading}> 93 96 <div class="flex items-center gap-2 font-sans text-sm text-neutral-500 dark:text-neutral-400">
-1
src/views/car/explore.tsx
··· 544 544 </Show> 545 545 </button> 546 546 } 547 - previewClass="max-h-80 w-max max-w-sm text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg" 548 547 > 549 548 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 550 549 </HoverCard>
-1
src/views/collection.tsx
··· 51 51 </Show> 52 52 </> 53 53 } 54 - previewClass="max-h-80 w-max max-w-sm text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg" 55 54 > 56 55 <JSONValue 57 56 data={props.record.record.value as JSONType}