diff --git a/src/api.ts b/src/api.ts index 21afe8f..28dee0a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,10 +12,59 @@ export function assetUrl(path: string | undefined | null) { return `${apiBase}${path}`; } +type JsonCacheEntry = { + cachedAt: number; + value: T; +}; + +const jsonMemoryCache = new Map>(); +const jsonStoragePrefix = "ark-json-cache:v1:"; + +function jsonCacheKey(path: string): string { + return `${apiBase}${path}`; +} + +export function readJSONCache(path: string): T | null { + const key = jsonCacheKey(path); + const memoryEntry = jsonMemoryCache.get(key) as JsonCacheEntry | 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; + jsonMemoryCache.set(key, entry as JsonCacheEntry); + return entry.value; + } catch { + return null; + } +} + +function writeJSONCache(path: string, value: T): void { + const key = jsonCacheKey(path); + const entry: JsonCacheEntry = { cachedAt: Date.now(), value }; + jsonMemoryCache.set(key, entry as JsonCacheEntry); + + 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(path: string): Promise { const res = await fetch(`${apiBase}${path}`); if (!res.ok) throw new Error(await res.text()); - return res.json() as Promise; + const data = (await res.json()) as T; + writeJSONCache(path, data); + return data; } export async function getJSONAuth(path: string, token: string): Promise { diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index 121aad7..fe45642 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -5,7 +5,7 @@ import { useState, type PointerEvent as ReactPointerEvent, } from "react"; -import { assetUrl, getJSON, itemsOrEmpty } from "../api"; +import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { langQuery, useI18n, type Lang } from "../i18n"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; @@ -75,14 +75,19 @@ export function FigmaBanner() { useEffect(() => { let cancelled = false; + const bannersUrl = `/api/banners?lang=${bannerLangParam(lang)}`; setActiveIndex(0); - getJSON(`/api/banners?lang=${bannerLangParam(lang)}`) + + const cachedBanners = readJSONCache(bannersUrl); + if (cachedBanners) setSlides(toSlides(itemsOrEmpty(cachedBanners.items))); + + getJSON(bannersUrl) .then((res) => { if (cancelled) return; setSlides(toSlides(itemsOrEmpty(res.items))); }) .catch(() => { - if (!cancelled) setSlides([]); + if (!cancelled && !cachedBanners) setSlides([]); }); return () => { cancelled = true; diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 8e43d67..c24bee0 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -1,6 +1,6 @@ import { Info, Search as SearchIcon, X } from "lucide-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 type { Post, PostListResponse } from "../types/post"; import { MessageBubble } from "./messageStream/MessageBubble"; @@ -75,20 +75,22 @@ export function SearchPanel({ useEffect(() => { let cancelled = false; + const tagsUrl = buildPostsUrl({ + lang: langParam, + sort: "latest", + limit: TAG_SOURCE_LIMIT, + }); + const cachedTags = readJSONCache(tagsUrl); + if (cachedTags) setTags(extractTags(itemsOrEmpty(cachedTags.items))); + setIsTagLoading(true); - getJSON( - buildPostsUrl({ - lang: langParam, - sort: "latest", - limit: TAG_SOURCE_LIMIT, - }), - ) + getJSON(tagsUrl) .then((res) => { if (cancelled) return; setTags(extractTags(itemsOrEmpty(res.items))); }) .catch(() => { - if (!cancelled) setTags([]); + if (!cancelled && !cachedTags) setTags([]); }) .finally(() => { if (!cancelled) setIsTagLoading(false); @@ -102,17 +104,31 @@ export function SearchPanel({ const showTagPosts = (tag: string) => { setSelectedTag(tag); onQueryChange(tag); + const searchUrl = buildSearchUrl({ + lang: langParam, + q: tag, + limit: TAG_RESULT_LIMIT, + }); + const cachedPosts = readJSONCache(searchUrl); + if (cachedPosts) { + setTagPosts( + itemsOrEmpty(cachedPosts.items).filter((post) => + post.tags?.some((postTag) => postTag.trim() === tag), + ), + ); + } + setIsPostLoading(true); - getJSON( - buildSearchUrl({ lang: langParam, q: tag, limit: TAG_RESULT_LIMIT }), - ) + getJSON(searchUrl) .then((res) => { const exactMatches = itemsOrEmpty(res.items).filter((post) => post.tags?.some((postTag) => postTag.trim() === tag), ); setTagPosts(exactMatches); }) - .catch(() => setTagPosts([])) + .catch(() => { + if (!cachedPosts) setTagPosts([]); + }) .finally(() => setIsPostLoading(false)); }; diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index 3bfcf6a..fc7643b 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { getJSON } from "../../../api"; +import { getJSON, itemsOrEmpty, readJSONCache } from "../../../api"; import { langQuery, type Lang } from "../../../i18n"; import { sourceLanguageQuery } from "../../../i18nLanguages"; import { MOCK_POSTS } from "../../../mocks/mockPosts"; @@ -114,6 +114,8 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { setError(null); const myReq = ++reqIdRef.current; + let showedCached = false; + try { if (USE_MOCK) { await new Promise((r) => setTimeout(r, MOCK_DELAY_MS)); @@ -133,9 +135,26 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { params, resetting ? undefined : cursorRef.current, ); + + if (resetting) { + const cachedPage = readJSONCache(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(url); 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; const more = !!res.nextCursor; setHasMore(more); @@ -143,7 +162,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { } } catch (e) { if (myReq !== reqIdRef.current) return; - setError(String(e)); + if (!showedCached) setError(String(e)); } finally { if (myReq === reqIdRef.current) setIsLoading(false); loadingRef.current = false; diff --git a/src/pages/Categories/index.tsx b/src/pages/Categories/index.tsx index e8ec19b..58c0071 100644 --- a/src/pages/Categories/index.tsx +++ b/src/pages/Categories/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; 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 { SectionHeader } from "../../components/SectionHeader"; import { langQuery, useI18n } from "../../i18n"; @@ -34,17 +34,32 @@ export function CategoriesPage() { const [err, setErr] = useState(null); useEffect(() => { - getJSON( - `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`, - ) - .then((items) => - setCats( - itemsOrEmpty(items).sort( - (a, b) => figmaCategoryRank(a) - figmaCategoryRank(b), - ), + let cancelled = false; + const categoriesUrl = `/api/categories?lang=${encodeURIComponent( + langQuery(lang), + )}`; + const applyCategories = (items: Category[]) => + setCats( + itemsOrEmpty(items).sort( + (a, b) => figmaCategoryRank(a) - figmaCategoryRank(b), ), - ) - .catch((e) => setErr(String(e))); + ); + + setErr(null); + const cachedCategories = readJSONCache(categoriesUrl); + if (cachedCategories) applyCategories(cachedCategories); + + getJSON(categoriesUrl) + .then((items) => { + if (!cancelled) applyCategories(items); + }) + .catch((e) => { + if (!cancelled && !cachedCategories) setErr(String(e)); + }); + + return () => { + cancelled = true; + }; }, [lang]); if (err) { diff --git a/src/pages/Category/index.tsx b/src/pages/Category/index.tsx index e5b265c..6f6302b 100644 --- a/src/pages/Category/index.tsx +++ b/src/pages/Category/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; 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 { langQuery, useI18n } from "../../i18n"; import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; @@ -13,18 +13,32 @@ export function CategoryPage() { useEffect(() => { if (!slug) return; - setTitle(slug); - getJSON( - `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`, - ) - .then((cats) => - setTitle( - cleanCategoryDisplayName( - itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug, - ), + let cancelled = false; + const categoriesUrl = `/api/categories?lang=${encodeURIComponent( + langQuery(lang), + )}`; + const applyTitle = (cats: Category[]) => + setTitle( + cleanCategoryDisplayName( + itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug, ), - ) - .catch(() => setTitle(slug)); + ); + + setTitle(slug); + const cachedCategories = readJSONCache(categoriesUrl); + if (cachedCategories) applyTitle(cachedCategories); + + getJSON(categoriesUrl) + .then((cats) => { + if (!cancelled) applyTitle(cats); + }) + .catch(() => { + if (!cancelled && !cachedCategories) setTitle(slug); + }); + + return () => { + cancelled = true; + }; }, [slug, lang]); return ; diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 953567f..f42b901 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,7 +1,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { Link, useLocation } from "react-router-dom"; 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 { FigmaBanner } from "../../components/FigmaBanner"; import { @@ -61,41 +61,80 @@ export function Home() { const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 }); useEffect(() => { + let cancelled = false; const langParam = encodeURIComponent(langQuery(lang)); const languageParam = encodeURIComponent(sourceLanguageQuery(lang)); const catQ = `?lang=${langParam}`; 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(p.items); + setPopularPosts(popularItems); + setPopular( + popularItems.map((post) => postToResource(post, lang, categoryItems)), + ); + }; + + setErr(null); + const cachedCategories = readJSONCache(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([ - getJSON(`/api/categories${catQ}`), - getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`), - getJSON<{ items: Post[] }>(`/api/posts${postQ}&sort=latest&limit=12`), - getJSON<{ items: Post[] }>( - `/api/posts${postQ}&sort=popular&limit=5`, - ).catch((): { items: Post[] } => ({ items: [] })), + getJSON(categoriesUrl), + getJSON<{ items: Post[] }>(recommendedUrl), + getJSON<{ items: Post[] }>(latestUrl), + getJSON<{ items: Post[] }>(popularUrl).catch( + (): { items: Post[] } => cachedPopular ?? { items: [] }, + ), ]) .then(([c, r, l, p]) => { - setCats(itemsOrEmpty(c)); - setRec( - 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(p.items); - setPopularPosts(popularItems); - setPopular( - popularItems.map((post) => - postToResource(post, lang, itemsOrEmpty(c)), - ), - ); + if (cancelled) return; + applyHomeData(c, r, l, p); }) - .catch((e) => setErr(String(e))); + .catch((e) => { + if (!cancelled && !showedCached) setErr(String(e)); + }); + + return () => { + cancelled = true; + }; }, [lang]); const iconKeyForResource = (r: PostBackedResource) => diff --git a/src/pages/OfficialRecommendations/index.tsx b/src/pages/OfficialRecommendations/index.tsx index cea4d8d..b52be7f 100644 --- a/src/pages/OfficialRecommendations/index.tsx +++ b/src/pages/OfficialRecommendations/index.tsx @@ -1,5 +1,5 @@ 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 { SectionHeader } from "../../components/SectionHeader"; import { langQuery, useI18n } from "../../i18n"; @@ -16,23 +16,46 @@ export function OfficialRecommendationsPage() { const [err, setErr] = useState(null); useEffect(() => { + let cancelled = false; const langParam = encodeURIComponent(langQuery(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(categoriesUrl); + const cachedRecommended = readJSONCache<{ items: Post[] }>(recommendedUrl); + const showedCached = !!(cachedCategories && cachedRecommended); + + if (showedCached) applyItems(cachedCategories, cachedRecommended); + Promise.all([ - getJSON(`/api/categories?lang=${langParam}`), - getJSON<{ items: Post[] }>( - `/api/posts/recommended?lang=${langParam}&language=${languageParam}&limit=100`, - ), + getJSON(categoriesUrl), + getJSON<{ items: Post[] }>(recommendedUrl), ]) .then(([categories, recommended]) => { - const cats = itemsOrEmpty(categories); - setItems( - itemsOrEmpty(recommended.items).map((post) => - postToResource(post, lang, cats), - ), - ); + if (cancelled) return; + applyItems(categories, recommended); }) - .catch((e) => setErr(String(e))); + .catch((e) => { + if (!cancelled && !showedCached) setErr(String(e)); + }); + + return () => { + cancelled = true; + }; }, [lang]); if (err) {