diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index fe45642..bc044ba 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -3,8 +3,10 @@ import { useEffect, useRef, useState, + type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; +import { useNavigate } from "react-router-dom"; import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { langQuery, useI18n, type Lang } from "../i18n"; @@ -41,6 +43,24 @@ function bannerLangParam(lang: Lang): string { return langQuery(lang).toLowerCase(); } +/** + * If `linkUrl` points inside this app (a relative path, or an absolute URL on + * the same origin), return the router path so we can navigate client-side — + * e.g. `/browse?post=` triggers the stream's scroll-to-post without a full + * reload. External URLs return null and fall back to a plain ``. + */ +function internalPath(linkUrl: string): string | null { + try { + if (linkUrl.startsWith("//")) return null; + if (linkUrl.startsWith("/")) return linkUrl; + const url = new URL(linkUrl, window.location.origin); + if (url.origin !== window.location.origin) return null; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +} + function toSlides(items: BannerApiItem[]): BannerSlide[] { return [...items] .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) @@ -59,8 +79,12 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] { export function FigmaBanner() { const { lang } = useI18n(); + const navigate = useNavigate(); const [slides, setSlides] = useState([]); const scrollerRef = useRef(null); + // Set when a mouse drag just moved the carousel, so the synthetic `click` + // that follows pointerup doesn't navigate the slide's link. + const suppressClickRef = useRef(false); const [activeIndex, setActiveIndex] = useState(0); const [autoplayPaused, setAutoplayPaused] = useState(false); const [publicMenuOpen, setPublicMenuOpen] = useState(false); @@ -177,7 +201,9 @@ export function FigmaBanner() { startScrollLeft: scroller.scrollLeft, moved: false, }; - scroller.setPointerCapture(event.pointerId); + // Don't capture the pointer yet: capturing on press makes the browser + // dispatch the following `click` to the scroller instead of the slide's + // , swallowing the link. Capture only once a real drag begins (below). pauseAutoplay(); }; @@ -189,6 +215,7 @@ export function FigmaBanner() { const dx = event.clientX - drag.startX; if (!drag.moved && Math.abs(dx) > 4) { drag.moved = true; + scroller.setPointerCapture(event.pointerId); scroller.style.scrollSnapType = "none"; } if (drag.moved) { @@ -206,6 +233,11 @@ export function FigmaBanner() { scroller.releasePointerCapture(event.pointerId); } if (drag.moved) { + // Swallow the click that the browser fires right after a drag-release. + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); const width = scroller.clientWidth || 1; const nearest = Math.round(scroller.scrollLeft / width); const clamped = Math.max(0, Math.min(slides.length - 1, nearest)); @@ -214,6 +246,27 @@ export function FigmaBanner() { } }; + const handleSlideClick = ( + event: ReactMouseEvent, + linkUrl: string, + ) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + event.preventDefault(); + return; + } + // Let modified clicks (new tab / window) and external links use default + // browser behavior; route same-app links through the SPA so the stream's + // `?post=` scroll-to-post runs without a full page reload. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + const path = internalPath(linkUrl); + if (!path) return; + event.preventDefault(); + navigate(path); + }; + if (slides.length === 0) return null; const pagination = hasMultiple ? ( @@ -287,7 +340,12 @@ export function FigmaBanner() { aria-label={`${index + 1} / ${slides.length}`} > {slide.linkUrl ? ( - + handleSlideClick(event, slide.linkUrl!)} + > {image} ) : ( diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 0e9e938..3b96164 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -119,7 +119,9 @@ function PopularRankRow({