Add stale cache for public data
This commit is contained in:
51
src/api.ts
51
src/api.ts
@@ -12,10 +12,59 @@ export function assetUrl(path: string | undefined | null) {
|
|||||||
return `${apiBase}${path}`;
|
return `${apiBase}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JsonCacheEntry<T> = {
|
||||||
|
cachedAt: number;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonMemoryCache = new Map<string, JsonCacheEntry<unknown>>();
|
||||||
|
const jsonStoragePrefix = "ark-json-cache:v1:";
|
||||||
|
|
||||||
|
function jsonCacheKey(path: string): string {
|
||||||
|
return `${apiBase}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJSONCache<T>(path: string): T | null {
|
||||||
|
const key = jsonCacheKey(path);
|
||||||
|
const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry<T> | undefined;
|
||||||
|
if (memoryEntry) return memoryEntry.value;
|
||||||
|
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(`${jsonStoragePrefix}${key}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
const entry = JSON.parse(raw) as JsonCacheEntry<T>;
|
||||||
|
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
|
||||||
|
return entry.value;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJSONCache<T>(path: string, value: T): void {
|
||||||
|
const key = jsonCacheKey(path);
|
||||||
|
const entry: JsonCacheEntry<T> = { cachedAt: Date.now(), value };
|
||||||
|
jsonMemoryCache.set(key, entry as JsonCacheEntry<unknown>);
|
||||||
|
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
`${jsonStoragePrefix}${key}`,
|
||||||
|
JSON.stringify(entry),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage quota / privacy-mode failures; memory cache still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getJSON<T>(path: string): Promise<T> {
|
export async function getJSON<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${apiBase}${path}`);
|
const res = await fetch(`${apiBase}${path}`);
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json() as Promise<T>;
|
const data = (await res.json()) as T;
|
||||||
|
writeJSONCache(path, data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJSONAuth<T>(path: string, token: string): Promise<T> {
|
export async function getJSONAuth<T>(path: string, token: string): Promise<T> {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { assetUrl, getJSON, itemsOrEmpty } from "../api";
|
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { langQuery, useI18n, type Lang } from "../i18n";
|
import { langQuery, useI18n, type Lang } from "../i18n";
|
||||||
|
|
||||||
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
||||||
@@ -75,14 +75,19 @@ export function FigmaBanner() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const bannersUrl = `/api/banners?lang=${bannerLangParam(lang)}`;
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
getJSON<BannerApiResponse>(`/api/banners?lang=${bannerLangParam(lang)}`)
|
|
||||||
|
const cachedBanners = readJSONCache<BannerApiResponse>(bannersUrl);
|
||||||
|
if (cachedBanners) setSlides(toSlides(itemsOrEmpty(cachedBanners.items)));
|
||||||
|
|
||||||
|
getJSON<BannerApiResponse>(bannersUrl)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSlides(toSlides(itemsOrEmpty(res.items)));
|
setSlides(toSlides(itemsOrEmpty(res.items)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setSlides([]);
|
if (!cancelled && !cachedBanners) setSlides([]);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Info, Search as SearchIcon, X } from "lucide-react";
|
import { Info, Search as SearchIcon, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { getJSON, itemsOrEmpty } from "../api";
|
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { langQuery, type Lang } from "../i18n";
|
import { langQuery, type Lang } from "../i18n";
|
||||||
import type { Post, PostListResponse } from "../types/post";
|
import type { Post, PostListResponse } from "../types/post";
|
||||||
import { MessageBubble } from "./messageStream/MessageBubble";
|
import { MessageBubble } from "./messageStream/MessageBubble";
|
||||||
@@ -75,20 +75,22 @@ export function SearchPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const tagsUrl = buildPostsUrl({
|
||||||
|
lang: langParam,
|
||||||
|
sort: "latest",
|
||||||
|
limit: TAG_SOURCE_LIMIT,
|
||||||
|
});
|
||||||
|
const cachedTags = readJSONCache<PostListResponse>(tagsUrl);
|
||||||
|
if (cachedTags) setTags(extractTags(itemsOrEmpty(cachedTags.items)));
|
||||||
|
|
||||||
setIsTagLoading(true);
|
setIsTagLoading(true);
|
||||||
getJSON<PostListResponse>(
|
getJSON<PostListResponse>(tagsUrl)
|
||||||
buildPostsUrl({
|
|
||||||
lang: langParam,
|
|
||||||
sort: "latest",
|
|
||||||
limit: TAG_SOURCE_LIMIT,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTags(extractTags(itemsOrEmpty(res.items)));
|
setTags(extractTags(itemsOrEmpty(res.items)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setTags([]);
|
if (!cancelled && !cachedTags) setTags([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsTagLoading(false);
|
if (!cancelled) setIsTagLoading(false);
|
||||||
@@ -102,17 +104,31 @@ export function SearchPanel({
|
|||||||
const showTagPosts = (tag: string) => {
|
const showTagPosts = (tag: string) => {
|
||||||
setSelectedTag(tag);
|
setSelectedTag(tag);
|
||||||
onQueryChange(tag);
|
onQueryChange(tag);
|
||||||
|
const searchUrl = buildSearchUrl({
|
||||||
|
lang: langParam,
|
||||||
|
q: tag,
|
||||||
|
limit: TAG_RESULT_LIMIT,
|
||||||
|
});
|
||||||
|
const cachedPosts = readJSONCache<PostListResponse>(searchUrl);
|
||||||
|
if (cachedPosts) {
|
||||||
|
setTagPosts(
|
||||||
|
itemsOrEmpty(cachedPosts.items).filter((post) =>
|
||||||
|
post.tags?.some((postTag) => postTag.trim() === tag),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setIsPostLoading(true);
|
setIsPostLoading(true);
|
||||||
getJSON<PostListResponse>(
|
getJSON<PostListResponse>(searchUrl)
|
||||||
buildSearchUrl({ lang: langParam, q: tag, limit: TAG_RESULT_LIMIT }),
|
|
||||||
)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const exactMatches = itemsOrEmpty(res.items).filter((post) =>
|
const exactMatches = itemsOrEmpty(res.items).filter((post) =>
|
||||||
post.tags?.some((postTag) => postTag.trim() === tag),
|
post.tags?.some((postTag) => postTag.trim() === tag),
|
||||||
);
|
);
|
||||||
setTagPosts(exactMatches);
|
setTagPosts(exactMatches);
|
||||||
})
|
})
|
||||||
.catch(() => setTagPosts([]))
|
.catch(() => {
|
||||||
|
if (!cachedPosts) setTagPosts([]);
|
||||||
|
})
|
||||||
.finally(() => setIsPostLoading(false));
|
.finally(() => setIsPostLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getJSON } from "../../../api";
|
import { getJSON, itemsOrEmpty, readJSONCache } from "../../../api";
|
||||||
import { langQuery, type Lang } from "../../../i18n";
|
import { langQuery, type Lang } from "../../../i18n";
|
||||||
import { sourceLanguageQuery } from "../../../i18nLanguages";
|
import { sourceLanguageQuery } from "../../../i18nLanguages";
|
||||||
import { MOCK_POSTS } from "../../../mocks/mockPosts";
|
import { MOCK_POSTS } from "../../../mocks/mockPosts";
|
||||||
@@ -114,6 +114,8 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const myReq = ++reqIdRef.current;
|
const myReq = ++reqIdRef.current;
|
||||||
|
|
||||||
|
let showedCached = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
await new Promise((r) => setTimeout(r, MOCK_DELAY_MS));
|
await new Promise((r) => setTimeout(r, MOCK_DELAY_MS));
|
||||||
@@ -133,9 +135,26 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
params,
|
params,
|
||||||
resetting ? undefined : cursorRef.current,
|
resetting ? undefined : cursorRef.current,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (resetting) {
|
||||||
|
const cachedPage = readJSONCache<PostListResponse>(url);
|
||||||
|
if (cachedPage && myReq === reqIdRef.current) {
|
||||||
|
showedCached = true;
|
||||||
|
const cachedItems = itemsOrEmpty(cachedPage.items);
|
||||||
|
setItems(cachedItems);
|
||||||
|
cursorRef.current = cachedPage.nextCursor;
|
||||||
|
const cachedMore = !!cachedPage.nextCursor;
|
||||||
|
setHasMore(cachedMore);
|
||||||
|
hasMoreRef.current = cachedMore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await getJSON<PostListResponse>(url);
|
const res = await getJSON<PostListResponse>(url);
|
||||||
if (myReq !== reqIdRef.current) return;
|
if (myReq !== reqIdRef.current) return;
|
||||||
setItems((prev) => (resetting ? res.items : [...prev, ...res.items]));
|
const freshItems = itemsOrEmpty(res.items);
|
||||||
|
setItems((prev) =>
|
||||||
|
resetting ? freshItems : [...prev, ...freshItems],
|
||||||
|
);
|
||||||
cursorRef.current = res.nextCursor;
|
cursorRef.current = res.nextCursor;
|
||||||
const more = !!res.nextCursor;
|
const more = !!res.nextCursor;
|
||||||
setHasMore(more);
|
setHasMore(more);
|
||||||
@@ -143,7 +162,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myReq !== reqIdRef.current) return;
|
if (myReq !== reqIdRef.current) return;
|
||||||
setError(String(e));
|
if (!showedCached) setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
if (myReq === reqIdRef.current) setIsLoading(false);
|
if (myReq === reqIdRef.current) setIsLoading(false);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
|
||||||
import { CategoryIcon } from "../../components/CategoryIcon";
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
@@ -34,17 +34,32 @@ export function CategoriesPage() {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getJSON<Category[]>(
|
let cancelled = false;
|
||||||
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
|
const categoriesUrl = `/api/categories?lang=${encodeURIComponent(
|
||||||
)
|
langQuery(lang),
|
||||||
.then((items) =>
|
)}`;
|
||||||
setCats(
|
const applyCategories = (items: Category[]) =>
|
||||||
itemsOrEmpty(items).sort(
|
setCats(
|
||||||
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
|
itemsOrEmpty(items).sort(
|
||||||
),
|
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.catch((e) => setErr(String(e)));
|
|
||||||
|
setErr(null);
|
||||||
|
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
|
||||||
|
if (cachedCategories) applyCategories(cachedCategories);
|
||||||
|
|
||||||
|
getJSON<Category[]>(categoriesUrl)
|
||||||
|
.then((items) => {
|
||||||
|
if (!cancelled) applyCategories(items);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled && !cachedCategories) setErr(String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
|
||||||
import { AssetStreamPage } from "../../components/AssetStreamPage";
|
import { AssetStreamPage } from "../../components/AssetStreamPage";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
@@ -13,18 +13,32 @@ export function CategoryPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
setTitle(slug);
|
let cancelled = false;
|
||||||
getJSON<Category[]>(
|
const categoriesUrl = `/api/categories?lang=${encodeURIComponent(
|
||||||
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
|
langQuery(lang),
|
||||||
)
|
)}`;
|
||||||
.then((cats) =>
|
const applyTitle = (cats: Category[]) =>
|
||||||
setTitle(
|
setTitle(
|
||||||
cleanCategoryDisplayName(
|
cleanCategoryDisplayName(
|
||||||
itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug,
|
itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.catch(() => setTitle(slug));
|
|
||||||
|
setTitle(slug);
|
||||||
|
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
|
||||||
|
if (cachedCategories) applyTitle(cachedCategories);
|
||||||
|
|
||||||
|
getJSON<Category[]>(categoriesUrl)
|
||||||
|
.then((cats) => {
|
||||||
|
if (!cancelled) applyTitle(cats);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled && !cachedCategories) setTitle(slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [slug, lang]);
|
}, [slug, lang]);
|
||||||
|
|
||||||
return <AssetStreamPage title={title || slug} scope={scope} />;
|
return <AssetStreamPage title={title || slug} scope={scope} />;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
|
||||||
import { CategoryIcon } from "../../components/CategoryIcon";
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
||||||
import { FigmaBanner } from "../../components/FigmaBanner";
|
import { FigmaBanner } from "../../components/FigmaBanner";
|
||||||
import {
|
import {
|
||||||
@@ -61,41 +61,80 @@ export function Home() {
|
|||||||
const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 });
|
const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
const langParam = encodeURIComponent(langQuery(lang));
|
const langParam = encodeURIComponent(langQuery(lang));
|
||||||
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
||||||
const catQ = `?lang=${langParam}`;
|
const catQ = `?lang=${langParam}`;
|
||||||
const postQ = `?lang=${langParam}&language=${languageParam}`;
|
const postQ = `?lang=${langParam}&language=${languageParam}`;
|
||||||
|
const categoriesUrl = `/api/categories${catQ}`;
|
||||||
|
const recommendedUrl = `/api/posts/recommended${postQ}&limit=12`;
|
||||||
|
const latestUrl = `/api/posts${postQ}&sort=latest&limit=12`;
|
||||||
|
const popularUrl = `/api/posts${postQ}&sort=popular&limit=5`;
|
||||||
|
|
||||||
|
const applyHomeData = (
|
||||||
|
c: Category[],
|
||||||
|
r: { items: Post[] },
|
||||||
|
l: { items: Post[] },
|
||||||
|
p: { items: Post[] },
|
||||||
|
) => {
|
||||||
|
const categoryItems = itemsOrEmpty(c);
|
||||||
|
setCats(categoryItems);
|
||||||
|
setRec(
|
||||||
|
itemsOrEmpty(r.items).map((post) =>
|
||||||
|
postToResource(post, lang, categoryItems),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const latestItems = itemsOrEmpty(l.items);
|
||||||
|
setLatestPosts(latestItems);
|
||||||
|
setLatest(
|
||||||
|
latestItems.map((post) => postToResource(post, lang, categoryItems)),
|
||||||
|
);
|
||||||
|
const popularItems = itemsOrEmpty<Post>(p.items);
|
||||||
|
setPopularPosts(popularItems);
|
||||||
|
setPopular(
|
||||||
|
popularItems.map((post) => postToResource(post, lang, categoryItems)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
setErr(null);
|
||||||
|
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
|
||||||
|
const cachedRecommended = readJSONCache<{ items: Post[] }>(recommendedUrl);
|
||||||
|
const cachedLatest = readJSONCache<{ items: Post[] }>(latestUrl);
|
||||||
|
const cachedPopular = readJSONCache<{ items: Post[] }>(popularUrl);
|
||||||
|
const showedCached = !!(
|
||||||
|
cachedCategories &&
|
||||||
|
cachedRecommended &&
|
||||||
|
cachedLatest
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showedCached) {
|
||||||
|
applyHomeData(
|
||||||
|
cachedCategories,
|
||||||
|
cachedRecommended,
|
||||||
|
cachedLatest,
|
||||||
|
cachedPopular ?? { items: [] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getJSON<Category[]>(`/api/categories${catQ}`),
|
getJSON<Category[]>(categoriesUrl),
|
||||||
getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`),
|
getJSON<{ items: Post[] }>(recommendedUrl),
|
||||||
getJSON<{ items: Post[] }>(`/api/posts${postQ}&sort=latest&limit=12`),
|
getJSON<{ items: Post[] }>(latestUrl),
|
||||||
getJSON<{ items: Post[] }>(
|
getJSON<{ items: Post[] }>(popularUrl).catch(
|
||||||
`/api/posts${postQ}&sort=popular&limit=5`,
|
(): { items: Post[] } => cachedPopular ?? { items: [] },
|
||||||
).catch((): { items: Post[] } => ({ items: [] })),
|
),
|
||||||
])
|
])
|
||||||
.then(([c, r, l, p]) => {
|
.then(([c, r, l, p]) => {
|
||||||
setCats(itemsOrEmpty(c));
|
if (cancelled) return;
|
||||||
setRec(
|
applyHomeData(c, r, l, p);
|
||||||
itemsOrEmpty(r.items).map((post) =>
|
|
||||||
postToResource(post, lang, itemsOrEmpty(c)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const latestItems = itemsOrEmpty(l.items);
|
|
||||||
setLatestPosts(latestItems);
|
|
||||||
setLatest(
|
|
||||||
latestItems.map((post) =>
|
|
||||||
postToResource(post, lang, itemsOrEmpty(c)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const popularItems = itemsOrEmpty<Post>(p.items);
|
|
||||||
setPopularPosts(popularItems);
|
|
||||||
setPopular(
|
|
||||||
popularItems.map((post) =>
|
|
||||||
postToResource(post, lang, itemsOrEmpty(c)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((e) => setErr(String(e)));
|
.catch((e) => {
|
||||||
|
if (!cancelled && !showedCached) setErr(String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
const iconKeyForResource = (r: PostBackedResource) =>
|
const iconKeyForResource = (r: PostBackedResource) =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
|
||||||
import { RecommendedCard } from "../../components/RecommendedCard";
|
import { RecommendedCard } from "../../components/RecommendedCard";
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
@@ -16,23 +16,46 @@ export function OfficialRecommendationsPage() {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
const langParam = encodeURIComponent(langQuery(lang));
|
const langParam = encodeURIComponent(langQuery(lang));
|
||||||
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
||||||
|
const categoriesUrl = `/api/categories?lang=${langParam}`;
|
||||||
|
const recommendedUrl = `/api/posts/recommended?lang=${langParam}&language=${languageParam}&limit=100`;
|
||||||
|
|
||||||
|
const applyItems = (
|
||||||
|
categories: Category[],
|
||||||
|
recommended: { items: Post[] },
|
||||||
|
) => {
|
||||||
|
const cats = itemsOrEmpty(categories);
|
||||||
|
setItems(
|
||||||
|
itemsOrEmpty(recommended.items).map((post) =>
|
||||||
|
postToResource(post, lang, cats),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
setErr(null);
|
||||||
|
const cachedCategories = readJSONCache<Category[]>(categoriesUrl);
|
||||||
|
const cachedRecommended = readJSONCache<{ items: Post[] }>(recommendedUrl);
|
||||||
|
const showedCached = !!(cachedCategories && cachedRecommended);
|
||||||
|
|
||||||
|
if (showedCached) applyItems(cachedCategories, cachedRecommended);
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getJSON<Category[]>(`/api/categories?lang=${langParam}`),
|
getJSON<Category[]>(categoriesUrl),
|
||||||
getJSON<{ items: Post[] }>(
|
getJSON<{ items: Post[] }>(recommendedUrl),
|
||||||
`/api/posts/recommended?lang=${langParam}&language=${languageParam}&limit=100`,
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
.then(([categories, recommended]) => {
|
.then(([categories, recommended]) => {
|
||||||
const cats = itemsOrEmpty(categories);
|
if (cancelled) return;
|
||||||
setItems(
|
applyItems(categories, recommended);
|
||||||
itemsOrEmpty(recommended.items).map((post) =>
|
|
||||||
postToResource(post, lang, cats),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((e) => setErr(String(e)));
|
.catch((e) => {
|
||||||
|
if (!cancelled && !showedCached) setErr(String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user