terry-wallet-login #15
@@ -1,7 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" translate="no">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<!-- The app ships its own 7-language i18n and serves localized content, so
|
||||||
|
browser auto-translation only garbles the UI. Opt out of it. -->
|
||||||
|
<meta name="google" content="notranslate" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ export function DocumentMeta() {
|
|||||||
const canonical = `${window.location.origin}${pathname}${search}`;
|
const canonical = `${window.location.origin}${pathname}${search}`;
|
||||||
|
|
||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
|
// Keep browser auto-translation off — the app is already fully localized.
|
||||||
|
document.documentElement.setAttribute("translate", "no");
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
|
||||||
setMeta('meta[name="description"]', "name", "description").content =
|
setMeta('meta[name="description"]', "name", "description").content =
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { EASE_OUT } from "../motion";
|
import { EASE_OUT } from "../motion";
|
||||||
import { langQuery, useI18n, type Lang } from "../i18n";
|
import { langQuery, useI18n, type Lang } from "../i18n";
|
||||||
|
import { localizePath, stripLangPrefix } from "../languageRoutes";
|
||||||
|
import { prefetchPostStream } from "./messageStream/hooks/usePostStream";
|
||||||
|
|
||||||
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ type BannerApiResponse = {
|
|||||||
|
|
||||||
const AUTOPLAY_MS = 3000;
|
const AUTOPLAY_MS = 3000;
|
||||||
const RESUME_AFTER_INTERACTION_MS = 8000;
|
const RESUME_AFTER_INTERACTION_MS = 8000;
|
||||||
|
// Product rule: show at most 10 banners, so the dot row always fits one line.
|
||||||
|
const MAX_BANNERS = 10;
|
||||||
const publicMenuOpenChangeEvent = "ark:public-menu-open-change";
|
const publicMenuOpenChangeEvent = "ark:public-menu-open-change";
|
||||||
|
|
||||||
function bannerLangParam(lang: Lang): string {
|
function bannerLangParam(lang: Lang): string {
|
||||||
@@ -63,18 +67,38 @@ function internalPath(linkUrl: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner link URLs are stored unprefixed (e.g. `/browse?post=123`). When the
|
||||||
|
* viewer is on a non-English locale we must re-prefix them with the active
|
||||||
|
* language path (`/malay/browse?post=123`) so navigation doesn't drop into
|
||||||
|
* the English version of the post.
|
||||||
|
*/
|
||||||
|
function localizeLinkUrl(linkUrl: string, lang: Lang): string {
|
||||||
|
const path = internalPath(linkUrl);
|
||||||
|
if (!path) return linkUrl;
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
const bare = stripLangPrefix(url.pathname);
|
||||||
|
return localizePath(bare, lang) + url.search + url.hash;
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
.filter((item) => item.imageUrl)
|
.filter((item) => item.imageUrl)
|
||||||
|
.slice(0, MAX_BANNERS)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const imageUrl = assetUrl(item.imageUrl);
|
const imageUrl = assetUrl(item.imageUrl);
|
||||||
|
const raw = (item.linkUrl ?? "").trim();
|
||||||
|
// Treat empty, placeholder, or javascript: URLs as "no link" so the
|
||||||
|
// slide renders as a non-interactive image rather than a dead anchor.
|
||||||
|
const linkUrl =
|
||||||
|
raw && raw !== "#" && !/^javascript:/i.test(raw) ? raw : undefined;
|
||||||
return {
|
return {
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
mobile: imageUrl,
|
mobile: imageUrl,
|
||||||
desktop: imageUrl,
|
desktop: imageUrl,
|
||||||
alt: "",
|
alt: "",
|
||||||
linkUrl: item.linkUrl || undefined,
|
linkUrl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,6 +144,32 @@ export function FigmaBanner() {
|
|||||||
};
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
|
// Banner clicks land on /browse?post=<id>. Warm the all-scope stream cache
|
||||||
|
// the moment the banner is on screen so the destination renders instantly
|
||||||
|
// instead of showing a skeleton / loading state before scrolling to the post.
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchPostStream({
|
||||||
|
scope: { kind: "all" },
|
||||||
|
type: "all",
|
||||||
|
q: "",
|
||||||
|
sort: "",
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
// Safety net: if the user taps quickly before mount-prefetch completes,
|
||||||
|
// pointerdown re-issues the prefetch. Same-key requests are deduped by the
|
||||||
|
// stream cache, so this is free when the warm-up already ran.
|
||||||
|
const prefetchAllStream = useCallback(() => {
|
||||||
|
prefetchPostStream({
|
||||||
|
scope: { kind: "all" },
|
||||||
|
type: "all",
|
||||||
|
q: "",
|
||||||
|
sort: "",
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
|
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
|
||||||
const scroller = scrollerRef.current;
|
const scroller = scrollerRef.current;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
@@ -199,6 +249,7 @@ export function FigmaBanner() {
|
|||||||
}, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]);
|
}, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]);
|
||||||
|
|
||||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
prefetchAllStream();
|
||||||
if (event.pointerType !== "mouse") return;
|
if (event.pointerType !== "mouse") return;
|
||||||
const scroller = scrollerRef.current;
|
const scroller = scrollerRef.current;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
@@ -272,39 +323,42 @@ export function FigmaBanner() {
|
|||||||
const path = internalPath(linkUrl);
|
const path = internalPath(linkUrl);
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
navigate(path);
|
navigate(localizeLinkUrl(path, lang));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (slides.length === 0) return null;
|
if (slides.length === 0) return null;
|
||||||
|
|
||||||
|
// At most MAX_BANNERS slides, so the dots always fit on a single centered row.
|
||||||
const pagination = hasMultiple ? (
|
const pagination = hasMultiple ? (
|
||||||
<div
|
<div className="px-4">
|
||||||
className="flex items-center justify-center gap-1.5 md:gap-2"
|
<div
|
||||||
role="tablist"
|
className="mx-auto flex max-w-full items-center justify-center gap-1.5 md:gap-2"
|
||||||
aria-label="Banner pagination"
|
role="tablist"
|
||||||
>
|
aria-label="Banner pagination"
|
||||||
{slides.map((slide, index) => {
|
>
|
||||||
const active = index === activeIndex;
|
{slides.map((slide, index) => {
|
||||||
return (
|
const active = index === activeIndex;
|
||||||
<button
|
return (
|
||||||
key={slide.id}
|
<button
|
||||||
type="button"
|
key={slide.id}
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={active}
|
role="tab"
|
||||||
aria-label={`Go to slide ${index + 1}`}
|
aria-selected={active}
|
||||||
onClick={() => {
|
aria-label={`Go to slide ${index + 1}`}
|
||||||
pauseAutoplay();
|
onClick={() => {
|
||||||
setActiveIndex(index);
|
pauseAutoplay();
|
||||||
goTo(index, "smooth");
|
setActiveIndex(index);
|
||||||
}}
|
goTo(index, "smooth");
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
}}
|
||||||
active
|
className={`h-1.5 shrink-0 rounded-full transition-all ${
|
||||||
? "w-6 bg-ark-gold"
|
active
|
||||||
: "w-1.5 bg-[#7C7C7C] hover:bg-white/50"
|
? "w-6 bg-ark-gold"
|
||||||
}`}
|
: "w-1.5 bg-[#7C7C7C] hover:bg-white/50"
|
||||||
/>
|
}`}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -366,7 +420,7 @@ export function FigmaBanner() {
|
|||||||
>
|
>
|
||||||
{slide.linkUrl ? (
|
{slide.linkUrl ? (
|
||||||
<a
|
<a
|
||||||
href={slide.linkUrl}
|
href={localizeLinkUrl(slide.linkUrl, lang)}
|
||||||
className="block"
|
className="block"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
|
onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { postJSON } from "../../api";
|
import { postJSON } from "../../api";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
@@ -33,7 +33,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
usePostStream(params);
|
usePostStream(params);
|
||||||
const { ensureFavoriteIds } = useFavorites();
|
const { ensureFavoriteIds } = useFavorites();
|
||||||
const groups = useGroupedByDay(items, lang);
|
const groups = useGroupedByDay(items, lang);
|
||||||
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
|
const retryLabel = t("retry");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
|
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
|
||||||
@@ -85,12 +85,18 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
? hash.slice("#post-".length)
|
? hash.slice("#post-".length)
|
||||||
: "";
|
: "";
|
||||||
const targetPostId = queryTargetPostId || hashTargetPostId;
|
const targetPostId = queryTargetPostId || hashTargetPostId;
|
||||||
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
|
// Lock only engages while we are actively running the smooth-scroll animation
|
||||||
Boolean(queryTargetPostId),
|
// — not during the wait/pagination phase — so the page never feels frozen
|
||||||
);
|
// before the bubble exists.
|
||||||
|
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(false);
|
||||||
const handledTargetRef = useRef<string>("");
|
const handledTargetRef = useRef<string>("");
|
||||||
const targetScrollTimersRef = useRef<number[]>([]);
|
const targetScrollTimersRef = useRef<number[]>([]);
|
||||||
const targetScrollFrameRef = useRef<number | null>(null);
|
const targetScrollFrameRef = useRef<number | null>(null);
|
||||||
|
// Timestamp (perf clock) when the first batch of real items became visible
|
||||||
|
// for this target. Used to delay the deep-link smooth scroll until the
|
||||||
|
// initial Reveal in-view animations have had a moment to play, so the user
|
||||||
|
// sees content before the page starts moving.
|
||||||
|
const firstContentAtRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const clearTargetScrollTimers = () => {
|
const clearTargetScrollTimers = () => {
|
||||||
for (const timer of targetScrollTimersRef.current) {
|
for (const timer of targetScrollTimersRef.current) {
|
||||||
@@ -105,10 +111,29 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handledTargetRef.current = "";
|
handledTargetRef.current = "";
|
||||||
|
firstContentAtRef.current = null;
|
||||||
clearTargetScrollTimers();
|
clearTargetScrollTimers();
|
||||||
setIsAligningQueryTarget(Boolean(queryTargetPostId));
|
setIsAligningQueryTarget(false);
|
||||||
}, [queryTargetPostId, targetPostId]);
|
}, [queryTargetPostId, targetPostId]);
|
||||||
|
|
||||||
|
// Mark when first real content becomes visible (skeletons gone, items in).
|
||||||
|
// Captured per-target via the reset above so a later navigation re-measures.
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0 && !isLoading && firstContentAtRef.current === null) {
|
||||||
|
firstContentAtRef.current = performance.now();
|
||||||
|
}
|
||||||
|
}, [items.length, isLoading]);
|
||||||
|
|
||||||
|
// Banner / deep-link arrivals (`?post=<id>`) should always begin the
|
||||||
|
// smooth-scroll positioning from the top of the stream, so the user sees a
|
||||||
|
// clear, satisfying journey to the target post instead of a tiny nudge when
|
||||||
|
// they happen to revisit the page mid-scroll. Run before paint so the user
|
||||||
|
// never briefly sees the previous scrollY before the jump.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!queryTargetPostId) return;
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||||
|
}, [queryTargetPostId]);
|
||||||
|
|
||||||
useEffect(() => clearTargetScrollTimers, []);
|
useEffect(() => clearTargetScrollTimers, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,52 +199,43 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
window.scrollTo({ top, left: 0, behavior });
|
window.scrollTo({ top, left: 0, behavior });
|
||||||
};
|
};
|
||||||
|
|
||||||
const boundedSmoothScrollToTarget = () => {
|
|
||||||
const firstTarget = targetScrollTop();
|
|
||||||
if (firstTarget === null) return;
|
|
||||||
const start = window.scrollY;
|
|
||||||
const startedAt = performance.now();
|
|
||||||
const duration = 520;
|
|
||||||
const direction = firstTarget >= start ? 1 : -1;
|
|
||||||
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3);
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const latestTarget = targetScrollTop();
|
|
||||||
if (latestTarget === null) return;
|
|
||||||
const progress = Math.min(1, (now - startedAt) / duration);
|
|
||||||
const ideal = start + (latestTarget - start) * easeOutCubic(progress);
|
|
||||||
const next =
|
|
||||||
direction >= 0
|
|
||||||
? Math.min(ideal, latestTarget)
|
|
||||||
: Math.max(ideal, latestTarget);
|
|
||||||
window.scrollTo({ top: next, left: 0, behavior: "auto" });
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToTarget("auto");
|
|
||||||
setIsAligningQueryTarget(false);
|
|
||||||
targetScrollFrameRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prefersReducedMotion = window.matchMedia(
|
const prefersReducedMotion = window.matchMedia(
|
||||||
"(prefers-reduced-motion: reduce)",
|
"(prefers-reduced-motion: reduce)",
|
||||||
).matches;
|
).matches;
|
||||||
|
|
||||||
if (queryTargetPostId) {
|
if (queryTargetPostId) {
|
||||||
// Query deep-links (`?post=<id>`) usually come from Home cards/list
|
// Query deep-links (`?post=<id>`) come from banner / Home card clicks.
|
||||||
// rows. Keep the premium motion, but drive it ourselves so scrolling is
|
// Use the browser-native smooth scroll (runs on the compositor, much
|
||||||
// bounded to the current target position and cannot visibly pass the
|
// smoother than a hand-rolled rAF) and lock user scroll only for the
|
||||||
// target before snapping back. User-driven scroll is temporarily locked
|
// duration of the animation so the page never feels frozen. Quiet
|
||||||
// by the effect above; programmatic scroll remains allowed.
|
// re-alignments run inside the lock window to absorb late image-shift
|
||||||
targetScrollTimersRef.current = [80].map((ms) =>
|
// above the target; nothing nudges the user after the lock releases.
|
||||||
window.setTimeout(boundedSmoothScrollToTarget, ms),
|
//
|
||||||
);
|
// Hold the animation until the page has had ~300ms after the first
|
||||||
|
// content paint, so the in-view Reveal staggers on the top bubbles
|
||||||
|
// have time to fade in. Otherwise the smooth scroll starts before the
|
||||||
|
// user can see anything, and the journey reads as "blank flash".
|
||||||
|
const REVEAL_SETTLE_MS = 300;
|
||||||
|
const elapsed =
|
||||||
|
firstContentAtRef.current !== null
|
||||||
|
? performance.now() - firstContentAtRef.current
|
||||||
|
: 0;
|
||||||
|
const settle = Math.max(0, REVEAL_SETTLE_MS - elapsed);
|
||||||
|
|
||||||
|
setIsAligningQueryTarget(true);
|
||||||
|
targetScrollTimersRef.current = [
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.requestAnimationFrame(() =>
|
||||||
|
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
||||||
|
);
|
||||||
|
}, settle),
|
||||||
|
window.setTimeout(() => scrollToTarget("auto"), settle + 450),
|
||||||
|
window.setTimeout(() => scrollToTarget("auto"), settle + 750),
|
||||||
|
window.setTimeout(() => {
|
||||||
|
scrollToTarget("auto");
|
||||||
|
setIsAligningQueryTarget(false);
|
||||||
|
}, settle + 950),
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
window.requestAnimationFrame(() =>
|
window.requestAnimationFrame(() =>
|
||||||
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
||||||
@@ -241,10 +257,17 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not loaded yet — keep paging until it appears or the stream is exhausted.
|
// Not loaded yet — keep paging until it appears or the stream is
|
||||||
|
// exhausted. If the previous loadMore errored, stop the loop so the user
|
||||||
|
// sees the inline retry button instead of an endless retry cycle, and
|
||||||
|
// release the scroll lock so they can interact with the page.
|
||||||
|
if (error) {
|
||||||
|
setIsAligningQueryTarget(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (hasMore && !isLoading) loadMore();
|
if (hasMore && !isLoading) loadMore();
|
||||||
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
||||||
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
}, [targetPostId, items, hasMore, isLoading, error, loadMore]);
|
||||||
|
|
||||||
const updateParam = (key: string, value: string) => {
|
const updateParam = (key: string, value: string) => {
|
||||||
const n = new URLSearchParams(sp);
|
const n = new URLSearchParams(sp);
|
||||||
@@ -301,20 +324,29 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
<div
|
||||||
<span className="break-all">{error}</span>
|
role="alert"
|
||||||
|
className="my-4 flex flex-col gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<span className="break-words">{t("loadMoreFailed")}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => reset()}
|
onClick={() => (items.length === 0 ? reset() : loadMore())}
|
||||||
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
|
className="shrink-0 self-start rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500 sm:self-auto"
|
||||||
>
|
>
|
||||||
{retryLabel}
|
{retryLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading && !error ? (
|
||||||
<div className="py-4 text-center text-xs text-neutral-500">…</div>
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("loading")}
|
||||||
|
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-[80px] rounded-2xl" />
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -164,15 +164,22 @@ function cacheFirstPage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-flight prefetch keys so concurrent callers (PublicLayout + FigmaBanner +
|
||||||
|
// hover prefetch, etc.) collapse into a single network request instead of
|
||||||
|
// firing one per call site before the first response lands.
|
||||||
|
const inFlightPrefetches = new Set<string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Warm the cache for a stream view before the user navigates to it, so opening
|
* Warm the cache for a stream view before the user navigates to it, so opening
|
||||||
* the page shows content immediately instead of starting to load on arrival.
|
* the page shows content immediately instead of starting to load on arrival.
|
||||||
* No-op for the mock backend or when the first page is already cached.
|
* No-op for the mock backend, when the first page is already cached, or when
|
||||||
|
* an identical prefetch is already in flight.
|
||||||
*/
|
*/
|
||||||
export function prefetchPostStream(params: PostStreamParams): void {
|
export function prefetchPostStream(params: PostStreamParams): void {
|
||||||
if (USE_MOCK) return;
|
if (USE_MOCK) return;
|
||||||
const key = streamKey(params);
|
const key = streamKey(params);
|
||||||
if (readStreamCache(key)) return;
|
if (readStreamCache(key)) return;
|
||||||
|
if (inFlightPrefetches.has(key)) return;
|
||||||
|
|
||||||
const url = buildRealUrl(params);
|
const url = buildRealUrl(params);
|
||||||
const cachedPage = readJSONCache<PostListResponse>(url);
|
const cachedPage = readJSONCache<PostListResponse>(url);
|
||||||
@@ -181,9 +188,11 @@ export function prefetchPostStream(params: PostStreamParams): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inFlightPrefetches.add(key);
|
||||||
getJSON<PostListResponse>(url)
|
getJSON<PostListResponse>(url)
|
||||||
.then((page) => cacheFirstPage(params, page))
|
.then((page) => cacheFirstPage(params, page))
|
||||||
.catch(() => {});
|
.catch(() => {})
|
||||||
|
.finally(() => inFlightPrefetches.delete(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePostStream(params: PostStreamParams): PostStreamResult {
|
export function usePostStream(params: PostStreamParams): PostStreamResult {
|
||||||
|
|||||||
@@ -152,7 +152,38 @@ function LightboxView({
|
|||||||
const [index, setIndex] = useState(startIndex);
|
const [index, setIndex] = useState(startIndex);
|
||||||
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
|
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
|
||||||
const [showSaveHint, setShowSaveHint] = useState(true);
|
const [showSaveHint, setShowSaveHint] = useState(true);
|
||||||
|
const [hintTop, setHintTop] = useState<number | null>(null);
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
const measureHintPosition = useCallback(() => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!stage || !img) return;
|
||||||
|
const stageRect = stage.getBoundingClientRect();
|
||||||
|
const imgRect = img.getBoundingClientRect();
|
||||||
|
const gap = 12;
|
||||||
|
const safeBottomReserve = 80;
|
||||||
|
const desired = imgRect.bottom - stageRect.top + gap;
|
||||||
|
const maxTop = stageRect.height - safeBottomReserve;
|
||||||
|
setHintTop(Math.max(0, Math.min(desired, maxTop)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSaveHint) return;
|
||||||
|
measureHintPosition();
|
||||||
|
const img = imgRef.current;
|
||||||
|
const stage = stageRef.current;
|
||||||
|
const ro = new ResizeObserver(() => measureHintPosition());
|
||||||
|
if (img) ro.observe(img);
|
||||||
|
if (stage) ro.observe(stage);
|
||||||
|
window.addEventListener("resize", measureHintPosition);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", measureHintPosition);
|
||||||
|
};
|
||||||
|
}, [measureHintPosition, showSaveHint, index]);
|
||||||
|
|
||||||
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
|
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
|
||||||
// keys should all behave like a linear gallery, not a carousel.
|
// keys should all behave like a linear gallery, not a carousel.
|
||||||
@@ -192,7 +223,7 @@ function LightboxView({
|
|||||||
const current = images[index];
|
const current = images[index];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => setShowSaveHint(false), 2000);
|
const timer = window.setTimeout(() => setShowSaveHint(false), 2500);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -241,7 +272,10 @@ function LightboxView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image stage */}
|
{/* Image stage */}
|
||||||
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
|
<div
|
||||||
|
ref={stageRef}
|
||||||
|
className="relative flex min-h-0 w-full flex-1 items-center justify-center"
|
||||||
|
>
|
||||||
{hasMany && index > 0 ? (
|
{hasMany && index > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -270,25 +304,26 @@ function LightboxView({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showSaveHint ? (
|
{showSaveHint ? (
|
||||||
<div className="pointer-events-none fixed inset-x-0 top-[58%] z-30 flex -translate-y-1/2 justify-center px-4">
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 z-30 flex justify-center px-4"
|
||||||
|
style={hintTop != null ? { top: hintTop } : { bottom: 16 }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto relative flex w-44 max-w-[calc(100vw-2rem)] flex-col items-center gap-1.5 rounded-xl bg-black/70 px-4 py-3 text-center text-white shadow-2xl ring-1 ring-white/20 backdrop-blur-md animate-scale-in"
|
className="pointer-events-auto relative flex max-w-[calc(100vw-2rem)] items-center gap-3 rounded-full bg-black/75 px-5 py-3 text-white shadow-2xl ring-1 ring-white/20 backdrop-blur-md animate-scale-in"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<span className="text-2xl leading-none animate-bounce">☝️</span>
|
||||||
|
<span className="text-sm font-semibold leading-5">
|
||||||
|
{t("longPressImageSave")}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSaveHint(false)}
|
onClick={() => setShowSaveHint(false)}
|
||||||
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||||
aria-label="Close hint"
|
aria-label="Close hint"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/15 text-2xl animate-bounce">
|
|
||||||
☝️
|
|
||||||
</div>
|
|
||||||
<div className="max-w-full break-words text-xs font-medium leading-4">
|
|
||||||
{t("longPressImageSave")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -315,8 +350,10 @@ function LightboxView({
|
|||||||
{/* No select-none / touch-callout:none here so iOS Safari's native
|
{/* No select-none / touch-callout:none here so iOS Safari's native
|
||||||
long-press menu ("Save in Photos") works on the full-size image. */}
|
long-press menu ("Save in Photos") works on the full-size image. */}
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
src={current.url}
|
src={current.url}
|
||||||
alt={current.filename}
|
alt={current.filename}
|
||||||
|
onLoad={measureHintPosition}
|
||||||
className="max-h-full max-w-full object-contain [-webkit-touch-callout:default]"
|
className="max-h-full max-w-full object-contain [-webkit-touch-callout:default]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -340,14 +340,16 @@ export function PublicLayout() {
|
|||||||
// Current page name shown in the header brand slot (falls back to the brand).
|
// Current page name shown in the header brand slot (falls back to the brand).
|
||||||
const pageTitle = usePageTitle();
|
const pageTitle = usePageTitle();
|
||||||
|
|
||||||
// Warm the common stream views (全部资料 / 热门资料 / 最新) in the background so
|
// Warm the common stream views (全部资料 / 热门资料 / 最新) so tapping them
|
||||||
// tapping them shows content immediately. Run one at a time, spaced out and
|
// shows content immediately. The default "all" stream is the most common
|
||||||
// only while idle, so prefetching never competes with the current page or
|
// destination (banners, Home cards) and fires right on mount so a fast tap
|
||||||
// janks low-end phones. Prefetch is JSON-only (no images).
|
// still hits a warm cache. Popular / latest stay deferred to idle time so
|
||||||
|
// they don't compete with the current page on low-end phones.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const base = { scope: { kind: "all" as const }, type: "all", q: "", lang };
|
const base = { scope: { kind: "all" as const }, type: "all", q: "", lang };
|
||||||
|
prefetchPostStream({ ...base, sort: "" });
|
||||||
|
|
||||||
const jobs = [
|
const jobs = [
|
||||||
() => prefetchPostStream({ ...base, sort: "" }),
|
|
||||||
() => prefetchPostStream({ ...base, sort: "popular" }),
|
() => prefetchPostStream({ ...base, sort: "popular" }),
|
||||||
() => prefetchPostStream({ ...base, sort: "latest" }),
|
() => prefetchPostStream({ ...base, sort: "latest" }),
|
||||||
];
|
];
|
||||||
@@ -369,7 +371,7 @@ export function PublicLayout() {
|
|||||||
else stepTimer = window.setTimeout(runNext, 200);
|
else stepTimer = window.setTimeout(runNext, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTimer = window.setTimeout(schedule, 600);
|
const startTimer = window.setTimeout(schedule, 300);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(startTimer);
|
window.clearTimeout(startTimer);
|
||||||
window.clearTimeout(stepTimer);
|
window.clearTimeout(stepTimer);
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export const enDict: Dict = {
|
|||||||
tagsCommaLabel: "Tags (comma-separated)",
|
tagsCommaLabel: "Tags (comma-separated)",
|
||||||
uploadFile: "Upload",
|
uploadFile: "Upload",
|
||||||
loading: "Loading…",
|
loading: "Loading…",
|
||||||
|
loadMoreFailed:
|
||||||
|
"Couldn't load more posts. Check your connection and try again.",
|
||||||
|
retry: "Retry",
|
||||||
paginationPrev: "Previous",
|
paginationPrev: "Previous",
|
||||||
paginationNext: "Next",
|
paginationNext: "Next",
|
||||||
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export const idDict: Dict = {
|
|||||||
tagsCommaLabel: "Tag (dipisahkan koma)",
|
tagsCommaLabel: "Tag (dipisahkan koma)",
|
||||||
uploadFile: "Unggah",
|
uploadFile: "Unggah",
|
||||||
loading: "Memuat…",
|
loading: "Memuat…",
|
||||||
|
loadMoreFailed:
|
||||||
|
"Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.",
|
||||||
|
retry: "Coba lagi",
|
||||||
paginationPrev: "Sebelumnya",
|
paginationPrev: "Sebelumnya",
|
||||||
paginationNext: "Berikutnya",
|
paginationNext: "Berikutnya",
|
||||||
listRange: "Menampilkan {{from}}–{{to}} dari {{total}}",
|
listRange: "Menampilkan {{from}}–{{to}} dari {{total}}",
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ export const jaDict: Dict = {
|
|||||||
tagsCommaLabel: "タグ(カンマ区切り)",
|
tagsCommaLabel: "タグ(カンマ区切り)",
|
||||||
uploadFile: "アップロード",
|
uploadFile: "アップロード",
|
||||||
loading: "読み込み中…",
|
loading: "読み込み中…",
|
||||||
|
loadMoreFailed:
|
||||||
|
"追加の読み込みに失敗しました。接続を確認してやり直してください。",
|
||||||
|
retry: "再試行",
|
||||||
paginationPrev: "前へ",
|
paginationPrev: "前へ",
|
||||||
paginationNext: "次へ",
|
paginationNext: "次へ",
|
||||||
listRange: "{{from}}–{{to}} / 全 {{total}} 件",
|
listRange: "{{from}}–{{to}} / 全 {{total}} 件",
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export const koDict: Dict = {
|
|||||||
tagsCommaLabel: "태그 (쉼표로 구분)",
|
tagsCommaLabel: "태그 (쉼표로 구분)",
|
||||||
uploadFile: "업로드",
|
uploadFile: "업로드",
|
||||||
loading: "로딩 중…",
|
loading: "로딩 중…",
|
||||||
|
loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.",
|
||||||
|
retry: "다시 시도",
|
||||||
paginationPrev: "이전",
|
paginationPrev: "이전",
|
||||||
paginationNext: "다음",
|
paginationNext: "다음",
|
||||||
listRange: "{{from}}–{{to}} / 총 {{total}}건",
|
listRange: "{{from}}–{{to}} / 총 {{total}}건",
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export const msDict: Dict = {
|
|||||||
tagsCommaLabel: "Tag (dipisahkan koma)",
|
tagsCommaLabel: "Tag (dipisahkan koma)",
|
||||||
uploadFile: "Muat naik",
|
uploadFile: "Muat naik",
|
||||||
loading: "Memuatkan…",
|
loading: "Memuatkan…",
|
||||||
|
loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.",
|
||||||
|
retry: "Cuba lagi",
|
||||||
paginationPrev: "Sebelum",
|
paginationPrev: "Sebelum",
|
||||||
paginationNext: "Seterusnya",
|
paginationNext: "Seterusnya",
|
||||||
listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}",
|
listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}",
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export const viDict: Dict = {
|
|||||||
tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)",
|
tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)",
|
||||||
uploadFile: "Tải lên",
|
uploadFile: "Tải lên",
|
||||||
loading: "Đang tải…",
|
loading: "Đang tải…",
|
||||||
|
loadMoreFailed: "Không thể tải thêm bài. Hãy kiểm tra kết nối và thử lại.",
|
||||||
|
retry: "Thử lại",
|
||||||
paginationPrev: "Trước",
|
paginationPrev: "Trước",
|
||||||
paginationNext: "Sau",
|
paginationNext: "Sau",
|
||||||
listRange: "Hiển thị {{from}}–{{to}} trên {{total}}",
|
listRange: "Hiển thị {{from}}–{{to}} trên {{total}}",
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export const zhDict: Dict = {
|
|||||||
tagsCommaLabel: "标签(逗号分隔)",
|
tagsCommaLabel: "标签(逗号分隔)",
|
||||||
uploadFile: "上传文件",
|
uploadFile: "上传文件",
|
||||||
loading: "加载中…",
|
loading: "加载中…",
|
||||||
|
loadMoreFailed: "加载更多资料失败,请检查网络后重试。",
|
||||||
|
retry: "重试",
|
||||||
paginationPrev: "上一页",
|
paginationPrev: "上一页",
|
||||||
paginationNext: "下一页",
|
paginationNext: "下一页",
|
||||||
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
||||||
|
|||||||
@@ -45,7 +45,15 @@ export function Home() {
|
|||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const lp = useLocalizedPath();
|
const lp = useLocalizedPath();
|
||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
// Seed from cache on the first render so the categories (and their icons)
|
||||||
|
// are present immediately when navigating back to the home page, instead of
|
||||||
|
// flashing empty for a frame before the effect re-applies the cached data.
|
||||||
|
const [cats, setCats] = useState<Category[]>(() => {
|
||||||
|
const cached = readJSONCache<Category[]>(
|
||||||
|
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
|
);
|
||||||
|
return cached ? itemsOrEmpty(cached) : [];
|
||||||
|
});
|
||||||
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
||||||
const [latestPosts, setLatestPosts] = useState<Post[]>([]);
|
const [latestPosts, setLatestPosts] = useState<Post[]>([]);
|
||||||
const [popular, setPopular] = useState<PostBackedResource[]>([]);
|
const [popular, setPopular] = useState<PostBackedResource[]>([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user