Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 55 additions & 34 deletions components/LinkPreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface PreviewData {
image?: string | null;
}

function truncate(desc: string, maxLength = 200) {
function truncate(desc: string, maxLength = 260) {
if (desc.length <= maxLength) return desc;
const cut = desc.slice(0, maxLength);
const lastSpace = cut.lastIndexOf(" ");
Expand All @@ -33,6 +33,7 @@ export function LinkPreviewCard({
const [pos, setPos] = useState({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
const anchorRef = useRef<HTMLAnchorElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

Expand Down Expand Up @@ -100,7 +101,14 @@ export function LinkPreviewCard({
cancelHide();
const rect = anchorRef.current?.getBoundingClientRect();
if (rect) {
setPos({ top: rect.bottom + 8, left: rect.left });
const cardWidth = 416;
let left = rect.left;

if (left + cardWidth > window.innerWidth) {
left = window.innerWidth - cardWidth - 16;
}

setPos({ top: rect.bottom + 8 + window.scrollY, left });
Comment on lines +104 to +111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The viewport collision detection only handles right-edge overflow but doesn't account for left-edge or bottom-edge cases. If the card is positioned near the left edge of the viewport, it could still be clipped. Additionally, the card width is hardcoded to 416px, but the actual card width is now 26rem (416px at 16px base), which should be verified to match.
The logic also doesn't prevent the card from extending below the viewport, which could make content unreadable on smaller screens or when the link is near the bottom of the page.

Confidence: 4/5

Suggested Fix
Suggested change
const cardWidth = 416;
let left = rect.left;
if (left + cardWidth > window.innerWidth) {
left = window.innerWidth - cardWidth - 16;
}
setPos({ top: rect.bottom + 8 + window.scrollY, left });
const cardWidth = 416;
const cardHeight = 300; // Approximate height for preview card
let left = rect.left;
let top = rect.bottom + 8 + window.scrollY;
// Handle right-edge overflow
if (left + cardWidth > window.innerWidth) {
left = window.innerWidth - cardWidth - 16;
}
// Handle left-edge overflow
if (left < 16) {
left = 16;
}
// Handle bottom-edge overflow
if (top + cardHeight > window.innerHeight + window.scrollY) {
top = rect.top - cardHeight - 8 + window.scrollY;
}
setPos({ top, left });

This improved collision detection handles all three edges: right, left, and bottom. It also attempts to reposition the card above the link if there's not enough space below. The cardHeight should be adjusted based on the actual rendered height of the preview card.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In components/LinkPreviewCard.tsx around line 104, the viewport collision detection only
prevents right-edge clipping but doesn't handle left-edge or bottom-edge overflow. This
can cause the preview card to be clipped off-screen on smaller viewports or when positioned
near the bottom. Add collision detection for left and bottom edges, and implement fallback
positioning (e.g., show card above the link if there's not enough space below).

📍 This suggestion applies to lines 104-111

}
setHovering(true);
setImgError(false);
Expand All @@ -121,6 +129,11 @@ export function LinkPreviewCard({
};

const hasContent = Boolean(data?.title || data?.description || data?.image);
const showImage = data?.image && !imgError;

// Determine if the description is short or missing
const isDescriptionShort =
!data?.description || data.description.length < 100;

return (
<>
Expand All @@ -131,7 +144,7 @@ export function LinkPreviewCard({
rel="noopener noreferrer"
onMouseEnter={handleLinkEnter}
onMouseLeave={handleLinkLeave}
className="hover:underline cursor-pointer hover:opacity-80 transition-opacity"
className="hover:underline cursor-pointer hover:opacity-80 transition-opacity inline-block"
style={{ color: accent.indigoLightest }}
>
{children}
Expand All @@ -141,68 +154,62 @@ export function LinkPreviewCard({
<AnimatePresence>
{hovering && hasContent && (
<motion.div
ref={cardRef}
onMouseEnter={handleCardEnter}
onMouseLeave={handleCardLeave}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
className="fixed z-50 w-[22rem] p-4 overflow-hidden"
transition={{ duration: 0.12 }}
className="absolute z-50 w-[26rem] p-5 overflow-hidden select-none pointer-events-auto"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component was changed from fixed positioning to absolute positioning, but it still uses window.scrollY for calculations (line 111). This creates a mismatch: absolute positioning is relative to the nearest positioned ancestor, not the viewport. If this component is rendered inside a scrollable container that's not the window, the positioning will be incorrect.
Either the positioning should remain fixed (with viewport-relative coordinates), or the scroll offset calculation needs to be adjusted based on the actual scroll container.

Confidence: 4/5

Suggested Fix
Suggested change
className="absolute z-50 w-[26rem] p-5 overflow-hidden select-none pointer-events-auto"
className="fixed z-50 w-[26rem] p-5 overflow-hidden select-none pointer-events-auto"

Revert to fixed positioning to maintain consistency with the window.scrollY offset calculation. Alternatively, if absolute positioning is required for a specific reason, remove the window.scrollY offset and ensure the component's parent container is properly positioned.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In components/LinkPreviewCard.tsx around line 164, the positioning was changed from
'fixed' to 'absolute', but the top position calculation still includes window.scrollY
(line 111). This creates a positioning mismatch. Either revert to 'fixed' positioning
or remove the window.scrollY offset if 'absolute' is intentional. Verify the card
positions correctly when the page is scrolled.

style={{
top: pos.top,
left: pos.left,
background: "rgba(7, 7, 15, 0.95)",
background: "rgba(7, 7, 15, 0.96)",
border: `1px solid ${indigo(0.15)}`,
backdropFilter: "blur(8px)",
backdropFilter: "blur(12px)",
boxShadow: "0 12px 40px -12px rgba(0,0,0,0.8)",
}}
>
<div
className="absolute top-2 left-2 w-3 h-3"
className="absolute top-2.5 left-2.5 w-3.5 h-3.5 pointer-events-none"
style={{
borderTop: `1.5px solid ${indigo(0.25)}`,
borderLeft: `1.5px solid ${indigo(0.25)}`,
borderTop: `1.5px solid ${indigo(0.3)}`,
borderLeft: `1.5px solid ${indigo(0.3)}`,
}}
/>
<div
className="absolute top-2 right-2 w-3 h-3"
className="absolute top-2.5 right-2.5 w-3.5 h-3.5 pointer-events-none"
style={{
borderTop: `1.5px solid ${indigo(0.25)}`,
borderRight: `1.5px solid ${indigo(0.25)}`,
borderTop: `1.5px solid ${indigo(0.3)}`,
borderRight: `1.5px solid ${indigo(0.3)}`,
}}
/>
<div
className="absolute bottom-2 left-2 w-3 h-3"
className="absolute bottom-2.5 left-2.5 w-3.5 h-3.5 pointer-events-none"
style={{
borderBottom: `1.5px solid ${indigo(0.25)}`,
borderLeft: `1.5px solid ${indigo(0.25)}`,
borderBottom: `1.5px solid ${indigo(0.3)}`,
borderLeft: `1.5px solid ${indigo(0.3)}`,
}}
/>
<div
className="absolute bottom-2 right-2 w-3 h-3"
className="absolute bottom-2.5 right-2.5 w-3.5 h-3.5 pointer-events-none"
style={{
borderBottom: `1.5px solid ${indigo(0.25)}`,
borderRight: `1.5px solid ${indigo(0.25)}`,
borderBottom: `1.5px solid ${indigo(0.3)}`,
borderRight: `1.5px solid ${indigo(0.3)}`,
}}
/>

<div className="relative">
{data?.image && !imgError && (
<img
src={data.image}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setImgError(true)}
className="float-right ml-3 w-[4.5rem] h-[4.5rem] object-cover shrink-0"
style={{ border: `1px solid ${indigo(0.12)}` }}
/>
)}
<div className="min-w-0">
<div className="flex gap-5 items-start relative h-full">
<div className="flex-1 min-w-0">
<a
href={href}
target={newTab ? "_blank" : "_self"}
rel="noopener noreferrer"
className="font-semibold text-sm hover:underline block mb-1"
// conditional truncation
className={`font-semibold text-sm hover:underline block mb-2 leading-snug ${
isDescriptionShort ? "break-words" : "truncate"
}`}
style={{
fontFamily: "var(--font-geist-mono)",
color: accent.indigoLightest,
Expand All @@ -212,7 +219,7 @@ export function LinkPreviewCard({
</a>
{data?.description && (
<p
className="text-xs leading-relaxed"
className="text-xs leading-relaxed line-clamp-4 overflow-hidden text-ellipsis"
style={{
fontFamily: "var(--font-geist-mono)",
color: text.muted,
Expand All @@ -222,6 +229,20 @@ export function LinkPreviewCard({
</p>
)}
</div>

{showImage && (
<div className="shrink-0">
<img
src={data.image!}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setImgError(true)}
className="w-[6rem] h-[6rem] object-cover aspect-square transition-all duration-300 hover:scale-105"
style={{ border: `1px solid ${indigo(0.18)}` }}
/>
</div>
)}
</div>
</motion.div>
)}
Expand Down
Loading