terry-staging #12
@@ -3,8 +3,10 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { langQuery, useI18n, type Lang } from "../i18n";
|
import { langQuery, useI18n, type Lang } from "../i18n";
|
||||||
|
|
||||||
@@ -41,6 +43,24 @@ function bannerLangParam(lang: Lang): string {
|
|||||||
return langQuery(lang).toLowerCase();
|
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=<id>` triggers the stream's scroll-to-post without a full
|
||||||
|
* reload. External URLs return null and fall back to a plain `<a href>`.
|
||||||
|
*/
|
||||||
|
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[] {
|
function toSlides(items: BannerApiItem[]): BannerSlide[] {
|
||||||
return [...items]
|
return [...items]
|
||||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
@@ -59,8 +79,12 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] {
|
|||||||
|
|
||||||
export function FigmaBanner() {
|
export function FigmaBanner() {
|
||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [slides, setSlides] = useState<BannerSlide[]>([]);
|
const [slides, setSlides] = useState<BannerSlide[]>([]);
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(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 [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
||||||
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
|
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
|
||||||
@@ -177,7 +201,9 @@ export function FigmaBanner() {
|
|||||||
startScrollLeft: scroller.scrollLeft,
|
startScrollLeft: scroller.scrollLeft,
|
||||||
moved: false,
|
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
|
||||||
|
// <a>, swallowing the link. Capture only once a real drag begins (below).
|
||||||
pauseAutoplay();
|
pauseAutoplay();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +215,7 @@ export function FigmaBanner() {
|
|||||||
const dx = event.clientX - drag.startX;
|
const dx = event.clientX - drag.startX;
|
||||||
if (!drag.moved && Math.abs(dx) > 4) {
|
if (!drag.moved && Math.abs(dx) > 4) {
|
||||||
drag.moved = true;
|
drag.moved = true;
|
||||||
|
scroller.setPointerCapture(event.pointerId);
|
||||||
scroller.style.scrollSnapType = "none";
|
scroller.style.scrollSnapType = "none";
|
||||||
}
|
}
|
||||||
if (drag.moved) {
|
if (drag.moved) {
|
||||||
@@ -206,6 +233,11 @@ export function FigmaBanner() {
|
|||||||
scroller.releasePointerCapture(event.pointerId);
|
scroller.releasePointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
if (drag.moved) {
|
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 width = scroller.clientWidth || 1;
|
||||||
const nearest = Math.round(scroller.scrollLeft / width);
|
const nearest = Math.round(scroller.scrollLeft / width);
|
||||||
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
|
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
|
||||||
@@ -214,6 +246,27 @@ export function FigmaBanner() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSlideClick = (
|
||||||
|
event: ReactMouseEvent<HTMLAnchorElement>,
|
||||||
|
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=<id>` 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;
|
if (slides.length === 0) return null;
|
||||||
|
|
||||||
const pagination = hasMultiple ? (
|
const pagination = hasMultiple ? (
|
||||||
@@ -287,7 +340,12 @@ export function FigmaBanner() {
|
|||||||
aria-label={`${index + 1} / ${slides.length}`}
|
aria-label={`${index + 1} / ${slides.length}`}
|
||||||
>
|
>
|
||||||
{slide.linkUrl ? (
|
{slide.linkUrl ? (
|
||||||
<a href={slide.linkUrl} className="block" rel="noreferrer">
|
<a
|
||||||
|
href={slide.linkUrl}
|
||||||
|
className="block"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
|
||||||
|
>
|
||||||
{image}
|
{image}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ function PopularRankRow({
|
|||||||
<article className="relative flex items-center gap-3 rounded-2xl border border-ark-line bg-ark-panel p-3 transition hover:border-ark-gold/45 md:gap-4 md:p-4">
|
<article className="relative flex items-center gap-3 rounded-2xl border border-ark-line bg-ark-panel p-3 transition hover:border-ark-gold/45 md:gap-4 md:p-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`/resource/${post.id}`)}
|
onClick={() =>
|
||||||
|
navigate(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`)
|
||||||
|
}
|
||||||
aria-label={r.title}
|
aria-label={r.title}
|
||||||
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { useLocation } from "react-router-dom";
|
|||||||
* clamp to wherever the previous (taller) page was scrolled — e.g. landing at
|
* clamp to wherever the previous (taller) page was scrolled — e.g. landing at
|
||||||
* the bottom of a category page after clicking a card far down the home grid.
|
* the bottom of a category page after clicking a card far down the home grid.
|
||||||
*
|
*
|
||||||
* Skips navigations that carry a hash (`#post-<id>`, `#categories`, …) so
|
* Skips navigations that carry a hash (`#post-<id>`, `#categories`, …) or a
|
||||||
* anchor / deep-link targets keep their own scroll handling.
|
* `?post=<id>` deep link so the target page can handle its own alignment.
|
||||||
*/
|
*/
|
||||||
export function ScrollToTop() {
|
export function ScrollToTop() {
|
||||||
const { pathname, search, hash } = useLocation();
|
const { pathname, search, hash } = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hash) return;
|
if (hash || new URLSearchParams(search).has("post")) return;
|
||||||
window.scrollTo({ top: 0, left: 0 });
|
window.scrollTo({ top: 0, left: 0 });
|
||||||
}, [pathname, search, hash]);
|
}, [pathname, search, hash]);
|
||||||
|
|
||||||
|
|||||||
@@ -121,12 +121,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
"(prefers-reduced-motion: reduce)",
|
"(prefers-reduced-motion: reduce)",
|
||||||
).matches;
|
).matches;
|
||||||
|
|
||||||
// Show a deliberate "from top to target" transition when opening a card
|
// Query deep-links (`?post=<id>`) usually come from Home cards/list rows.
|
||||||
// from Home. The later auto re-alignments are intentionally delayed so
|
// Keep that navigation stable by jumping directly to the target instead
|
||||||
// they don't interrupt the visible smooth scroll animation.
|
// of first resetting to the top and then animating through the stream.
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
// Legacy hash links can still use the visible smooth scroll.
|
||||||
window.requestAnimationFrame(() =>
|
window.requestAnimationFrame(() =>
|
||||||
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
scrollToTarget(
|
||||||
|
queryTargetPostId || prefersReducedMotion ? "auto" : "smooth",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Media above the target can finish loading after the first scroll and
|
// Media above the target can finish loading after the first scroll and
|
||||||
|
|||||||
Reference in New Issue
Block a user