terry-staging #16

Merged
terry merged 96 commits from terry-staging into main 2026-06-05 16:33:12 +00:00
15 changed files with 271 additions and 107 deletions
Showing only changes of commit 0898744deb - Show all commits

View File

@@ -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"

View File

@@ -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 =

View File

@@ -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!)}

View File

@@ -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}
</> </>
)} )}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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);

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}} 件",

View File

@@ -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}}건",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}} 条",

View File

@@ -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[]>([]);