v2: global player, infinite scroll, image picker, threaded comments
This commit is contained in:
@@ -4,6 +4,15 @@ import { AuthContext } from "../contexts/AuthContext.ts";
|
||||
|
||||
import { type AuthResponse } from "../model.ts";
|
||||
|
||||
function isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const { authResponse, setAuthResponse } = useContext(AuthContext);
|
||||
|
||||
@@ -19,6 +28,13 @@ export const useAuth = () => {
|
||||
|
||||
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
|
||||
const token = authResponse?.token;
|
||||
|
||||
if (token && isTokenExpired(token)) {
|
||||
logout();
|
||||
// Return a synthetic 401 so callers handle it consistently
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
const isFormData = init.body instanceof FormData;
|
||||
|
||||
const res = await fetch(input, {
|
||||
|
||||
56
src/hooks/useFeedCache.ts
Normal file
56
src/hooks/useFeedCache.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useNavigationType } from "react-router";
|
||||
|
||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
interface FeedCacheEntry<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
scrollY: number;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export interface FeedCacheResult<T> {
|
||||
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
||||
saveState: (items: T[], page: number, hasMore: boolean, scrollY: number) => void;
|
||||
}
|
||||
|
||||
export function useFeedCache<T>(
|
||||
key: string,
|
||||
hydrateItem: (raw: T) => T,
|
||||
): FeedCacheResult<T> {
|
||||
const navType = useNavigationType();
|
||||
|
||||
// Read ONCE on mount. Empty deps is intentional — nav type and cache are only
|
||||
// relevant at the moment the component first mounts.
|
||||
const cached = useMemo<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||
if (navType !== "POP") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const entry = JSON.parse(raw) as FeedCacheEntry<T>;
|
||||
if (Date.now() - entry.savedAt > TTL) return null;
|
||||
return { ...entry, items: entry.items.map(hydrateItem) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const saveState = useCallback(
|
||||
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ items, page, hasMore, scrollY, savedAt: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// QuotaExceededError or SecurityError — degrade silently
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return { cached, saveState };
|
||||
}
|
||||
26
src/hooks/useInfiniteScroll.ts
Normal file
26
src/hooks/useInfiniteScroll.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export function useInfiniteScroll(onLoadMore: () => void, enabled: boolean) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleIntersect = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
if (entries[0].isIntersecting && enabled) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
[onLoadMore, enabled],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(handleIntersect, {
|
||||
rootMargin: "200px",
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [handleIntersect]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
Reference in New Issue
Block a user