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