diff --git a/index.html b/index.html index 4751cc1..d3f15da 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,10 @@ - + + + (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) .filter((item) => item.imageUrl) + .slice(0, MAX_BANNERS) .map((item) => { 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 { id: String(item.id), mobile: imageUrl, desktop: imageUrl, alt: "", - linkUrl: item.linkUrl || undefined, + linkUrl, }; }); } @@ -120,6 +144,32 @@ export function FigmaBanner() { }; }, [lang]); + // Banner clicks land on /browse?post=. 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 scroller = scrollerRef.current; if (!scroller) return; @@ -199,6 +249,7 @@ export function FigmaBanner() { }, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]); const handlePointerDown = (event: ReactPointerEvent) => { + prefetchAllStream(); if (event.pointerType !== "mouse") return; const scroller = scrollerRef.current; if (!scroller) return; @@ -272,39 +323,42 @@ export function FigmaBanner() { const path = internalPath(linkUrl); if (!path) return; event.preventDefault(); - navigate(path); + navigate(localizeLinkUrl(path, lang)); }; if (slides.length === 0) return null; + // At most MAX_BANNERS slides, so the dots always fit on a single centered row. const pagination = hasMultiple ? ( -
- {slides.map((slide, index) => { - const active = index === activeIndex; - return ( -
) : null; @@ -366,7 +420,7 @@ export function FigmaBanner() { > {slide.linkUrl ? ( handleSlideClick(event, slide.linkUrl!)} diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 6ef9fe9..192ef5f 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -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 { postJSON } from "../../api"; import { useI18n } from "../../i18n"; @@ -33,7 +33,7 @@ export function MessageStream({ scope }: MessageStreamProps) { usePostStream(params); const { ensureFavoriteIds } = useFavorites(); const groups = useGroupedByDay(items, lang); - const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; + const retryLabel = t("retry"); useEffect(() => { void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined); @@ -85,12 +85,18 @@ export function MessageStream({ scope }: MessageStreamProps) { ? hash.slice("#post-".length) : ""; const targetPostId = queryTargetPostId || hashTargetPostId; - const [isAligningQueryTarget, setIsAligningQueryTarget] = useState( - Boolean(queryTargetPostId), - ); + // Lock only engages while we are actively running the smooth-scroll animation + // — not during the wait/pagination phase — so the page never feels frozen + // before the bubble exists. + const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(false); const handledTargetRef = useRef(""); const targetScrollTimersRef = useRef([]); const targetScrollFrameRef = useRef(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(null); const clearTargetScrollTimers = () => { for (const timer of targetScrollTimersRef.current) { @@ -105,10 +111,29 @@ export function MessageStream({ scope }: MessageStreamProps) { useEffect(() => { handledTargetRef.current = ""; + firstContentAtRef.current = null; clearTargetScrollTimers(); - setIsAligningQueryTarget(Boolean(queryTargetPostId)); + setIsAligningQueryTarget(false); }, [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=`) 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(() => { @@ -174,52 +199,43 @@ export function MessageStream({ scope }: MessageStreamProps) { 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( "(prefers-reduced-motion: reduce)", ).matches; if (queryTargetPostId) { - // Query deep-links (`?post=`) usually come from Home cards/list - // rows. Keep the premium motion, but drive it ourselves so scrolling is - // bounded to the current target position and cannot visibly pass the - // target before snapping back. User-driven scroll is temporarily locked - // by the effect above; programmatic scroll remains allowed. - targetScrollTimersRef.current = [80].map((ms) => - window.setTimeout(boundedSmoothScrollToTarget, ms), - ); + // Query deep-links (`?post=`) come from banner / Home card clicks. + // Use the browser-native smooth scroll (runs on the compositor, much + // smoother than a hand-rolled rAF) and lock user scroll only for the + // duration of the animation so the page never feels frozen. Quiet + // re-alignments run inside the lock window to absorb late image-shift + // above the target; nothing nudges the user after the lock releases. + // + // 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 { window.requestAnimationFrame(() => scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), @@ -241,10 +257,17 @@ export function MessageStream({ scope }: MessageStreamProps) { 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(); else if (!hasMore && !isLoading) setIsAligningQueryTarget(false); - }, [targetPostId, items, hasMore, isLoading, loadMore]); + }, [targetPostId, items, hasMore, isLoading, error, loadMore]); const updateParam = (key: string, value: string) => { const n = new URLSearchParams(sp); @@ -301,20 +324,29 @@ export function MessageStream({ scope }: MessageStreamProps) { ) : null} {error ? ( -
- {error} +
+ {t("loadMoreFailed")}
) : null} - {isLoading ? ( -
+ {isLoading && !error ? ( +
+ +
) : null} )} diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index e78f4c3..646de1f 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -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(); + /** * 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. - * 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 { if (USE_MOCK) return; const key = streamKey(params); if (readStreamCache(key)) return; + if (inFlightPrefetches.has(key)) return; const url = buildRealUrl(params); const cachedPage = readJSONCache(url); @@ -181,9 +188,11 @@ export function prefetchPostStream(params: PostStreamParams): void { return; } + inFlightPrefetches.add(key); getJSON(url) .then((page) => cacheFirstPage(params, page)) - .catch(() => {}); + .catch(() => {}) + .finally(() => inFlightPrefetches.delete(key)); } export function usePostStream(params: PostStreamParams): PostStreamResult { diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index 7b2dc2c..f2101d5 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -152,7 +152,38 @@ function LightboxView({ const [index, setIndex] = useState(startIndex); const [isCaptionVisible, setIsCaptionVisible] = useState(true); const [showSaveHint, setShowSaveHint] = useState(true); + const [hintTop, setHintTop] = useState(null); const touchStartX = useRef(null); + const stageRef = useRef(null); + const imgRef = useRef(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 // keys should all behave like a linear gallery, not a carousel. @@ -192,7 +223,7 @@ function LightboxView({ const current = images[index]; useEffect(() => { - const timer = window.setTimeout(() => setShowSaveHint(false), 2000); + const timer = window.setTimeout(() => setShowSaveHint(false), 2500); return () => window.clearTimeout(timer); }, []); @@ -241,7 +272,10 @@ function LightboxView({
{/* Image stage */} -
+
{hasMany && index > 0 ? ( -
- ☝️ -
-
- {t("longPressImageSave")} -
) : null} @@ -315,8 +350,10 @@ function LightboxView({ {/* No select-none / touch-callout:none here so iOS Safari's native long-press menu ("Save in Photos") works on the full-size image. */} {current.filename} diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 0b1e969..bb194c0 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -340,14 +340,16 @@ export function PublicLayout() { // Current page name shown in the header brand slot (falls back to the brand). const pageTitle = usePageTitle(); - // Warm the common stream views (全部资料 / 热门资料 / 最新) in the background so - // tapping them shows content immediately. Run one at a time, spaced out and - // only while idle, so prefetching never competes with the current page or - // janks low-end phones. Prefetch is JSON-only (no images). + // Warm the common stream views (全部资料 / 热门资料 / 最新) so tapping them + // shows content immediately. The default "all" stream is the most common + // destination (banners, Home cards) and fires right on mount so a fast tap + // 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(() => { const base = { scope: { kind: "all" as const }, type: "all", q: "", lang }; + prefetchPostStream({ ...base, sort: "" }); + const jobs = [ - () => prefetchPostStream({ ...base, sort: "" }), () => prefetchPostStream({ ...base, sort: "popular" }), () => prefetchPostStream({ ...base, sort: "latest" }), ]; @@ -369,7 +371,7 @@ export function PublicLayout() { else stepTimer = window.setTimeout(runNext, 200); }; - const startTimer = window.setTimeout(schedule, 600); + const startTimer = window.setTimeout(schedule, 300); return () => { window.clearTimeout(startTimer); window.clearTimeout(stepTimer); diff --git a/src/locales/en.ts b/src/locales/en.ts index 48d990f..fb94a67 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -126,6 +126,9 @@ export const enDict: Dict = { tagsCommaLabel: "Tags (comma-separated)", uploadFile: "Upload", loading: "Loading…", + loadMoreFailed: + "Couldn't load more posts. Check your connection and try again.", + retry: "Retry", paginationPrev: "Previous", paginationNext: "Next", listRange: "Showing {{from}}–{{to}} of {{total}}", diff --git a/src/locales/id.ts b/src/locales/id.ts index d49f526..9633c86 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -126,6 +126,9 @@ export const idDict: Dict = { tagsCommaLabel: "Tag (dipisahkan koma)", uploadFile: "Unggah", loading: "Memuat…", + loadMoreFailed: + "Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.", + retry: "Coba lagi", paginationPrev: "Sebelumnya", paginationNext: "Berikutnya", listRange: "Menampilkan {{from}}–{{to}} dari {{total}}", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index fba8bdf..db8d2b8 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -127,6 +127,9 @@ export const jaDict: Dict = { tagsCommaLabel: "タグ(カンマ区切り)", uploadFile: "アップロード", loading: "読み込み中…", + loadMoreFailed: + "追加の読み込みに失敗しました。接続を確認してやり直してください。", + retry: "再試行", paginationPrev: "前へ", paginationNext: "次へ", listRange: "{{from}}–{{to}} / 全 {{total}} 件", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index b89fc14..0f20c9a 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -126,6 +126,8 @@ export const koDict: Dict = { tagsCommaLabel: "태그 (쉼표로 구분)", uploadFile: "업로드", loading: "로딩 중…", + loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.", + retry: "다시 시도", paginationPrev: "이전", paginationNext: "다음", listRange: "{{from}}–{{to}} / 총 {{total}}건", diff --git a/src/locales/ms.ts b/src/locales/ms.ts index f993c9c..68bac45 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -126,6 +126,8 @@ export const msDict: Dict = { tagsCommaLabel: "Tag (dipisahkan koma)", uploadFile: "Muat naik", loading: "Memuatkan…", + loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.", + retry: "Cuba lagi", paginationPrev: "Sebelum", paginationNext: "Seterusnya", listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}", diff --git a/src/locales/vi.ts b/src/locales/vi.ts index fcb5be7..8ffc601 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -126,6 +126,8 @@ export const viDict: Dict = { tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)", uploadFile: "Tải lên", 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", paginationNext: "Sau", listRange: "Hiển thị {{from}}–{{to}} trên {{total}}", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index a638318..a5e2e34 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -124,6 +124,8 @@ export const zhDict: Dict = { tagsCommaLabel: "标签(逗号分隔)", uploadFile: "上传文件", loading: "加载中…", + loadMoreFailed: "加载更多资料失败,请检查网络后重试。", + retry: "重试", paginationPrev: "上一页", paginationNext: "下一页", listRange: "显示 {{from}}–{{to}},共 {{total}} 条", diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index a8412cc..df9d9de 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -45,7 +45,15 @@ export function Home() { const { t, lang } = useI18n(); const lp = useLocalizedPath(); const { hash } = useLocation(); - const [cats, setCats] = useState([]); + // 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(() => { + const cached = readJSONCache( + `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`, + ); + return cached ? itemsOrEmpty(cached) : []; + }); const [rec, setRec] = useState([]); const [latestPosts, setLatestPosts] = useState([]); const [popular, setPopular] = useState([]);